[
  {
    "path": ".editorconfig",
    "content": "[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 2\nindent_style = space\ninsert_final_newline = true\nmax_line_length = 120\ntab_width = 8\nij_continuation_indent_size = 4\nij_formatter_off_tag = @formatter:off\nij_formatter_on_tag = @formatter:on\nij_formatter_tags_enabled = false\nij_smart_tabs = false\nij_visual_guides = none\nij_wrap_on_typing = false\n\n[*.css]\nij_css_align_closing_brace_with_properties = false\nij_css_blank_lines_around_nested_selector = 1\nij_css_blank_lines_between_blocks = 1\nij_css_brace_placement = end_of_line\nij_css_enforce_quotes_on_format = false\nij_css_hex_color_long_format = false\nij_css_hex_color_lower_case = false\nij_css_hex_color_short_format = false\nij_css_hex_color_upper_case = false\nij_css_keep_blank_lines_in_code = 2\nij_css_keep_indents_on_empty_lines = false\nij_css_keep_single_line_blocks = false\nij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_css_space_after_colon = true\nij_css_space_before_opening_brace = true\nij_css_use_double_quotes = true\nij_css_value_alignment = do_not_align\n\n[*.feature]\nij_continuation_indent_size = 8\nij_gherkin_keep_indents_on_empty_lines = false\n\n[*.gsp]\nindent_size = 4\nij_continuation_indent_size = 8\nij_gsp_keep_indents_on_empty_lines = false\n\n[*.haml]\nij_continuation_indent_size = 8\nij_haml_keep_indents_on_empty_lines = false\n\n[*.java]\nij_java_align_consecutive_assignments = false\nij_java_align_consecutive_variable_declarations = false\nij_java_align_group_field_declarations = false\nij_java_align_multiline_annotation_parameters = false\nij_java_align_multiline_array_initializer_expression = false\nij_java_align_multiline_assignment = false\nij_java_align_multiline_binary_operation = false\nij_java_align_multiline_chained_methods = false\nij_java_align_multiline_extends_list = false\nij_java_align_multiline_for = false\nij_java_align_multiline_method_parentheses = false\nij_java_align_multiline_parameters = false\nij_java_align_multiline_parameters_in_calls = false\nij_java_align_multiline_parenthesized_expression = false\nij_java_align_multiline_records = true\nij_java_align_multiline_resources = false\nij_java_align_multiline_ternary_operation = false\nij_java_align_multiline_text_blocks = false\nij_java_align_multiline_throws_list = false\nij_java_align_subsequent_simple_methods = false\nij_java_align_throws_keyword = false\nij_java_annotation_parameter_wrap = off\nij_java_array_initializer_new_line_after_left_brace = false\nij_java_array_initializer_right_brace_on_new_line = false\nij_java_array_initializer_wrap = normal\nij_java_assert_statement_colon_on_next_line = false\nij_java_assert_statement_wrap = off\nij_java_assignment_wrap = off\nij_java_binary_operation_sign_on_next_line = true\nij_java_binary_operation_wrap = normal\nij_java_blank_lines_after_anonymous_class_header = 0\nij_java_blank_lines_after_class_header = 1\nij_java_blank_lines_after_imports = 1\nij_java_blank_lines_after_package = 1\nij_java_blank_lines_around_class = 1\nij_java_blank_lines_around_field = 0\nij_java_blank_lines_around_field_in_interface = 0\nij_java_blank_lines_around_initializer = 1\nij_java_blank_lines_around_method = 1\nij_java_blank_lines_around_method_in_interface = 1\nij_java_blank_lines_before_class_end = 0\nij_java_blank_lines_before_imports = 1\nij_java_blank_lines_before_method_body = 0\nij_java_blank_lines_before_package = 0\nij_java_block_brace_style = end_of_line\nij_java_block_comment_at_first_column = true\nij_java_call_parameters_new_line_after_left_paren = false\nij_java_call_parameters_right_paren_on_new_line = false\nij_java_call_parameters_wrap = normal\nij_java_case_statement_on_separate_line = true\nij_java_catch_on_new_line = false\nij_java_class_annotation_wrap = split_into_lines\nij_java_class_brace_style = end_of_line\nij_java_class_count_to_use_import_on_demand = 999\nij_java_class_names_in_javadoc = 1\nij_java_do_not_indent_top_level_class_members = false\nij_java_do_not_wrap_after_single_annotation = false\nij_java_do_while_brace_force = always\nij_java_doc_add_blank_line_after_description = true\nij_java_doc_add_blank_line_after_param_comments = false\nij_java_doc_add_blank_line_after_return = false\nij_java_doc_add_p_tag_on_empty_lines = true\nij_java_doc_align_exception_comments = true\nij_java_doc_align_param_comments = true\nij_java_doc_do_not_wrap_if_one_line = false\nij_java_doc_enable_formatting = true\nij_java_doc_enable_leading_asterisks = true\nij_java_doc_indent_on_continuation = false\nij_java_doc_keep_empty_lines = true\nij_java_doc_keep_empty_parameter_tag = true\nij_java_doc_keep_empty_return_tag = true\nij_java_doc_keep_empty_throws_tag = true\nij_java_doc_keep_invalid_tags = true\nij_java_doc_param_description_on_new_line = false\nij_java_doc_preserve_line_breaks = false\nij_java_doc_use_throws_not_exception_tag = true\nij_java_else_on_new_line = false\nij_java_entity_dd_suffix = EJB\nij_java_entity_eb_suffix = Bean\nij_java_entity_hi_suffix = Home\nij_java_entity_lhi_prefix = Local\nij_java_entity_lhi_suffix = Home\nij_java_entity_li_prefix = Local\nij_java_entity_pk_class = java.lang.String\nij_java_entity_vo_suffix = VO\nij_java_enum_constants_wrap = off\nij_java_extends_keyword_wrap = off\nij_java_extends_list_wrap = normal\nij_java_field_annotation_wrap = split_into_lines\nij_java_finally_on_new_line = false\nij_java_for_brace_force = always\nij_java_for_statement_new_line_after_left_paren = false\nij_java_for_statement_right_paren_on_new_line = false\nij_java_for_statement_wrap = normal\nij_java_generate_final_locals = true\nij_java_generate_final_parameters = true\nij_java_if_brace_force = always\nij_java_imports_layout = $*,|,*\nij_java_indent_case_from_switch = true\nij_java_insert_inner_class_imports = false\nij_java_insert_override_annotation = true\nij_java_keep_blank_lines_before_right_brace = 2\nij_java_keep_blank_lines_between_package_declaration_and_header = 2\nij_java_keep_blank_lines_in_code = 1\nij_java_keep_blank_lines_in_declarations = 2\nij_java_keep_control_statement_in_one_line = false\nij_java_keep_first_column_comment = true\nij_java_keep_indents_on_empty_lines = false\nij_java_keep_line_breaks = true\nij_java_keep_multiple_expressions_in_one_line = false\nij_java_keep_simple_blocks_in_one_line = false\nij_java_keep_simple_classes_in_one_line = true\nij_java_keep_simple_lambdas_in_one_line = false\nij_java_keep_simple_methods_in_one_line = false\nij_java_label_indent_absolute = false\nij_java_label_indent_size = 0\nij_java_lambda_brace_style = end_of_line\nij_java_layout_static_imports_separately = true\nij_java_line_comment_add_space = false\nij_java_line_comment_at_first_column = true\nij_java_message_dd_suffix = EJB\nij_java_message_eb_suffix = Bean\nij_java_method_annotation_wrap = split_into_lines\nij_java_method_brace_style = end_of_line\nij_java_method_call_chain_wrap = normal\nij_java_method_parameters_new_line_after_left_paren = false\nij_java_method_parameters_right_paren_on_new_line = false\nij_java_method_parameters_wrap = normal\nij_java_modifier_list_wrap = false\nij_java_names_count_to_use_import_on_demand = 999\nij_java_new_line_after_lparen_in_record_header = false\nij_java_parameter_annotation_wrap = off\nij_java_parentheses_expression_new_line_after_left_paren = false\nij_java_parentheses_expression_right_paren_on_new_line = false\nij_java_place_assignment_sign_on_next_line = false\nij_java_prefer_longer_names = true\nij_java_prefer_parameters_wrap = false\nij_java_record_components_wrap = normal\nij_java_repeat_synchronized = true\nij_java_replace_instanceof_and_cast = false\nij_java_replace_null_check = true\nij_java_replace_sum_lambda_with_method_ref = true\nij_java_resource_list_new_line_after_left_paren = false\nij_java_resource_list_right_paren_on_new_line = false\nij_java_resource_list_wrap = off\nij_java_rparen_on_new_line_in_record_header = false\nij_java_session_dd_suffix = EJB\nij_java_session_eb_suffix = Bean\nij_java_session_hi_suffix = Home\nij_java_session_lhi_prefix = Local\nij_java_session_lhi_suffix = Home\nij_java_session_li_prefix = Local\nij_java_session_si_suffix = Service\nij_java_space_after_closing_angle_bracket_in_type_argument = false\nij_java_space_after_colon = true\nij_java_space_after_comma = true\nij_java_space_after_comma_in_type_arguments = true\nij_java_space_after_for_semicolon = true\nij_java_space_after_quest = true\nij_java_space_after_type_cast = true\nij_java_space_before_annotation_array_initializer_left_brace = false\nij_java_space_before_annotation_parameter_list = false\nij_java_space_before_array_initializer_left_brace = false\nij_java_space_before_catch_keyword = true\nij_java_space_before_catch_left_brace = true\nij_java_space_before_catch_parentheses = true\nij_java_space_before_class_left_brace = true\nij_java_space_before_colon = true\nij_java_space_before_colon_in_foreach = true\nij_java_space_before_comma = false\nij_java_space_before_do_left_brace = true\nij_java_space_before_else_keyword = true\nij_java_space_before_else_left_brace = true\nij_java_space_before_finally_keyword = true\nij_java_space_before_finally_left_brace = true\nij_java_space_before_for_left_brace = true\nij_java_space_before_for_parentheses = true\nij_java_space_before_for_semicolon = false\nij_java_space_before_if_left_brace = true\nij_java_space_before_if_parentheses = true\nij_java_space_before_method_call_parentheses = false\nij_java_space_before_method_left_brace = true\nij_java_space_before_method_parentheses = false\nij_java_space_before_opening_angle_bracket_in_type_parameter = false\nij_java_space_before_quest = true\nij_java_space_before_switch_left_brace = true\nij_java_space_before_switch_parentheses = true\nij_java_space_before_synchronized_left_brace = true\nij_java_space_before_synchronized_parentheses = true\nij_java_space_before_try_left_brace = true\nij_java_space_before_try_parentheses = true\nij_java_space_before_type_parameter_list = false\nij_java_space_before_while_keyword = true\nij_java_space_before_while_left_brace = true\nij_java_space_before_while_parentheses = true\nij_java_space_inside_one_line_enum_braces = false\nij_java_space_within_empty_array_initializer_braces = false\nij_java_space_within_empty_method_call_parentheses = false\nij_java_space_within_empty_method_parentheses = false\nij_java_spaces_around_additive_operators = true\nij_java_spaces_around_assignment_operators = true\nij_java_spaces_around_bitwise_operators = true\nij_java_spaces_around_equality_operators = true\nij_java_spaces_around_lambda_arrow = true\nij_java_spaces_around_logical_operators = true\nij_java_spaces_around_method_ref_dbl_colon = false\nij_java_spaces_around_multiplicative_operators = true\nij_java_spaces_around_relational_operators = true\nij_java_spaces_around_shift_operators = true\nij_java_spaces_around_type_bounds_in_type_parameters = true\nij_java_spaces_around_unary_operator = false\nij_java_spaces_within_angle_brackets = false\nij_java_spaces_within_annotation_parentheses = false\nij_java_spaces_within_array_initializer_braces = false\nij_java_spaces_within_braces = false\nij_java_spaces_within_brackets = false\nij_java_spaces_within_cast_parentheses = false\nij_java_spaces_within_catch_parentheses = false\nij_java_spaces_within_for_parentheses = false\nij_java_spaces_within_if_parentheses = false\nij_java_spaces_within_method_call_parentheses = false\nij_java_spaces_within_method_parentheses = false\nij_java_spaces_within_parentheses = false\nij_java_spaces_within_record_header = false\nij_java_spaces_within_switch_parentheses = false\nij_java_spaces_within_synchronized_parentheses = false\nij_java_spaces_within_try_parentheses = false\nij_java_spaces_within_while_parentheses = false\nij_java_special_else_if_treatment = true\nij_java_subclass_name_suffix = Impl\nij_java_ternary_operation_signs_on_next_line = true\nij_java_ternary_operation_wrap = normal\nij_java_test_name_suffix = Test\nij_java_throws_keyword_wrap = normal\nij_java_throws_list_wrap = off\nij_java_use_external_annotations = false\nij_java_use_fq_class_names = false\nij_java_use_relative_indents = false\nij_java_use_single_class_imports = true\nij_java_variable_annotation_wrap = off\nij_java_visibility = public\nij_java_while_brace_force = always\nij_java_while_on_new_line = false\nij_java_wrap_comments = true\nij_java_wrap_first_method_in_call_chain = false\nij_java_wrap_long_lines = false\n\n[*.less]\nij_continuation_indent_size = 8\nij_less_align_closing_brace_with_properties = false\nij_less_blank_lines_around_nested_selector = 1\nij_less_blank_lines_between_blocks = 1\nij_less_brace_placement = 0\nij_less_enforce_quotes_on_format = false\nij_less_hex_color_long_format = false\nij_less_hex_color_lower_case = false\nij_less_hex_color_short_format = false\nij_less_hex_color_upper_case = false\nij_less_keep_blank_lines_in_code = 2\nij_less_keep_indents_on_empty_lines = false\nij_less_keep_single_line_blocks = false\nij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_less_space_after_colon = true\nij_less_space_before_opening_brace = true\nij_less_use_double_quotes = true\nij_less_value_alignment = 0\n\n[*.proto]\nmax_line_length = 80\nij_continuation_indent_size = 2\nij_protobuf_keep_blank_lines_in_code = 2\nij_protobuf_keep_indents_on_empty_lines = false\nij_protobuf_keep_line_breaks = true\nij_protobuf_space_after_comma = true\nij_protobuf_space_before_comma = false\nij_protobuf_spaces_around_assignment_operators = true\nij_protobuf_spaces_within_braces = false\nij_protobuf_spaces_within_brackets = false\n\n[*.rs]\nindent_size = 4\nij_rust_align_multiline_chained_methods = false\nij_rust_align_multiline_parameters = true\nij_rust_align_multiline_parameters_in_calls = true\nij_rust_align_ret_type = true\nij_rust_align_type_params = false\nij_rust_align_where_bounds = true\nij_rust_align_where_clause = false\nij_rust_allow_one_line_match = false\nij_rust_block_comment_at_first_column = false\nij_rust_indent_where_clause = true\nij_rust_keep_blank_lines_in_code = 2\nij_rust_keep_blank_lines_in_declarations = 2\nij_rust_keep_indents_on_empty_lines = false\nij_rust_keep_line_breaks = true\nij_rust_line_comment_add_space = true\nij_rust_line_comment_at_first_column = false\nij_rust_min_number_of_blanks_between_items = 1\nij_rust_preserve_punctuation = false\nij_rust_spaces_around_assoc_type_binding = false\n\n[*.sass]\nij_sass_align_closing_brace_with_properties = false\nij_sass_blank_lines_around_nested_selector = 1\nij_sass_blank_lines_between_blocks = 1\nij_sass_brace_placement = 0\nij_sass_enforce_quotes_on_format = false\nij_sass_hex_color_long_format = false\nij_sass_hex_color_lower_case = false\nij_sass_hex_color_short_format = false\nij_sass_hex_color_upper_case = false\nij_sass_keep_blank_lines_in_code = 2\nij_sass_keep_indents_on_empty_lines = false\nij_sass_keep_single_line_blocks = false\nij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_sass_space_after_colon = true\nij_sass_space_before_opening_brace = true\nij_sass_use_double_quotes = true\nij_sass_value_alignment = 0\n\n[*.scss]\nij_scss_align_closing_brace_with_properties = false\nij_scss_blank_lines_around_nested_selector = 1\nij_scss_blank_lines_between_blocks = 1\nij_scss_brace_placement = 0\nij_scss_enforce_quotes_on_format = false\nij_scss_hex_color_long_format = false\nij_scss_hex_color_lower_case = false\nij_scss_hex_color_short_format = false\nij_scss_hex_color_upper_case = false\nij_scss_keep_blank_lines_in_code = 2\nij_scss_keep_indents_on_empty_lines = false\nij_scss_keep_single_line_blocks = false\nij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_scss_space_after_colon = true\nij_scss_space_before_opening_brace = true\nij_scss_use_double_quotes = true\nij_scss_value_alignment = 0\n\n[*.styl]\nij_continuation_indent_size = 8\nij_stylus_align_closing_brace_with_properties = false\nij_stylus_blank_lines_around_nested_selector = 1\nij_stylus_blank_lines_between_blocks = 1\nij_stylus_brace_placement = 0\nij_stylus_enforce_quotes_on_format = false\nij_stylus_hex_color_long_format = false\nij_stylus_hex_color_lower_case = false\nij_stylus_hex_color_short_format = false\nij_stylus_hex_color_upper_case = false\nij_stylus_keep_blank_lines_in_code = 2\nij_stylus_keep_indents_on_empty_lines = false\nij_stylus_keep_single_line_blocks = false\nij_stylus_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow\nij_stylus_space_after_colon = true\nij_stylus_space_before_opening_brace = true\nij_stylus_use_double_quotes = true\nij_stylus_value_alignment = 0\n\n[.editorconfig]\nij_editorconfig_align_group_field_declarations = false\nij_editorconfig_space_after_colon = false\nij_editorconfig_space_after_comma = true\nij_editorconfig_space_before_colon = false\nij_editorconfig_space_before_comma = false\nij_editorconfig_spaces_around_assignment_operators = true\n\n[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.qrc,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}]\nij_continuation_indent_size = 2\nij_xml_align_attributes = false\nij_xml_align_text = false\nij_xml_attribute_wrap = normal\nij_xml_block_comment_at_first_column = true\nij_xml_keep_blank_lines = 2\nij_xml_keep_indents_on_empty_lines = false\nij_xml_keep_line_breaks = true\nij_xml_keep_line_breaks_in_text = true\nij_xml_keep_whitespaces = false\nij_xml_keep_whitespaces_around_cdata = preserve\nij_xml_keep_whitespaces_inside_cdata = false\nij_xml_line_comment_at_first_column = true\nij_xml_space_after_tag_name = false\nij_xml_space_around_equals_in_attribute = false\nij_xml_space_inside_empty_tag = false\nij_xml_text_wrap = normal\nij_xml_use_custom_settings = true\n\n[{*.ats,*.ts}]\nij_typescript_align_imports = false\nij_typescript_align_multiline_array_initializer_expression = false\nij_typescript_align_multiline_binary_operation = false\nij_typescript_align_multiline_chained_methods = false\nij_typescript_align_multiline_extends_list = false\nij_typescript_align_multiline_for = true\nij_typescript_align_multiline_parameters = true\nij_typescript_align_multiline_parameters_in_calls = false\nij_typescript_align_multiline_ternary_operation = false\nij_typescript_align_object_properties = 0\nij_typescript_align_union_types = false\nij_typescript_align_var_statements = 0\nij_typescript_array_initializer_new_line_after_left_brace = false\nij_typescript_array_initializer_right_brace_on_new_line = false\nij_typescript_array_initializer_wrap = off\nij_typescript_assignment_wrap = off\nij_typescript_binary_operation_sign_on_next_line = false\nij_typescript_binary_operation_wrap = off\nij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**\nij_typescript_blank_lines_after_imports = 1\nij_typescript_blank_lines_around_class = 1\nij_typescript_blank_lines_around_field = 0\nij_typescript_blank_lines_around_field_in_interface = 0\nij_typescript_blank_lines_around_function = 1\nij_typescript_blank_lines_around_method = 1\nij_typescript_blank_lines_around_method_in_interface = 1\nij_typescript_block_brace_style = end_of_line\nij_typescript_call_parameters_new_line_after_left_paren = false\nij_typescript_call_parameters_right_paren_on_new_line = false\nij_typescript_call_parameters_wrap = off\nij_typescript_catch_on_new_line = false\nij_typescript_chained_call_dot_on_new_line = true\nij_typescript_class_brace_style = end_of_line\nij_typescript_comma_on_new_line = false\nij_typescript_do_while_brace_force = never\nij_typescript_else_on_new_line = false\nij_typescript_enforce_trailing_comma = keep\nij_typescript_extends_keyword_wrap = off\nij_typescript_extends_list_wrap = off\nij_typescript_field_prefix = _\nij_typescript_file_name_style = relaxed\nij_typescript_finally_on_new_line = false\nij_typescript_for_brace_force = never\nij_typescript_for_statement_new_line_after_left_paren = false\nij_typescript_for_statement_right_paren_on_new_line = false\nij_typescript_for_statement_wrap = off\nij_typescript_force_quote_style = false\nij_typescript_force_semicolon_style = false\nij_typescript_function_expression_brace_style = end_of_line\nij_typescript_if_brace_force = never\nij_typescript_import_merge_members = global\nij_typescript_import_prefer_absolute_path = global\nij_typescript_import_sort_members = true\nij_typescript_import_sort_module_name = false\nij_typescript_import_use_node_resolution = true\nij_typescript_imports_wrap = on_every_item\nij_typescript_indent_case_from_switch = true\nij_typescript_indent_chained_calls = false\nij_typescript_indent_package_children = 0\nij_typescript_jsdoc_include_types = false\nij_typescript_jsx_attribute_value = braces\nij_typescript_keep_blank_lines_in_code = 2\nij_typescript_keep_first_column_comment = true\nij_typescript_keep_indents_on_empty_lines = false\nij_typescript_keep_line_breaks = true\nij_typescript_keep_simple_blocks_in_one_line = false\nij_typescript_keep_simple_methods_in_one_line = false\nij_typescript_line_comment_add_space = true\nij_typescript_line_comment_at_first_column = false\nij_typescript_method_brace_style = end_of_line\nij_typescript_method_call_chain_wrap = off\nij_typescript_method_parameters_new_line_after_left_paren = false\nij_typescript_method_parameters_right_paren_on_new_line = false\nij_typescript_method_parameters_wrap = off\nij_typescript_object_literal_wrap = on_every_item\nij_typescript_parentheses_expression_new_line_after_left_paren = false\nij_typescript_parentheses_expression_right_paren_on_new_line = false\nij_typescript_place_assignment_sign_on_next_line = false\nij_typescript_prefer_as_type_cast = false\nij_typescript_prefer_explicit_types_function_expression_returns = false\nij_typescript_prefer_explicit_types_function_returns = false\nij_typescript_prefer_explicit_types_vars_fields = false\nij_typescript_prefer_parameters_wrap = false\nij_typescript_reformat_c_style_comments = false\nij_typescript_space_after_colon = true\nij_typescript_space_after_comma = true\nij_typescript_space_after_dots_in_rest_parameter = false\nij_typescript_space_after_generator_mult = true\nij_typescript_space_after_property_colon = true\nij_typescript_space_after_quest = true\nij_typescript_space_after_type_colon = true\nij_typescript_space_after_unary_not = false\nij_typescript_space_before_async_arrow_lparen = true\nij_typescript_space_before_catch_keyword = true\nij_typescript_space_before_catch_left_brace = true\nij_typescript_space_before_catch_parentheses = true\nij_typescript_space_before_class_lbrace = true\nij_typescript_space_before_class_left_brace = true\nij_typescript_space_before_colon = true\nij_typescript_space_before_comma = false\nij_typescript_space_before_do_left_brace = true\nij_typescript_space_before_else_keyword = true\nij_typescript_space_before_else_left_brace = true\nij_typescript_space_before_finally_keyword = true\nij_typescript_space_before_finally_left_brace = true\nij_typescript_space_before_for_left_brace = true\nij_typescript_space_before_for_parentheses = true\nij_typescript_space_before_for_semicolon = false\nij_typescript_space_before_function_left_parenth = true\nij_typescript_space_before_generator_mult = false\nij_typescript_space_before_if_left_brace = true\nij_typescript_space_before_if_parentheses = true\nij_typescript_space_before_method_call_parentheses = false\nij_typescript_space_before_method_left_brace = true\nij_typescript_space_before_method_parentheses = false\nij_typescript_space_before_property_colon = false\nij_typescript_space_before_quest = true\nij_typescript_space_before_switch_left_brace = true\nij_typescript_space_before_switch_parentheses = true\nij_typescript_space_before_try_left_brace = true\nij_typescript_space_before_type_colon = false\nij_typescript_space_before_unary_not = false\nij_typescript_space_before_while_keyword = true\nij_typescript_space_before_while_left_brace = true\nij_typescript_space_before_while_parentheses = true\nij_typescript_spaces_around_additive_operators = true\nij_typescript_spaces_around_arrow_function_operator = true\nij_typescript_spaces_around_assignment_operators = true\nij_typescript_spaces_around_bitwise_operators = true\nij_typescript_spaces_around_equality_operators = true\nij_typescript_spaces_around_logical_operators = true\nij_typescript_spaces_around_multiplicative_operators = true\nij_typescript_spaces_around_relational_operators = true\nij_typescript_spaces_around_shift_operators = true\nij_typescript_spaces_around_unary_operator = false\nij_typescript_spaces_within_array_initializer_brackets = false\nij_typescript_spaces_within_brackets = false\nij_typescript_spaces_within_catch_parentheses = false\nij_typescript_spaces_within_for_parentheses = false\nij_typescript_spaces_within_if_parentheses = false\nij_typescript_spaces_within_imports = false\nij_typescript_spaces_within_interpolation_expressions = false\nij_typescript_spaces_within_method_call_parentheses = false\nij_typescript_spaces_within_method_parentheses = false\nij_typescript_spaces_within_object_literal_braces = false\nij_typescript_spaces_within_object_type_braces = true\nij_typescript_spaces_within_parentheses = false\nij_typescript_spaces_within_switch_parentheses = false\nij_typescript_spaces_within_type_assertion = false\nij_typescript_spaces_within_union_types = true\nij_typescript_spaces_within_while_parentheses = false\nij_typescript_special_else_if_treatment = true\nij_typescript_ternary_operation_signs_on_next_line = false\nij_typescript_ternary_operation_wrap = off\nij_typescript_union_types_wrap = on_every_item\nij_typescript_use_chained_calls_group_indents = false\nij_typescript_use_double_quotes = true\nij_typescript_use_explicit_js_extension = global\nij_typescript_use_path_mapping = always\nij_typescript_use_public_modifier = false\nij_typescript_use_semicolon_after_statement = true\nij_typescript_var_declaration_wrap = normal\nij_typescript_while_brace_force = never\nij_typescript_while_on_new_line = false\nij_typescript_wrap_comments = false\n\n[{*.bash,*.sh,*.zsh}]\nij_shell_binary_ops_start_line = false\nij_shell_keep_column_alignment_padding = false\nij_shell_minify_program = false\nij_shell_redirect_followed_by_space = false\nij_shell_switch_cases_indented = false\n\n[{*.cjs,*.js}]\nmax_line_length = 80\nij_javascript_align_imports = false\nij_javascript_align_multiline_array_initializer_expression = false\nij_javascript_align_multiline_binary_operation = false\nij_javascript_align_multiline_chained_methods = false\nij_javascript_align_multiline_extends_list = false\nij_javascript_align_multiline_for = false\nij_javascript_align_multiline_parameters = false\nij_javascript_align_multiline_parameters_in_calls = false\nij_javascript_align_multiline_ternary_operation = false\nij_javascript_align_object_properties = 0\nij_javascript_align_union_types = false\nij_javascript_align_var_statements = 0\nij_javascript_array_initializer_new_line_after_left_brace = false\nij_javascript_array_initializer_right_brace_on_new_line = false\nij_javascript_array_initializer_wrap = normal\nij_javascript_assignment_wrap = off\nij_javascript_binary_operation_sign_on_next_line = true\nij_javascript_binary_operation_wrap = normal\nij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**\nij_javascript_blank_lines_after_imports = 1\nij_javascript_blank_lines_around_class = 1\nij_javascript_blank_lines_around_field = 0\nij_javascript_blank_lines_around_function = 1\nij_javascript_blank_lines_around_method = 1\nij_javascript_block_brace_style = end_of_line\nij_javascript_call_parameters_new_line_after_left_paren = false\nij_javascript_call_parameters_right_paren_on_new_line = false\nij_javascript_call_parameters_wrap = normal\nij_javascript_catch_on_new_line = false\nij_javascript_chained_call_dot_on_new_line = true\nij_javascript_class_brace_style = end_of_line\nij_javascript_comma_on_new_line = false\nij_javascript_do_while_brace_force = always\nij_javascript_else_on_new_line = false\nij_javascript_enforce_trailing_comma = keep\nij_javascript_extends_keyword_wrap = off\nij_javascript_extends_list_wrap = off\nij_javascript_field_prefix = _\nij_javascript_file_name_style = relaxed\nij_javascript_finally_on_new_line = false\nij_javascript_for_brace_force = always\nij_javascript_for_statement_new_line_after_left_paren = false\nij_javascript_for_statement_right_paren_on_new_line = false\nij_javascript_for_statement_wrap = normal\nij_javascript_force_quote_style = false\nij_javascript_force_semicolon_style = false\nij_javascript_function_expression_brace_style = end_of_line\nij_javascript_if_brace_force = always\nij_javascript_import_merge_members = global\nij_javascript_import_prefer_absolute_path = global\nij_javascript_import_sort_members = true\nij_javascript_import_sort_module_name = false\nij_javascript_import_use_node_resolution = true\nij_javascript_imports_wrap = on_every_item\nij_javascript_indent_case_from_switch = true\nij_javascript_indent_chained_calls = false\nij_javascript_indent_package_children = 0\nij_javascript_jsx_attribute_value = braces\nij_javascript_keep_blank_lines_in_code = 1\nij_javascript_keep_first_column_comment = true\nij_javascript_keep_indents_on_empty_lines = false\nij_javascript_keep_line_breaks = true\nij_javascript_keep_simple_blocks_in_one_line = false\nij_javascript_keep_simple_methods_in_one_line = false\nij_javascript_line_comment_add_space = true\nij_javascript_line_comment_at_first_column = false\nij_javascript_method_brace_style = end_of_line\nij_javascript_method_call_chain_wrap = off\nij_javascript_method_parameters_new_line_after_left_paren = false\nij_javascript_method_parameters_right_paren_on_new_line = false\nij_javascript_method_parameters_wrap = normal\nij_javascript_object_literal_wrap = on_every_item\nij_javascript_parentheses_expression_new_line_after_left_paren = false\nij_javascript_parentheses_expression_right_paren_on_new_line = false\nij_javascript_place_assignment_sign_on_next_line = false\nij_javascript_prefer_as_type_cast = false\nij_javascript_prefer_explicit_types_function_expression_returns = false\nij_javascript_prefer_explicit_types_function_returns = false\nij_javascript_prefer_explicit_types_vars_fields = false\nij_javascript_prefer_parameters_wrap = false\nij_javascript_reformat_c_style_comments = false\nij_javascript_space_after_colon = true\nij_javascript_space_after_comma = true\nij_javascript_space_after_dots_in_rest_parameter = false\nij_javascript_space_after_generator_mult = true\nij_javascript_space_after_property_colon = true\nij_javascript_space_after_quest = true\nij_javascript_space_after_type_colon = true\nij_javascript_space_after_unary_not = false\nij_javascript_space_before_async_arrow_lparen = true\nij_javascript_space_before_catch_keyword = true\nij_javascript_space_before_catch_left_brace = true\nij_javascript_space_before_catch_parentheses = true\nij_javascript_space_before_class_lbrace = true\nij_javascript_space_before_class_left_brace = true\nij_javascript_space_before_colon = true\nij_javascript_space_before_comma = false\nij_javascript_space_before_do_left_brace = true\nij_javascript_space_before_else_keyword = true\nij_javascript_space_before_else_left_brace = true\nij_javascript_space_before_finally_keyword = true\nij_javascript_space_before_finally_left_brace = true\nij_javascript_space_before_for_left_brace = true\nij_javascript_space_before_for_parentheses = true\nij_javascript_space_before_for_semicolon = false\nij_javascript_space_before_function_left_parenth = true\nij_javascript_space_before_generator_mult = false\nij_javascript_space_before_if_left_brace = true\nij_javascript_space_before_if_parentheses = true\nij_javascript_space_before_method_call_parentheses = false\nij_javascript_space_before_method_left_brace = true\nij_javascript_space_before_method_parentheses = false\nij_javascript_space_before_property_colon = false\nij_javascript_space_before_quest = true\nij_javascript_space_before_switch_left_brace = true\nij_javascript_space_before_switch_parentheses = true\nij_javascript_space_before_try_left_brace = true\nij_javascript_space_before_type_colon = false\nij_javascript_space_before_unary_not = false\nij_javascript_space_before_while_keyword = true\nij_javascript_space_before_while_left_brace = true\nij_javascript_space_before_while_parentheses = true\nij_javascript_spaces_around_additive_operators = true\nij_javascript_spaces_around_arrow_function_operator = true\nij_javascript_spaces_around_assignment_operators = true\nij_javascript_spaces_around_bitwise_operators = true\nij_javascript_spaces_around_equality_operators = true\nij_javascript_spaces_around_logical_operators = true\nij_javascript_spaces_around_multiplicative_operators = true\nij_javascript_spaces_around_relational_operators = true\nij_javascript_spaces_around_shift_operators = true\nij_javascript_spaces_around_unary_operator = false\nij_javascript_spaces_within_array_initializer_brackets = false\nij_javascript_spaces_within_brackets = false\nij_javascript_spaces_within_catch_parentheses = false\nij_javascript_spaces_within_for_parentheses = false\nij_javascript_spaces_within_if_parentheses = false\nij_javascript_spaces_within_imports = false\nij_javascript_spaces_within_interpolation_expressions = false\nij_javascript_spaces_within_method_call_parentheses = false\nij_javascript_spaces_within_method_parentheses = false\nij_javascript_spaces_within_object_literal_braces = false\nij_javascript_spaces_within_object_type_braces = true\nij_javascript_spaces_within_parentheses = false\nij_javascript_spaces_within_switch_parentheses = false\nij_javascript_spaces_within_type_assertion = false\nij_javascript_spaces_within_union_types = true\nij_javascript_spaces_within_while_parentheses = false\nij_javascript_special_else_if_treatment = true\nij_javascript_ternary_operation_signs_on_next_line = true\nij_javascript_ternary_operation_wrap = normal\nij_javascript_union_types_wrap = on_every_item\nij_javascript_use_chained_calls_group_indents = false\nij_javascript_use_double_quotes = true\nij_javascript_use_explicit_js_extension = global\nij_javascript_use_path_mapping = always\nij_javascript_use_public_modifier = false\nij_javascript_use_semicolon_after_statement = true\nij_javascript_var_declaration_wrap = normal\nij_javascript_while_brace_force = always\nij_javascript_while_on_new_line = false\nij_javascript_wrap_comments = false\n\n[{*.cjsx,*.coffee}]\nij_continuation_indent_size = 2\nij_coffeescript_align_function_body = false\nij_coffeescript_align_imports = false\nij_coffeescript_align_multiline_array_initializer_expression = true\nij_coffeescript_align_multiline_parameters = true\nij_coffeescript_align_multiline_parameters_in_calls = false\nij_coffeescript_align_object_properties = 0\nij_coffeescript_align_union_types = false\nij_coffeescript_align_var_statements = 0\nij_coffeescript_array_initializer_new_line_after_left_brace = false\nij_coffeescript_array_initializer_right_brace_on_new_line = false\nij_coffeescript_array_initializer_wrap = normal\nij_coffeescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/**\nij_coffeescript_blank_lines_around_function = 1\nij_coffeescript_call_parameters_new_line_after_left_paren = false\nij_coffeescript_call_parameters_right_paren_on_new_line = false\nij_coffeescript_call_parameters_wrap = normal\nij_coffeescript_chained_call_dot_on_new_line = true\nij_coffeescript_comma_on_new_line = false\nij_coffeescript_enforce_trailing_comma = keep\nij_coffeescript_field_prefix = _\nij_coffeescript_file_name_style = relaxed\nij_coffeescript_force_quote_style = false\nij_coffeescript_force_semicolon_style = false\nij_coffeescript_function_expression_brace_style = end_of_line\nij_coffeescript_import_merge_members = global\nij_coffeescript_import_prefer_absolute_path = global\nij_coffeescript_import_sort_members = true\nij_coffeescript_import_sort_module_name = false\nij_coffeescript_import_use_node_resolution = true\nij_coffeescript_imports_wrap = on_every_item\nij_coffeescript_indent_chained_calls = true\nij_coffeescript_indent_package_children = 0\nij_coffeescript_jsx_attribute_value = braces\nij_coffeescript_keep_blank_lines_in_code = 2\nij_coffeescript_keep_first_column_comment = true\nij_coffeescript_keep_indents_on_empty_lines = false\nij_coffeescript_keep_line_breaks = true\nij_coffeescript_keep_simple_methods_in_one_line = false\nij_coffeescript_method_parameters_new_line_after_left_paren = false\nij_coffeescript_method_parameters_right_paren_on_new_line = false\nij_coffeescript_method_parameters_wrap = off\nij_coffeescript_object_literal_wrap = on_every_item\nij_coffeescript_prefer_as_type_cast = false\nij_coffeescript_prefer_explicit_types_function_expression_returns = false\nij_coffeescript_prefer_explicit_types_function_returns = false\nij_coffeescript_prefer_explicit_types_vars_fields = false\nij_coffeescript_reformat_c_style_comments = false\nij_coffeescript_space_after_comma = true\nij_coffeescript_space_after_dots_in_rest_parameter = false\nij_coffeescript_space_after_generator_mult = true\nij_coffeescript_space_after_property_colon = true\nij_coffeescript_space_after_type_colon = true\nij_coffeescript_space_after_unary_not = false\nij_coffeescript_space_before_async_arrow_lparen = true\nij_coffeescript_space_before_class_lbrace = true\nij_coffeescript_space_before_comma = false\nij_coffeescript_space_before_function_left_parenth = true\nij_coffeescript_space_before_generator_mult = false\nij_coffeescript_space_before_property_colon = false\nij_coffeescript_space_before_type_colon = false\nij_coffeescript_space_before_unary_not = false\nij_coffeescript_spaces_around_additive_operators = true\nij_coffeescript_spaces_around_arrow_function_operator = true\nij_coffeescript_spaces_around_assignment_operators = true\nij_coffeescript_spaces_around_bitwise_operators = true\nij_coffeescript_spaces_around_equality_operators = true\nij_coffeescript_spaces_around_logical_operators = true\nij_coffeescript_spaces_around_multiplicative_operators = true\nij_coffeescript_spaces_around_relational_operators = true\nij_coffeescript_spaces_around_shift_operators = true\nij_coffeescript_spaces_around_unary_operator = false\nij_coffeescript_spaces_within_array_initializer_braces = false\nij_coffeescript_spaces_within_array_initializer_brackets = false\nij_coffeescript_spaces_within_imports = false\nij_coffeescript_spaces_within_index_brackets = false\nij_coffeescript_spaces_within_interpolation_expressions = false\nij_coffeescript_spaces_within_method_call_parentheses = false\nij_coffeescript_spaces_within_method_parentheses = false\nij_coffeescript_spaces_within_object_braces = false\nij_coffeescript_spaces_within_object_literal_braces = false\nij_coffeescript_spaces_within_object_type_braces = true\nij_coffeescript_spaces_within_range_brackets = false\nij_coffeescript_spaces_within_type_assertion = false\nij_coffeescript_spaces_within_union_types = true\nij_coffeescript_union_types_wrap = on_every_item\nij_coffeescript_use_chained_calls_group_indents = false\nij_coffeescript_use_double_quotes = true\nij_coffeescript_use_explicit_js_extension = global\nij_coffeescript_use_path_mapping = always\nij_coffeescript_use_public_modifier = false\nij_coffeescript_use_semicolon_after_statement = false\nij_coffeescript_var_declaration_wrap = normal\n\n[{*.dot,*.gv}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_dot_keep_blank_lines_in_code = 2\nij_dot_keep_indents_on_empty_lines = false\nij_dot_label_indent_absolute = false\nij_dot_label_indent_size = 0\nij_dot_space_after_colon = true\nij_dot_space_after_for_semicolon = true\nij_dot_space_before_class_left_brace = true\nij_dot_space_before_for_semicolon = false\nij_dot_space_before_method_left_brace = true\nij_dot_spaces_around_assignment_operators = true\nij_dot_spaces_around_equality_operators = true\nij_dot_spaces_within_brackets = false\nij_dot_use_relative_indents = false\n\n[{*.erb,*.rhtml}]\nij_continuation_indent_size = 2\nij_rhtml_keep_indents_on_empty_lines = false\n\n[{*.ft,*.vm,*.vsl}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_vtl_keep_indents_on_empty_lines = false\n\n[{*.gant,*.gradle,*.groovy,*.gson,*.gy}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_groovy_align_group_field_declarations = false\nij_groovy_align_multiline_array_initializer_expression = false\nij_groovy_align_multiline_assignment = false\nij_groovy_align_multiline_binary_operation = false\nij_groovy_align_multiline_chained_methods = false\nij_groovy_align_multiline_extends_list = false\nij_groovy_align_multiline_for = true\nij_groovy_align_multiline_list_or_map = true\nij_groovy_align_multiline_method_parentheses = false\nij_groovy_align_multiline_parameters = true\nij_groovy_align_multiline_parameters_in_calls = false\nij_groovy_align_multiline_resources = true\nij_groovy_align_multiline_ternary_operation = false\nij_groovy_align_multiline_throws_list = false\nij_groovy_align_named_args_in_map = true\nij_groovy_align_throws_keyword = false\nij_groovy_array_initializer_new_line_after_left_brace = false\nij_groovy_array_initializer_right_brace_on_new_line = false\nij_groovy_array_initializer_wrap = off\nij_groovy_assert_statement_wrap = off\nij_groovy_assignment_wrap = off\nij_groovy_binary_operation_wrap = off\nij_groovy_blank_lines_after_class_header = 0\nij_groovy_blank_lines_after_imports = 1\nij_groovy_blank_lines_after_package = 1\nij_groovy_blank_lines_around_class = 1\nij_groovy_blank_lines_around_field = 0\nij_groovy_blank_lines_around_field_in_interface = 0\nij_groovy_blank_lines_around_method = 1\nij_groovy_blank_lines_around_method_in_interface = 1\nij_groovy_blank_lines_before_imports = 1\nij_groovy_blank_lines_before_method_body = 0\nij_groovy_blank_lines_before_package = 0\nij_groovy_block_brace_style = end_of_line\nij_groovy_block_comment_at_first_column = true\nij_groovy_call_parameters_new_line_after_left_paren = false\nij_groovy_call_parameters_right_paren_on_new_line = false\nij_groovy_call_parameters_wrap = off\nij_groovy_catch_on_new_line = false\nij_groovy_class_annotation_wrap = split_into_lines\nij_groovy_class_brace_style = end_of_line\nij_groovy_class_count_to_use_import_on_demand = 5\nij_groovy_do_while_brace_force = never\nij_groovy_else_on_new_line = false\nij_groovy_enum_constants_wrap = off\nij_groovy_extends_keyword_wrap = off\nij_groovy_extends_list_wrap = off\nij_groovy_field_annotation_wrap = split_into_lines\nij_groovy_finally_on_new_line = false\nij_groovy_for_brace_force = never\nij_groovy_for_statement_new_line_after_left_paren = false\nij_groovy_for_statement_right_paren_on_new_line = false\nij_groovy_for_statement_wrap = off\nij_groovy_if_brace_force = never\nij_groovy_import_annotation_wrap = 2\nij_groovy_imports_layout = *,|,javax.**,java.**,|,$*\nij_groovy_indent_case_from_switch = true\nij_groovy_indent_label_blocks = true\nij_groovy_insert_inner_class_imports = false\nij_groovy_keep_blank_lines_before_right_brace = 2\nij_groovy_keep_blank_lines_in_code = 2\nij_groovy_keep_blank_lines_in_declarations = 2\nij_groovy_keep_control_statement_in_one_line = true\nij_groovy_keep_first_column_comment = true\nij_groovy_keep_indents_on_empty_lines = false\nij_groovy_keep_line_breaks = true\nij_groovy_keep_multiple_expressions_in_one_line = false\nij_groovy_keep_simple_blocks_in_one_line = false\nij_groovy_keep_simple_classes_in_one_line = true\nij_groovy_keep_simple_lambdas_in_one_line = true\nij_groovy_keep_simple_methods_in_one_line = true\nij_groovy_label_indent_absolute = false\nij_groovy_label_indent_size = 0\nij_groovy_lambda_brace_style = end_of_line\nij_groovy_layout_static_imports_separately = true\nij_groovy_line_comment_add_space = false\nij_groovy_line_comment_at_first_column = true\nij_groovy_method_annotation_wrap = split_into_lines\nij_groovy_method_brace_style = end_of_line\nij_groovy_method_call_chain_wrap = off\nij_groovy_method_parameters_new_line_after_left_paren = false\nij_groovy_method_parameters_right_paren_on_new_line = false\nij_groovy_method_parameters_wrap = off\nij_groovy_modifier_list_wrap = false\nij_groovy_names_count_to_use_import_on_demand = 3\nij_groovy_parameter_annotation_wrap = off\nij_groovy_parentheses_expression_new_line_after_left_paren = false\nij_groovy_parentheses_expression_right_paren_on_new_line = false\nij_groovy_prefer_parameters_wrap = false\nij_groovy_resource_list_new_line_after_left_paren = false\nij_groovy_resource_list_right_paren_on_new_line = false\nij_groovy_resource_list_wrap = off\nij_groovy_space_after_assert_separator = true\nij_groovy_space_after_colon = true\nij_groovy_space_after_comma = true\nij_groovy_space_after_comma_in_type_arguments = true\nij_groovy_space_after_for_semicolon = true\nij_groovy_space_after_quest = true\nij_groovy_space_after_type_cast = true\nij_groovy_space_before_annotation_parameter_list = false\nij_groovy_space_before_array_initializer_left_brace = false\nij_groovy_space_before_assert_separator = false\nij_groovy_space_before_catch_keyword = true\nij_groovy_space_before_catch_left_brace = true\nij_groovy_space_before_catch_parentheses = true\nij_groovy_space_before_class_left_brace = true\nij_groovy_space_before_closure_left_brace = true\nij_groovy_space_before_colon = true\nij_groovy_space_before_comma = false\nij_groovy_space_before_do_left_brace = true\nij_groovy_space_before_else_keyword = true\nij_groovy_space_before_else_left_brace = true\nij_groovy_space_before_finally_keyword = true\nij_groovy_space_before_finally_left_brace = true\nij_groovy_space_before_for_left_brace = true\nij_groovy_space_before_for_parentheses = true\nij_groovy_space_before_for_semicolon = false\nij_groovy_space_before_if_left_brace = true\nij_groovy_space_before_if_parentheses = true\nij_groovy_space_before_method_call_parentheses = false\nij_groovy_space_before_method_left_brace = true\nij_groovy_space_before_method_parentheses = false\nij_groovy_space_before_quest = true\nij_groovy_space_before_switch_left_brace = true\nij_groovy_space_before_switch_parentheses = true\nij_groovy_space_before_synchronized_left_brace = true\nij_groovy_space_before_synchronized_parentheses = true\nij_groovy_space_before_try_left_brace = true\nij_groovy_space_before_try_parentheses = true\nij_groovy_space_before_while_keyword = true\nij_groovy_space_before_while_left_brace = true\nij_groovy_space_before_while_parentheses = true\nij_groovy_space_in_named_argument = true\nij_groovy_space_in_named_argument_before_colon = false\nij_groovy_space_within_empty_array_initializer_braces = false\nij_groovy_space_within_empty_method_call_parentheses = false\nij_groovy_spaces_around_additive_operators = true\nij_groovy_spaces_around_assignment_operators = true\nij_groovy_spaces_around_bitwise_operators = true\nij_groovy_spaces_around_equality_operators = true\nij_groovy_spaces_around_lambda_arrow = true\nij_groovy_spaces_around_logical_operators = true\nij_groovy_spaces_around_multiplicative_operators = true\nij_groovy_spaces_around_regex_operators = true\nij_groovy_spaces_around_relational_operators = true\nij_groovy_spaces_around_shift_operators = true\nij_groovy_spaces_within_annotation_parentheses = false\nij_groovy_spaces_within_array_initializer_braces = false\nij_groovy_spaces_within_braces = true\nij_groovy_spaces_within_brackets = false\nij_groovy_spaces_within_cast_parentheses = false\nij_groovy_spaces_within_catch_parentheses = false\nij_groovy_spaces_within_for_parentheses = false\nij_groovy_spaces_within_gstring_injection_braces = false\nij_groovy_spaces_within_if_parentheses = false\nij_groovy_spaces_within_list_or_map = false\nij_groovy_spaces_within_method_call_parentheses = false\nij_groovy_spaces_within_method_parentheses = false\nij_groovy_spaces_within_parentheses = false\nij_groovy_spaces_within_switch_parentheses = false\nij_groovy_spaces_within_synchronized_parentheses = false\nij_groovy_spaces_within_try_parentheses = false\nij_groovy_spaces_within_tuple_expression = false\nij_groovy_spaces_within_while_parentheses = false\nij_groovy_special_else_if_treatment = true\nij_groovy_ternary_operation_wrap = off\nij_groovy_throws_keyword_wrap = off\nij_groovy_throws_list_wrap = off\nij_groovy_use_flying_geese_braces = false\nij_groovy_use_fq_class_names = false\nij_groovy_use_fq_class_names_in_javadoc = true\nij_groovy_use_relative_indents = false\nij_groovy_use_single_class_imports = true\nij_groovy_variable_annotation_wrap = off\nij_groovy_while_brace_force = never\nij_groovy_while_on_new_line = false\nij_groovy_wrap_long_lines = false\n\n[{*.gemspec,*.jbuilder,*.rake,*.rb,*.rbw,*.ru,*.thor,.simplecov,capfile,cucumber,gemfile,guardfile,isolate,rails,rake,rakefile,rcov,spec,spork,vagrantfile}]\nij_ruby_align_group_field_declarations = false\nij_ruby_align_multiline_parameters = true\nij_ruby_blank_lines_around_class = 1\nij_ruby_blank_lines_around_method = 1\nij_ruby_chain_calls_alignment = 2\nij_ruby_convert_brace_block_by_enter = true\nij_ruby_empty_declarations_style = 1\nij_ruby_force_newlines_around_visibility_mods = true\nij_ruby_indent_private_methods = false\nij_ruby_indent_protected_methods = false\nij_ruby_indent_public_methods = false\nij_ruby_indent_when_cases = false\nij_ruby_keep_blank_lines_in_code = 1\nij_ruby_keep_blank_lines_in_declarations = 1\nij_ruby_keep_line_breaks = true\nij_ruby_parentheses_around_method_arguments = true\nij_ruby_spaces_around_assignment_operators = true\nij_ruby_spaces_around_hashrocket = true\nij_ruby_spaces_around_other_operators = true\nij_ruby_spaces_around_range_operators = false\nij_ruby_spaces_around_relational_operators = true\nij_ruby_spaces_within_array_initializer_braces = true\nij_ruby_spaces_within_braces = true\n\n[{*.gradle.kts,*.kt,*.kts,*.main.kts}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_kotlin_align_in_columns_case_branch = false\nij_kotlin_align_multiline_binary_operation = false\nij_kotlin_align_multiline_extends_list = false\nij_kotlin_align_multiline_method_parentheses = false\nij_kotlin_align_multiline_parameters = true\nij_kotlin_align_multiline_parameters_in_calls = false\nij_kotlin_allow_trailing_comma = false\nij_kotlin_allow_trailing_comma_on_call_site = false\nij_kotlin_assignment_wrap = off\nij_kotlin_blank_lines_after_class_header = 0\nij_kotlin_blank_lines_around_block_when_branches = 0\nij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1\nij_kotlin_block_comment_at_first_column = true\nij_kotlin_call_parameters_new_line_after_left_paren = false\nij_kotlin_call_parameters_right_paren_on_new_line = false\nij_kotlin_call_parameters_wrap = off\nij_kotlin_catch_on_new_line = false\nij_kotlin_class_annotation_wrap = split_into_lines\nij_kotlin_continuation_indent_for_chained_calls = true\nij_kotlin_continuation_indent_for_expression_bodies = true\nij_kotlin_continuation_indent_in_argument_lists = true\nij_kotlin_continuation_indent_in_elvis = true\nij_kotlin_continuation_indent_in_if_conditions = true\nij_kotlin_continuation_indent_in_parameter_lists = true\nij_kotlin_continuation_indent_in_supertype_lists = true\nij_kotlin_else_on_new_line = false\nij_kotlin_enum_constants_wrap = off\nij_kotlin_extends_list_wrap = off\nij_kotlin_field_annotation_wrap = split_into_lines\nij_kotlin_finally_on_new_line = false\nij_kotlin_if_rparen_on_new_line = false\nij_kotlin_import_nested_classes = false\nij_kotlin_imports_layout = *\nij_kotlin_insert_whitespaces_in_simple_one_line_method = true\nij_kotlin_keep_blank_lines_before_right_brace = 2\nij_kotlin_keep_blank_lines_in_code = 2\nij_kotlin_keep_blank_lines_in_declarations = 2\nij_kotlin_keep_first_column_comment = true\nij_kotlin_keep_indents_on_empty_lines = false\nij_kotlin_keep_line_breaks = true\nij_kotlin_lbrace_on_next_line = false\nij_kotlin_line_comment_add_space = false\nij_kotlin_line_comment_at_first_column = true\nij_kotlin_method_annotation_wrap = split_into_lines\nij_kotlin_method_call_chain_wrap = off\nij_kotlin_method_parameters_new_line_after_left_paren = false\nij_kotlin_method_parameters_right_paren_on_new_line = false\nij_kotlin_method_parameters_wrap = off\nij_kotlin_name_count_to_use_star_import = 999\nij_kotlin_name_count_to_use_star_import_for_members = 999\nij_kotlin_packages_to_use_import_on_demand =\nij_kotlin_parameter_annotation_wrap = off\nij_kotlin_space_after_comma = true\nij_kotlin_space_after_extend_colon = true\nij_kotlin_space_after_type_colon = true\nij_kotlin_space_before_catch_parentheses = true\nij_kotlin_space_before_comma = false\nij_kotlin_space_before_extend_colon = true\nij_kotlin_space_before_for_parentheses = true\nij_kotlin_space_before_if_parentheses = true\nij_kotlin_space_before_lambda_arrow = true\nij_kotlin_space_before_type_colon = false\nij_kotlin_space_before_when_parentheses = true\nij_kotlin_space_before_while_parentheses = true\nij_kotlin_spaces_around_additive_operators = true\nij_kotlin_spaces_around_assignment_operators = true\nij_kotlin_spaces_around_equality_operators = true\nij_kotlin_spaces_around_function_type_arrow = true\nij_kotlin_spaces_around_logical_operators = true\nij_kotlin_spaces_around_multiplicative_operators = true\nij_kotlin_spaces_around_range = false\nij_kotlin_spaces_around_relational_operators = true\nij_kotlin_spaces_around_unary_operator = false\nij_kotlin_spaces_around_when_arrow = true\nij_kotlin_variable_annotation_wrap = off\nij_kotlin_while_on_new_line = false\nij_kotlin_wrap_elvis_expressions = 1\nij_kotlin_wrap_expression_body_functions = 0\nij_kotlin_wrap_first_method_in_call_chain = false\n\n[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}]\nij_json_keep_blank_lines_in_code = 0\nij_json_keep_indents_on_empty_lines = false\nij_json_keep_line_breaks = true\nij_json_space_after_colon = true\nij_json_space_after_comma = true\nij_json_space_before_colon = true\nij_json_space_before_comma = false\nij_json_spaces_within_braces = false\nij_json_spaces_within_brackets = false\nij_json_wrap_long_lines = false\n\n[{*.hcl,*.nomad}]\nij_continuation_indent_size = 8\nij_hcl_array_wrapping = 2\nij_hcl_keep_blank_lines_in_code = 2\nij_hcl_keep_indents_on_empty_lines = false\nij_hcl_keep_line_breaks = true\nij_hcl_object_wrapping = 2\nij_hcl_property_alignment = 0\nij_hcl_property_line_commenter_character = 0\nij_hcl_space_after_comma = true\nij_hcl_space_before_comma = false\nij_hcl_spaces_around_assignment_operators = true\nij_hcl_spaces_within_braces = false\nij_hcl_spaces_within_brackets = false\nij_hcl_wrap_long_lines = false\n\n[{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}]\nij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3\nij_html_align_attributes = true\nij_html_align_text = false\nij_html_attribute_wrap = normal\nij_html_block_comment_at_first_column = true\nij_html_do_not_align_children_of_min_lines = 0\nij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p\nij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot\nij_html_enforce_quotes = false\nij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var\nij_html_keep_blank_lines = 2\nij_html_keep_indents_on_empty_lines = false\nij_html_keep_line_breaks = true\nij_html_keep_line_breaks_in_text = true\nij_html_keep_whitespaces = false\nij_html_keep_whitespaces_inside = span,pre,textarea\nij_html_line_comment_at_first_column = true\nij_html_new_line_after_last_attribute = never\nij_html_new_line_before_first_attribute = never\nij_html_quote_style = double\nij_html_remove_new_line_before_tags = br\nij_html_space_after_tag_name = false\nij_html_space_around_equality_in_attribute = false\nij_html_space_inside_empty_tag = false\nij_html_text_wrap = normal\nij_html_uniform_ident = false\n\n[{*.jsf,*.jsp,*.jspf,*.tag,*.tagf,*.xjsp}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_jsp_jsp_prefer_comma_separated_import_list = false\nij_jsp_keep_indents_on_empty_lines = false\n\n[{*.jspx,*.tagx}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_jspx_keep_indents_on_empty_lines = false\n\n[{*.lua,*.lua.txt}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_lua_align_consecutive_variable_declarations = false\nij_lua_align_multiline_parameters = true\nij_lua_align_multiline_parameters_in_calls = false\nij_lua_call_parameters_wrap = off\nij_lua_keep_indents_on_empty_lines = false\nij_lua_keep_simple_blocks_in_one_line = false\nij_lua_method_parameters_wrap = off\nij_lua_space_after_comma = true\nij_lua_space_before_comma = false\nij_lua_spaces_around_assignment_operators = true\n\n[{*.markdown,*.md}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_markdown_force_one_space_after_blockquote_symbol = true\nij_markdown_force_one_space_after_header_symbol = true\nij_markdown_force_one_space_after_list_bullet = true\nij_markdown_force_one_space_between_words = true\nij_markdown_keep_indents_on_empty_lines = false\nij_markdown_max_lines_around_block_elements = 1\nij_markdown_max_lines_around_header = 1\nij_markdown_max_lines_between_paragraphs = 1\nij_markdown_min_lines_around_block_elements = 1\nij_markdown_min_lines_around_header = 1\nij_markdown_min_lines_between_paragraphs = 1\n\n[{*.pb,*.textproto}]\nij_prototext_keep_blank_lines_in_code = 2\nij_prototext_keep_indents_on_empty_lines = false\nij_prototext_keep_line_breaks = true\nij_prototext_space_after_colon = true\nij_prototext_space_after_comma = true\nij_prototext_space_before_colon = false\nij_prototext_space_before_comma = false\nij_prototext_spaces_within_braces = false\nij_prototext_spaces_within_brackets = false\n\n[{*.properties,spring.handlers,spring.schemas}]\nij_properties_align_group_field_declarations = false\nij_properties_keep_blank_lines = false\nij_properties_key_value_delimiter = equals\nij_properties_spaces_around_key_value_delimiter = false\n\n[{*.py,*.pyw}]\nmax_line_length = 80\nij_python_align_collections_and_comprehensions = true\nij_python_align_multiline_imports = true\nij_python_align_multiline_parameters = false\nij_python_align_multiline_parameters_in_calls = true\nij_python_blank_line_at_file_end = true\nij_python_blank_lines_after_imports = 1\nij_python_blank_lines_after_local_imports = 0\nij_python_blank_lines_around_class = 1\nij_python_blank_lines_around_method = 1\nij_python_blank_lines_around_top_level_classes_functions = 2\nij_python_blank_lines_before_first_method = 0\nij_python_dict_alignment = 0\nij_python_dict_new_line_after_left_brace = false\nij_python_dict_new_line_before_right_brace = false\nij_python_dict_wrapping = 1\nij_python_from_import_new_line_after_left_parenthesis = false\nij_python_from_import_new_line_before_right_parenthesis = false\nij_python_from_import_parentheses_force_if_multiline = false\nij_python_from_import_trailing_comma_if_multiline = false\nij_python_from_import_wrapping = 1\nij_python_hang_closing_brackets = false\nij_python_keep_blank_lines_in_code = 1\nij_python_keep_blank_lines_in_declarations = 1\nij_python_keep_indents_on_empty_lines = false\nij_python_keep_line_breaks = true\nij_python_new_line_after_colon = false\nij_python_new_line_after_colon_multi_clause = true\nij_python_optimize_imports_always_split_from_imports = false\nij_python_optimize_imports_case_insensitive_order = false\nij_python_optimize_imports_join_from_imports_with_same_source = false\nij_python_optimize_imports_sort_by_type_first = true\nij_python_optimize_imports_sort_imports = true\nij_python_optimize_imports_sort_names_in_from_imports = false\nij_python_space_after_comma = true\nij_python_space_after_number_sign = true\nij_python_space_after_py_colon = true\nij_python_space_before_backslash = true\nij_python_space_before_comma = false\nij_python_space_before_for_semicolon = false\nij_python_space_before_lbracket = false\nij_python_space_before_method_call_parentheses = false\nij_python_space_before_method_parentheses = false\nij_python_space_before_number_sign = true\nij_python_space_before_py_colon = false\nij_python_space_within_empty_method_call_parentheses = false\nij_python_space_within_empty_method_parentheses = false\nij_python_spaces_around_additive_operators = true\nij_python_spaces_around_assignment_operators = true\nij_python_spaces_around_bitwise_operators = true\nij_python_spaces_around_eq_in_keyword_argument = false\nij_python_spaces_around_eq_in_named_parameter = false\nij_python_spaces_around_equality_operators = true\nij_python_spaces_around_multiplicative_operators = true\nij_python_spaces_around_power_operator = true\nij_python_spaces_around_relational_operators = true\nij_python_spaces_around_shift_operators = true\nij_python_spaces_within_braces = false\nij_python_spaces_within_brackets = false\nij_python_spaces_within_method_call_parentheses = false\nij_python_spaces_within_method_parentheses = false\nij_python_use_continuation_indent_for_arguments = true\nij_python_use_continuation_indent_for_collection_and_comprehensions = false\nij_python_wrap_long_lines = false\n\n[{*.tf,*.tfvars}]\nij_continuation_indent_size = 8\nij_hcl-terraform_array_wrapping = 2\nij_hcl-terraform_keep_blank_lines_in_code = 2\nij_hcl-terraform_keep_indents_on_empty_lines = false\nij_hcl-terraform_keep_line_breaks = true\nij_hcl-terraform_object_wrapping = 2\nij_hcl-terraform_property_alignment = 0\nij_hcl-terraform_property_line_commenter_character = 0\nij_hcl-terraform_space_after_comma = true\nij_hcl-terraform_space_before_comma = false\nij_hcl-terraform_spaces_around_assignment_operators = true\nij_hcl-terraform_spaces_within_braces = false\nij_hcl-terraform_spaces_within_brackets = false\nij_hcl-terraform_wrap_long_lines = false\n\n[{*.toml,Cargo.lock,Gopkg.lock,Pipfile}]\nindent_size = 4\nij_continuation_indent_size = 8\nij_toml_keep_indents_on_empty_lines = false\n\n[{*.yaml,*.yml}]\nij_yaml_align_values_properties = do_not_align\nij_yaml_autoinsert_sequence_marker = true\nij_yaml_block_mapping_on_new_line = false\nij_yaml_indent_sequence_value = true\nij_yaml_keep_indents_on_empty_lines = false\nij_yaml_keep_line_breaks = true\nij_yaml_sequence_on_new_line = false\nij_yaml_space_before_colon = false\nij_yaml_spaces_within_braces = true\nij_yaml_spaces_within_brackets = true\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "# Copyright 2021 Signal Messenger, LLC\n# SPDX-License-Identifier: AGPL-3.0-only\n\ncustom: https://signal.org/donate/\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "version: 2\nupdates:\n- package-ecosystem: github-actions\n  directory: /\n  schedule:\n    interval: monthly\n  groups:\n    minor-actions-dependencies:\n      # GitHub Actions: Only group minor and patch updates (we want to carefully review major updates)\n      update-types: [ minor, patch ]\n\n- package-ecosystem: maven\n  directory: /\n  schedule:\n    interval: monthly\n  groups:\n    minor-java-dependencies:\n      # Java: Only group minor and patch updates (we want to carefully review major updates)\n      update-types: [ minor, patch ]\n\n"
  },
  {
    "path": ".github/stale.yml",
    "content": ""
  },
  {
    "path": ".github/workflows/documentation.yml",
    "content": "name: Update Documentation\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  build:\n    permissions:\n      contents: write\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0\n        with:\n          distribution: 'temurin'\n          java-version-file: .java-version\n          cache: 'maven'\n      - name: Compile and Build OpenAPI file\n        run: ./mvnw compile\n      - name: Update Documentation\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        run: |\n          cp -r api-doc/target/openapi/signal-server-openapi.yaml /tmp/\n          git config user.email \"github@signal.org\"\n          git config user.name \"Documentation Updater\"\n          git fetch origin gh-pages\n          git checkout gh-pages\n          cp /tmp/signal-server-openapi.yaml .\n          git diff --quiet || git commit -a -m \"Updating documentation\"\n          git push origin gh-pages -q\n"
  },
  {
    "path": ".github/workflows/integration-tests.yml",
    "content": "name: Integration Tests\n\non:\n  schedule:\n    - cron: '30 19 * * MON-FRI'\n  workflow_dispatch:\n\nenv:\n  # This may seem a little redundant, but copying the configuration to an environment variable makes it easier and safer\n  # to then write its contents to a file\n  INTEGRATION_TEST_CONFIG: ${{ vars.INTEGRATION_TEST_CONFIG }}\n\njobs:\n  build:\n    if: ${{ vars.INTEGRATION_TEST_CONFIG != '' }}\n    runs-on: ubuntu-latest\n    permissions:\n      id-token: write\n      contents: read\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0\n        with:\n          distribution: 'temurin'\n          java-version-file: .java-version\n          cache: 'maven'\n      - uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0\n        name: Configure AWS credentials from Test account\n        with:\n          role-to-assume: ${{ vars.AWS_ROLE }}\n          aws-region: ${{ vars.AWS_REGION }}\n      - name: Write integration test configuration\n        run: |\n          mkdir -p integration-tests/src/main/resources\n          echo \"${INTEGRATION_TEST_CONFIG}\" > integration-tests/src/main/resources/config.yml\n      - name: Run and verify integration tests\n        run: ./mvnw clean compile test-compile failsafe:integration-test failsafe:verify -P aws-sso\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Service CI\n\non:\n  pull_request:\n  push:\n    branches-ignore:\n      - gh-pages\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    container: ubuntu:24.04\n    timeout-minutes: 20\n\n    services:\n      foundationdb0:\n        # Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if\n        # it's a little behind the CLIENT version.\n        image: foundationdb/foundationdb:7.3.62\n        options: --name foundationdb0\n      foundationdb1:\n        # Note: this should generally match the version of the FoundationDB SERVER deployed in production; it's okay if\n        # it's a little behind the CLIENT version.\n        image: foundationdb/foundationdb:7.3.62\n        options: --name foundationdb1\n\n    steps:\n      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2\n      - name: Set up JDK\n        uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0\n        with:\n          distribution: 'temurin'\n          java-version-file: .java-version\n          cache: 'maven'\n        env:\n          # work around an issue with actions/runner setting an incorrect HOME in containers, which breaks maven caching\n          # https://github.com/actions/setup-java/issues/356\n          HOME: /root\n      - name: Install APT packages\n        # ca-certificates: required for AWS CRT client\n        run: |\n          # Add Docker's official GPG key:\n          apt update\n          apt install -y ca-certificates curl\n          install -m 0755 -d /etc/apt/keyrings\n          curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc\n          chmod a+r /etc/apt/keyrings/docker.asc\n\n          # Add Docker repository to apt sources:\n          echo \\\n            \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \\\n            $(. /etc/os-release && echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\") stable\" | \\\n            tee /etc/apt/sources.list.d/docker.list > /dev/null\n\n          # ca-certificates: required for AWS CRT client\n          apt update && apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin ca-certificates\n      - name: Configure FoundationDB0 database\n        run: docker exec foundationdb0 /usr/bin/fdbcli --exec 'configure new single memory'\n      - name: Configure FoundationDB1 database\n        run: docker exec foundationdb1 /usr/bin/fdbcli --exec 'configure new single memory'\n      - name: Download and install FoundationDB client\n        run: |\n          ./mvnw -e -B -Pexclude-spam-filter clean prepare-package -DskipTests=true\n          cp service/target/jib-extra/usr/lib/libfdb_c.x86_64.so /usr/lib/libfdb_c.x86_64.so\n          ldconfig\n      - name: Build with Maven\n        run: ./mvnw -e -B clean verify -DfoundationDb.serviceContainerNamePrefix=foundationdb\n"
  },
  {
    "path": ".gitignore",
    "content": "target\nlocal.properties\n.idea\n*.iml\nrun.sh\n*~\nlocal.yml\nconfig/production.yml\nconfig/federated.yml\nconfig/staging.yml\nconfig/testing.yml\nconfig/deploy.properties\n/service/config/production.yml\n/service/config/federated.yml\n/service/config/staging.yml\n/service/config/testing.yml\n/service/config/deploy.properties\n/service/dependency-reduced-pom.xml\n.opsmanage\nput.sh\ndeployer-staging.properties\ndeployer-production.properties\ndeployer.log\n/service/src/main/resources/org/signal/badges/Badges_*.properties\n!/service/src/main/resources/org/signal/badges/Badges_en.properties\n/service/src/main/resources/org/signal/subscriptions/Subscriptions_*.properties\n!/service/src/main/resources/org/signal/subscriptions/Subscriptions_en.properties\n.project\n.classpath\n.settings\n.DS_Store\n"
  },
  {
    "path": ".gitmodules",
    "content": "# Note that the implementation of the spam filter is private; internal\n# developers will need to override this URL with:\n#\n# ```\n# git config submodule.spam-filter.url PRIVATE_URL\n# ```\n#\n# External developers may safely ignore this submodule.\n[submodule \"spam-filter\"]\n\tpath = spam-filter\n\turl = REDACTED\n"
  },
  {
    "path": ".java-version",
    "content": "temurin-24\n"
  },
  {
    "path": ".mvn/extensions.xml",
    "content": "<extensions xmlns=\"http://maven.apache.org/EXTENSIONS/1.0.0\"\n  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n  xsi:schemaLocation=\"http://maven.apache.org/EXTENSIONS/1.0.0 http://maven.apache.org/xsd/core-extensions-1.0.0.xsd\">\n  <extension>\n    <groupId>fr.brouillard.oss</groupId>\n    <artifactId>jgitver-maven-plugin</artifactId>\n    <version>1.9.0</version>\n  </extension>\n</extensions>\n"
  },
  {
    "path": ".mvn/jgitver.config.xml",
    "content": "<configuration xmlns=\"http://jgitver.github.io/maven/configuration/1.1.0\"\n  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n  xsi:schemaLocation=\"http://jgitver.github.io/maven/configuration/1.1.0 https://jgitver.github.io/maven/configuration/jgitver-configuration-v1_1_0.xsd\">\n  <useDirty>true</useDirty>\n  <useDefaultBranchingPolicy>false</useDefaultBranchingPolicy>\n  <branchPolicies>\n    <branchPolicy>\n      <pattern>(.*)</pattern>\n      <transformations>\n        <transformation>IGNORE</transformation>\n      </transformations>\n    </branchPolicy>\n  </branchPolicies>\n</configuration>\n"
  },
  {
    "path": ".mvn/wrapper/maven-wrapper.properties",
    "content": "# Licensed to the Apache Software Foundation (ASF) under one\n# or more contributor license agreements.  See the NOTICE file\n# distributed with this work for additional information\n# regarding copyright ownership.  The ASF licenses this file\n# to you under the Apache License, Version 2.0 (the\n# \"License\"); you may not use this file except in compliance\n# with the License.  You may obtain a copy of the License at\n#\n#   https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing,\n# software distributed under the License is distributed on an\n# \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n# KIND, either express or implied.  See the License for the\n# specific language governing permissions and limitations\n# under the License.\ndistributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip\nwrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.4/maven-wrapper-3.3.4.jar\ndistributionSha256Sum=0d7125e8c91097b36edb990ea5934e6c68b4440eef4ea96510a0f6815e7eeadb\nwrapperSha256Sum=4e2fbf6554bc8a4702cdfdd3bef464f423393d784ddbb037216320ce55d5e4e1\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keysManager, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "Signal-Server\n=================\n\nDocumentation\n-------------\n\nLooking for protocol documentation? Check out the website!\n\nhttps://signal.org/docs/\n\nHow to Build\n------------\n\nThis project uses [FoundationDB](https://www.foundationdb.org/) and requires the FoundationDB client library to be installed on the host system. With that in place, the server can be built and tested with:\n\n```shell script\n$ ./mvnw clean test\n```\n\nSecurity\n--------\n\nSecurity issues should be sent to <a href=mailto:security@signal.org>security@signal.org</a>.\n\nHelp\n----\n\nWe cannot provide direct technical support. Get help running this software in your own environment in our [unofficial community forum][community forum].\n\nCryptography Notice\n-------------------\n\nThis distribution includes cryptographic software. The country in which you currently reside may have restrictions on the import, possession, use, and/or re-export to another country, of encryption software.\nBEFORE using any encryption software, please check your country's laws, regulations and policies concerning the import, possession, or use, and re-export of encryption software, to see if this is permitted.\nSee <https://www.wassenaar.org/> for more information.\n\nThe U.S. Government Department of Commerce, Bureau of Industry and Security (BIS), has classified this software as Export Commodity Control Number (ECCN) 5D002.C.1, which includes information security software using or performing cryptographic functions with asymmetric algorithms.\nThe form and manner of this distribution makes it eligible for export under the License Exception ENC Technology Software Unrestricted (TSU) exception (see the BIS Export Administration Regulations, Section 740.13) for both object code and source code.\n\nLicense\n-------\n\nCopyright 2013 Signal Messenger, LLC\n\nLicensed under the GNU AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html\n\n[community forum]: https://community.signalusers.org\n"
  },
  {
    "path": "TESTING.md",
    "content": "# Testing\n\n## Automated tests\n\nThe full suite of automated tests can be run using Maven from the project root:\n\n```sh\n./mvnw verify\n```\n\n## Test server\n\nThe service can be run in a feature-limited test mode by running the Maven `integration-test`\ngoal with the `test-server` profile activated:\n\n```sh\n./mvnw integration-test -Ptest-server [-DskipTests=true]\n```\n\nThis runs [`LocalWhisperServerService`][lwss] with [test configuration][test.yml] and [secrets][test secrets]. External\nregistration clients are stubbed so that:\n\n- a captcha requirement can be satisfied with `noop.noop.registration.noop`\n- any string will be accepted for a phone verification code\n\n[lwss]: service/src/test/java/org/whispersystems/textsecuregcm/LocalWhisperServerService.java\n\n[test.yml]: service/src/test/resources/config/test.yml\n\n[test secrets]: service/src/test/resources/config/test-secrets-bundle.yml\n"
  },
  {
    "path": "api-doc/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n  <parent>\n    <artifactId>TextSecureServer</artifactId>\n    <groupId>org.whispersystems.textsecure</groupId>\n    <version>JGITVER</version>\n  </parent>\n  <modelVersion>4.0.0</modelVersion>\n  <artifactId>api-doc</artifactId>\n\n  <dependencies>\n    <dependency>\n      <groupId>org.whispersystems.textsecure</groupId>\n      <artifactId>service</artifactId>\n      <version>${project.version}</version>\n    </dependency>\n  </dependencies>\n\n  <build>\n    <plugins>\n      <plugin>\n        <groupId>io.swagger.core.v3</groupId>\n        <artifactId>swagger-maven-plugin-jakarta</artifactId>\n        <version>${swagger.version}</version>\n        <configuration>\n          <outputFileName>signal-server-openapi</outputFileName>\n          <outputPath>${project.build.directory}/openapi</outputPath>\n          <outputFormat>YAML</outputFormat>\n          <configurationFilePath>${project.basedir}/src/main/resources/openapi/openapi-configuration.yaml\n          </configurationFilePath>\n        </configuration>\n        <executions>\n          <execution>\n            <phase>compile</phase>\n            <goals>\n              <goal>resolve</goal>\n            </goals>\n          </execution>\n        </executions>\n      </plugin>\n      <plugin>\n        <groupId>com.google.cloud.tools</groupId>\n        <artifactId>jib-maven-plugin</artifactId>\n        <configuration>\n          <!-- we don't want jib to execute on this module -->\n          <skip>true</skip>\n        </configuration>\n      </plugin>\n    </plugins>\n  </build>\n</project>\n"
  },
  {
    "path": "api-doc/src/main/java/org/signal/openapi/OpenApiExtension.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.openapi;\n\nimport com.fasterxml.jackson.annotation.JsonView;\nimport com.fasterxml.jackson.databind.JavaType;\nimport com.fasterxml.jackson.databind.type.SimpleType;\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.jaxrs2.ResolvedParameter;\nimport io.swagger.v3.jaxrs2.ext.AbstractOpenAPIExtension;\nimport io.swagger.v3.jaxrs2.ext.OpenAPIExtension;\nimport io.swagger.v3.oas.models.Components;\nimport jakarta.ws.rs.Consumes;\nimport java.lang.annotation.Annotation;\nimport java.lang.reflect.Type;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.ServiceLoader;\nimport java.util.Set;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\n\n/**\n * One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations\n * of the {@link AbstractOpenAPIExtension} class.\n * <p/>\n * The purpose of this extension is to customize certain aspects of the OpenAPI model generation on a lower level.\n * This extension works in coordination with {@link OpenApiReader} that has access to the model on a higher level.\n * <p/>\n * The extension is enabled by being listed in {@code META-INF/services/io.swagger.v3.jaxrs2.ext.OpenAPIExtension} file.\n * @see ServiceLoader\n * @see OpenApiReader\n * @see <a href=\"https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Extensions\">Swagger 2.X Extensions</a>\n */\npublic class OpenApiExtension extends AbstractOpenAPIExtension {\n\n  public static final ResolvedParameter AUTHENTICATED_ACCOUNT = new ResolvedParameter();\n\n  public static final ResolvedParameter OPTIONAL_AUTHENTICATED_ACCOUNT = new ResolvedParameter();\n\n  /**\n   * When parsing endpoint methods, Swagger will treat the first parameter not annotated as header/path/query param\n   * as a request body (and will ignore other not annotated parameters). In our case, this behavior conflicts with\n   * the {@code @Auth}-annotated parameters. Here we're checking if parameters are known to be anything other than\n   * a body and return an appropriate {@link ResolvedParameter} representation.\n   */\n  @Override\n  public ResolvedParameter extractParameters(\n      final List<Annotation> annotations,\n      final Type type,\n      final Set<Type> typesToSkip,\n      final Components components,\n      final Consumes classConsumes,\n      final Consumes methodConsumes,\n      final boolean includeRequestBody,\n      final JsonView jsonViewAnnotation,\n      final Iterator<OpenAPIExtension> chain) {\n\n    if (annotations.stream().anyMatch(a -> a.annotationType().equals(Auth.class))) {\n      // this is the case of authenticated endpoint,\n      if (type instanceof SimpleType simpleType\n          && simpleType.getRawClass().equals(AuthenticatedDevice.class)) {\n        return AUTHENTICATED_ACCOUNT;\n      }\n      if (type instanceof SimpleType simpleType\n          && isOptionalOfType(simpleType, AuthenticatedDevice.class)) {\n        return OPTIONAL_AUTHENTICATED_ACCOUNT;\n      }\n    }\n\n    return super.extractParameters(\n        annotations,\n        type,\n        typesToSkip,\n        components,\n        classConsumes,\n        methodConsumes,\n        includeRequestBody,\n        jsonViewAnnotation,\n        chain);\n  }\n\n  private static boolean isOptionalOfType(final SimpleType simpleType, final Class<?> expectedType) {\n    if (!simpleType.getRawClass().equals(Optional.class)) {\n      return false;\n    }\n    final List<JavaType> typeParameters = simpleType.getBindings().getTypeParameters();\n    if (typeParameters.isEmpty()) {\n      return false;\n    }\n    return typeParameters.get(0) instanceof SimpleType optionalParameterType\n        && optionalParameterType.getRawClass().equals(expectedType);\n  }\n}\n"
  },
  {
    "path": "api-doc/src/main/java/org/signal/openapi/OpenApiReader.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.openapi;\n\nimport static com.google.common.base.MoreObjects.firstNonNull;\nimport static org.signal.openapi.OpenApiExtension.AUTHENTICATED_ACCOUNT;\nimport static org.signal.openapi.OpenApiExtension.OPTIONAL_AUTHENTICATED_ACCOUNT;\n\nimport com.fasterxml.jackson.annotation.JsonView;\nimport com.google.common.collect.ImmutableList;\nimport io.swagger.v3.jaxrs2.Reader;\nimport io.swagger.v3.jaxrs2.ResolvedParameter;\nimport io.swagger.v3.oas.models.Operation;\nimport io.swagger.v3.oas.models.security.SecurityRequirement;\nimport jakarta.ws.rs.Consumes;\nimport java.lang.annotation.Annotation;\nimport java.lang.reflect.Type;\nimport java.util.Collections;\nimport java.util.List;\n\n/**\n * One of the extension mechanisms of Swagger Core library (OpenAPI processor) is via custom implementations\n * of the {@link Reader} class.\n * <p/>\n * The purpose of this extension is to customize certain aspects of the OpenAPI model generation on a higher level.\n * This extension works in coordination with {@link OpenApiExtension} that has access to the model on a lower level.\n * <p/>\n * The extension is enabled by being listed in {@code resources/openapi/openapi-configuration.yaml} file.\n * @see OpenApiExtension\n * @see <a href=\"https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Extensions\">Swagger 2.X Extensions</a>\n */\npublic class OpenApiReader extends Reader {\n\n  private static final String AUTHENTICATED_ACCOUNT_AUTH_SCHEMA = \"authenticatedAccount\";\n\n\n  /**\n   * Overriding this method allows converting a resolved parameter into other operation entities,\n   * in this case, into security requirements.\n   */\n  @Override\n  protected ResolvedParameter getParameters(\n      final Type type,\n      final List<Annotation> annotations,\n      final Operation operation,\n      final Consumes classConsumes,\n      final Consumes methodConsumes,\n      final JsonView jsonViewAnnotation) {\n    final ResolvedParameter resolved = super.getParameters(\n        type, annotations, operation, classConsumes, methodConsumes, jsonViewAnnotation);\n\n    if (resolved == AUTHENTICATED_ACCOUNT) {\n      operation.setSecurity(ImmutableList.<SecurityRequirement>builder()\n          .addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))\n          .add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))\n          .build());\n    }\n    if (resolved == OPTIONAL_AUTHENTICATED_ACCOUNT) {\n      operation.setSecurity(ImmutableList.<SecurityRequirement>builder()\n          .addAll(firstNonNull(operation.getSecurity(), Collections.emptyList()))\n          .add(new SecurityRequirement().addList(AUTHENTICATED_ACCOUNT_AUTH_SCHEMA))\n          .add(new SecurityRequirement())\n          .build());\n    }\n\n    return resolved;\n  }\n}\n"
  },
  {
    "path": "api-doc/src/main/resources/META-INF/services/io.swagger.v3.jaxrs2.ext.OpenAPIExtension",
    "content": "org.signal.openapi.OpenApiExtension\n"
  },
  {
    "path": "api-doc/src/main/resources/openapi/openapi-configuration.yaml",
    "content": "resourcePackages:\n  - org.whispersystems.textsecuregcm\nprettyPrint: true\ncacheTTL: 0\nreaderClass: org.signal.openapi.OpenApiReader\nopenAPI:\n  info:\n    title: Signal Server API\n    license:\n      name: AGPL-3.0-only\n      url: https://www.gnu.org/licenses/agpl-3.0.txt\n  servers:\n    - url: https://chat.signal.org\n      description: Production service\n    - url: https://chat.staging.signal.org\n      description: Staging service\n  components:\n    securitySchemes:\n      authenticatedAccount:\n        type: http\n        scheme: basic\n        description: |\n          Account authentication is based on Basic authentication schema, \n          where `username` has a format of `<user_id>[.<device_id>]`. If `device_id` is not specified,\n          user's `main` device is assumed.\n"
  },
  {
    "path": "integration-tests/.gitignore",
    "content": ".libs\nsrc/main/resources/config.yml\n"
  },
  {
    "path": "integration-tests/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n  <parent>\n    <artifactId>TextSecureServer</artifactId>\n    <groupId>org.whispersystems.textsecure</groupId>\n    <version>JGITVER</version>\n  </parent>\n  <modelVersion>4.0.0</modelVersion>\n  <artifactId>integration-tests</artifactId>\n\n  <dependencies>\n    <dependency>\n      <groupId>org.whispersystems.textsecure</groupId>\n      <artifactId>service</artifactId>\n      <version>${project.version}</version>\n    </dependency>\n\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>dynamodb</artifactId>\n    </dependency>\n  </dependencies>\n\n  <build>\n    <plugins>\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-surefire-plugin</artifactId>\n        <configuration>\n          <excludes>\n            <exclude>**</exclude>\n          </excludes>\n        </configuration>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-failsafe-plugin</artifactId>\n        <configuration>\n          <additionalClasspathElements>\n            <additionalClasspathElement>${project.basedir}/.libs/software.amazon.awssdk-sso.jar</additionalClasspathElement>\n          </additionalClasspathElements>\n          <includes>\n            <include>**/*.java</include>\n          </includes>\n        </configuration>\n      </plugin>\n      <plugin>\n        <groupId>com.google.cloud.tools</groupId>\n        <artifactId>jib-maven-plugin</artifactId>\n        <configuration>\n          <!-- we don't want jib to execute on this module -->\n          <skip>true</skip>\n        </configuration>\n      </plugin>\n    </plugins>\n  </build>\n\n  <profiles>\n    <profile>\n      <id>aws-sso</id>\n\n      <dependencies>\n        <dependency>\n          <groupId>software.amazon.awssdk</groupId>\n          <artifactId>sso</artifactId>\n        </dependency>\n      </dependencies>\n    </profile>\n  </profiles>\n</project>\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/Codecs.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\n\npublic final class Codecs {\n\n  private Codecs() {\n    // utility class\n  }\n\n  @FunctionalInterface\n  public interface CheckedFunction<T, R> {\n    R apply(T t) throws Exception;\n  }\n\n  public static class Base64BasedSerializer<T> extends JsonSerializer<T> {\n\n    private final CheckedFunction<T, byte[]> mapper;\n\n    public Base64BasedSerializer(final CheckedFunction<T, byte[]> mapper) {\n      this.mapper = mapper;\n    }\n\n    @Override\n    public void serialize(final T value, final JsonGenerator gen, final SerializerProvider serializers) throws IOException {\n      try {\n        gen.writeString(Base64.getEncoder().withoutPadding().encodeToString(mapper.apply(value)));\n      } catch (Exception e) {\n        throw new RuntimeException(e);\n      }\n    }\n  }\n\n  public static class Base64BasedDeserializer<T> extends JsonDeserializer<T> {\n\n    private final CheckedFunction<byte[], T> mapper;\n\n    public Base64BasedDeserializer(final CheckedFunction<byte[], T> mapper) {\n      this.mapper = mapper;\n    }\n\n    @Override\n    public T deserialize(final JsonParser p, final DeserializationContext ctxt) throws IOException {\n      try {\n        return mapper.apply(Base64.getDecoder().decode(p.getValueAsString()));\n      } catch (Exception e) {\n        throw new RuntimeException(e);\n      }\n    }\n  }\n\n  public static class ByteArraySerializer extends Base64BasedSerializer<byte[]> {\n    public ByteArraySerializer() {\n      super(bytes -> bytes);\n    }\n  }\n\n  public static class ByteArrayDeserializer extends Base64BasedDeserializer<byte[]> {\n    public ByteArrayDeserializer() {\n      super(bytes -> bytes);\n    }\n  }\n\n  public static class ECPublicKeySerializer extends Base64BasedSerializer<ECPublicKey> {\n    public ECPublicKeySerializer() {\n      super(ECPublicKey::serialize);\n    }\n  }\n\n  public static class ECPublicKeyDeserializer extends Base64BasedDeserializer<ECPublicKey> {\n    public ECPublicKeyDeserializer() {\n      super(ECPublicKey::new);\n    }\n  }\n\n  public static class IdentityKeySerializer extends Base64BasedSerializer<IdentityKey> {\n    public IdentityKeySerializer() {\n      super(IdentityKey::serialize);\n    }\n  }\n\n  public static class IdentityKeyDeserializer extends Base64BasedDeserializer<IdentityKey> {\n    public IdentityKeyDeserializer() {\n      super(bytes -> new IdentityKey(bytes, 0));\n    }\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/IntegrationTools.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.signal.integration.config.Config;\nimport org.whispersystems.textsecuregcm.metrics.NoopAwsSdkMetricPublisher;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.VerificationSessionManager;\nimport org.whispersystems.textsecuregcm.storage.VerificationSessions;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\npublic class IntegrationTools {\n\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n\n  private final VerificationSessionManager verificationSessionManager;\n\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers;\n\n\n  public static IntegrationTools create(final Config config) {\n    final AwsCredentialsProvider credentialsProvider = DefaultCredentialsProvider.builder().build();\n\n    final DynamoDbAsyncClient dynamoDbAsyncClient =\n        config.dynamoDbClient().buildAsyncClient(credentialsProvider, new NoopAwsSdkMetricPublisher());\n\n    final RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(\n        config.dynamoDbTables().registrationRecovery(), Duration.ofDays(1), dynamoDbAsyncClient, Clock.systemUTC());\n\n    final VerificationSessions verificationSessions = new VerificationSessions(\n        dynamoDbAsyncClient, config.dynamoDbTables().verificationSessions(), Clock.systemUTC());\n\n    return new IntegrationTools(\n        new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords),\n        new VerificationSessionManager(verificationSessions),\n        new PhoneNumberIdentifiers(dynamoDbAsyncClient, config.dynamoDbTables().phoneNumberIdentifiers())\n    );\n  }\n\n  private IntegrationTools(\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n      final VerificationSessionManager verificationSessionManager,\n      final PhoneNumberIdentifiers phoneNumberIdentifiers) {\n    this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;\n    this.verificationSessionManager = verificationSessionManager;\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n  }\n\n  public CompletableFuture<Void> populateRecoveryPassword(final String phoneNumber, final byte[] password) {\n    return phoneNumberIdentifiers\n        .getPhoneNumberIdentifier(phoneNumber)\n        .thenCompose(pni -> registrationRecoveryPasswordsManager.store(pni, password))\n        .thenRun(Util.NOOP);\n  }\n\n  public CompletableFuture<Optional<String>> peekVerificationSessionPushChallenge(final String sessionId) {\n    return verificationSessionManager.findForId(sessionId)\n        .thenApply(maybeSession -> maybeSession.map(VerificationSession::pushChallenge));\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/Operations.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.io.Resources;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.configuration.ConfigurationValidationException;\nimport io.dropwizard.jersey.validation.Validators;\nimport jakarta.validation.ConstraintViolation;\nimport java.io.IOException;\nimport java.lang.invoke.MethodHandles;\nimport java.net.URI;\nimport java.net.URL;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.nio.charset.StandardCharsets;\nimport java.security.SecureRandom;\nimport java.security.cert.CertificateException;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.Executors;\nimport org.apache.commons.lang3.StringUtils;\nimport org.apache.commons.lang3.Validate;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.signal.integration.config.Config;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.protocol.kem.KEMKeyPair;\nimport org.signal.libsignal.protocol.kem.KEMKeyType;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.RegistrationRequest;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.HttpUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic final class Operations {\n\n  private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());\n\n  private static final Config CONFIG = loadConfigFromClasspath(\"config.yml\");\n\n  private static final IntegrationTools INTEGRATION_TOOLS = IntegrationTools.create(CONFIG);\n\n  private static final String USER_AGENT = \"integration-test\";\n\n  private static final FaultTolerantHttpClient CLIENT = buildClient();\n\n\n  private Operations() {\n    // utility class\n  }\n\n  public static TestUser newRegisteredUser(final String number) {\n    final byte[] registrationPassword = populateRandomRecoveryPassword(number);\n    final String accountPassword = Base64.getEncoder().encodeToString(randomBytes(32));\n\n    final TestUser user = TestUser.create(number, accountPassword, registrationPassword);\n    final AccountAttributes accountAttributes = user.accountAttributes();\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    // register account\n    final RegistrationRequest registrationRequest = new RegistrationRequest(null,\n        registrationPassword,\n        accountAttributes,\n        true,\n        new IdentityKey(aciIdentityKeyPair.getPublicKey()),\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        new DeviceActivationRequest(generateSignedECPreKey(1, aciIdentityKeyPair),\n            generateSignedECPreKey(2, pniIdentityKeyPair),\n            generateSignedKEMPreKey(3, aciIdentityKeyPair),\n            generateSignedKEMPreKey(4, pniIdentityKeyPair),\n            Optional.empty(),\n            Optional.empty()));\n\n    final AccountIdentityResponse registrationResponse = apiPost(\"/v1/registration\", registrationRequest)\n        .authorized(number, accountPassword)\n        .executeExpectSuccess(AccountIdentityResponse.class);\n\n    user.setAciUuid(registrationResponse.uuid());\n    user.setPniUuid(registrationResponse.pni());\n\n    return user;\n  }\n\n  public record PrescribedVerificationNumber(String number, String verificationCode) {}\n\n  public static PrescribedVerificationNumber prescribedVerificationNumber() {\n      return new PrescribedVerificationNumber(\n          CONFIG.prescribedRegistrationNumber(),\n          CONFIG.prescribedRegistrationCode());\n  }\n\n  public static void deleteUser(final TestUser user) {\n    apiDelete(\"/v1/accounts/me\").authorized(user).executeExpectSuccess();\n  }\n\n  public static String peekVerificationSessionPushChallenge(final String sessionId) {\n    return INTEGRATION_TOOLS.peekVerificationSessionPushChallenge(sessionId).join()\n        .orElseThrow(() -> new RuntimeException(\"push challenge not found for the verification session\"));\n  }\n\n  public static byte[] populateRandomRecoveryPassword(final String number) {\n    final byte[] recoveryPassword = randomBytes(32);\n    INTEGRATION_TOOLS.populateRecoveryPassword(number, recoveryPassword).join();\n\n    return recoveryPassword;\n  }\n\n  public static <T> T sendEmptyRequestAuthenticated(\n      final String endpoint,\n      final String method,\n      final String username,\n      final String password,\n      final Class<T> outputType) {\n    try {\n      final HttpRequest request = HttpRequest.newBuilder()\n          .uri(serverUri(endpoint, Collections.emptyList()))\n          .method(method, HttpRequest.BodyPublishers.noBody())\n          .header(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, password))\n          .header(HttpHeaders.CONTENT_TYPE, \"application/json\")\n          .build();\n      return CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))\n          .whenComplete((response, error) -> {\n            if (error != null) {\n              logger.error(\"request error\", error);\n              error.printStackTrace();\n            } else {\n              logger.info(\"response: {}\", response.statusCode());\n              System.out.println(\"response: \" + response.statusCode() + \", \" + response.body());\n            }\n          })\n          .thenApply(response -> {\n            try {\n              return outputType.equals(Void.class)\n                  ? null\n                  : SystemMapper.jsonMapper().readValue(response.body(), outputType);\n            } catch (final IOException e) {\n              throw new RuntimeException(e);\n            }\n          })\n          .get();\n    } catch (final Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private static byte[] randomBytes(int numBytes) {\n    final byte[] bytes = new byte[numBytes];\n    new SecureRandom().nextBytes(bytes);\n    return bytes;\n  }\n\n  public static RequestBuilder apiGet(final String endpoint) {\n    return new RequestBuilder(HttpRequest.newBuilder().GET(), endpoint);\n  }\n\n  public static RequestBuilder apiDelete(final String endpoint) {\n    return new RequestBuilder(HttpRequest.newBuilder().DELETE(), endpoint);\n  }\n\n  public static <R> RequestBuilder apiPost(final String endpoint, final R input) {\n    return RequestBuilder.withJsonBody(endpoint, \"POST\", input);\n  }\n\n  public static <R> RequestBuilder apiPut(final String endpoint, final R input) {\n    return RequestBuilder.withJsonBody(endpoint, \"PUT\", input);\n  }\n\n  public static <R> RequestBuilder apiPatch(final String endpoint, final R input) {\n    return RequestBuilder.withJsonBody(endpoint, \"PATCH\", input);\n  }\n\n  private static URI serverUri(final String endpoint, final List<String> queryParams) {\n    final String query = queryParams.isEmpty()\n        ? StringUtils.EMPTY\n        : \"?\" + String.join(\"&\", queryParams);\n    return URI.create(\"https://\" + CONFIG.domain() + endpoint + query);\n  }\n\n  public static class RequestBuilder {\n\n    private final HttpRequest.Builder builder;\n\n    private final String endpoint;\n\n    private final List<String> queryParams = new ArrayList<>();\n\n\n    private RequestBuilder(final HttpRequest.Builder builder, final String endpoint) {\n      this.builder = builder;\n      this.endpoint = endpoint;\n    }\n\n    private static <R> RequestBuilder withJsonBody(final String endpoint, final String method, final R input) {\n      try {\n        final byte[] body = SystemMapper.jsonMapper().writeValueAsBytes(input);\n        return new RequestBuilder(HttpRequest.newBuilder()\n            .header(HttpHeaders.CONTENT_TYPE, \"application/json\")\n            .method(method, HttpRequest.BodyPublishers.ofByteArray(body)), endpoint);\n      } catch (final JsonProcessingException e) {\n        throw new RuntimeException(e);\n      }\n    }\n\n    public RequestBuilder authorized(final TestUser user) {\n      return authorized(user, Device.PRIMARY_ID);\n    }\n\n    public RequestBuilder authorized(final TestUser user, final byte deviceId) {\n      final String username = \"%s.%d\".formatted(user.aciUuid().toString(), deviceId);\n      return authorized(username, user.accountPassword());\n    }\n\n    public RequestBuilder authorized(final String username, final String password) {\n      builder.header(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(username, password));\n      return this;\n    }\n\n    public RequestBuilder queryParam(final String key, final String value) {\n      queryParams.add(\"%s=%s\".formatted(key, value));\n      return this;\n    }\n\n    public RequestBuilder header(final String name, final String value) {\n      builder.header(name, value);\n      return this;\n    }\n\n    public Pair<Integer, Void> execute() {\n      return execute(Void.class);\n    }\n\n    public Pair<Integer, Void> executeExpectSuccess() {\n      final Pair<Integer, Void> execute = execute();\n      Validate.isTrue(\n          HttpUtils.isSuccessfulResponse(execute.getLeft()),\n          \"Unexpected response code: %d\",\n          execute.getLeft());\n      return execute;\n    }\n\n    public <T> T executeExpectSuccess(final Class<T> expectedType) {\n      final Pair<Integer, T> execute = execute(expectedType);\n      Validate.isTrue(\n          HttpUtils.isSuccessfulResponse(execute.getLeft()),\n          \"Unexpected response code: %d : %s\",\n          execute.getLeft(), execute.getRight());\n      return requireNonNull(execute.getRight());\n    }\n\n    public void executeExpectStatusCode(final int expectedStatusCode) {\n      final Pair<Integer, Void> execute = execute(Void.class);\n      Validate.isTrue(\n          execute.getLeft() == expectedStatusCode,\n          \"Unexpected response code: %d\",\n          execute.getLeft()\n      );\n    }\n\n    public <T> Pair<Integer, T> execute(final Class<T> expectedType) {\n      builder.uri(serverUri(endpoint, queryParams))\n          .header(HttpHeaders.USER_AGENT, USER_AGENT);\n      return CLIENT.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8))\n          .whenComplete((response, error) -> {\n            if (error != null) {\n              logger.error(\"request error\", error);\n              error.printStackTrace();\n            }\n          })\n          .thenApply(response -> {\n            try {\n              final T result = expectedType.equals(Void.class)\n                  ? null\n                  : SystemMapper.jsonMapper().readValue(response.body(), expectedType);\n              return Pair.of(response.statusCode(), result);\n            } catch (final IOException e) {\n              throw new RuntimeException(e);\n            }\n          })\n          .join();\n    }\n  }\n\n  private static FaultTolerantHttpClient buildClient() {\n    try {\n      return FaultTolerantHttpClient.newBuilder(\"integration-test\", Executors.newFixedThreadPool(16))\n          .withTrustedServerCertificates(CONFIG.rootCert())\n          .build();\n    } catch (final CertificateException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private static Config loadConfigFromClasspath(final String filename) {\n    try {\n      final URL configFileUrl = Resources.getResource(filename);\n      final Config config = SystemMapper.yamlMapper().readValue(Resources.toByteArray(configFileUrl), Config.class);\n\n      final Set<ConstraintViolation<Config>> constraintViolations = Validators.newValidator().validate(config);\n\n      if (!constraintViolations.isEmpty()) {\n        throw new ConfigurationValidationException(filename, constraintViolations);\n      }\n\n      return config;\n    } catch (final Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public static ECSignedPreKey generateSignedECPreKey(final long id, final ECKeyPair identityKeyPair) {\n    final ECPublicKey pubKey = ECKeyPair.generate().getPublicKey();\n    final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());\n    return new ECSignedPreKey(id, pubKey, signature);\n  }\n\n  public static KEMSignedPreKey generateSignedKEMPreKey(final long id, final ECKeyPair identityKeyPair) {\n    final KEMPublicKey pubKey = KEMKeyPair.generate(KEMKeyType.KYBER_1024).getPublicKey();\n    final byte[] signature = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());\n    return new KEMSignedPreKey(id, pubKey, signature);\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/TestDevice.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.signal.libsignal.protocol.IdentityKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.state.SignedPreKeyRecord;\n\npublic class TestDevice {\n\n  private final byte deviceId;\n\n  private final Map<Integer, Pair<IdentityKeyPair, SignedPreKeyRecord>> signedPreKeys = new ConcurrentHashMap<>();\n\n\n  public static TestDevice create(\n      final byte deviceId,\n      final IdentityKeyPair aciIdentityKeyPair,\n      final IdentityKeyPair pniIdentityKeyPair) {\n    final TestDevice device = new TestDevice(deviceId);\n    device.addSignedPreKey(aciIdentityKeyPair);\n    device.addSignedPreKey(pniIdentityKeyPair);\n    return device;\n  }\n\n  public TestDevice(final byte deviceId) {\n    this.deviceId = deviceId;\n  }\n\n  public byte deviceId() {\n    return deviceId;\n  }\n\n  public SignedPreKeyRecord latestSignedPreKey(final IdentityKeyPair identity) {\n    final int id = signedPreKeys.entrySet()\n        .stream()\n        .filter(p -> p.getValue().getLeft().equals(identity))\n        .mapToInt(Map.Entry::getKey)\n        .max()\n        .orElseThrow();\n    return signedPreKeys.get(id).getRight();\n  }\n\n  public SignedPreKeyRecord addSignedPreKey(final IdentityKeyPair identity) {\n    final int nextId = signedPreKeys.keySet().stream().mapToInt(k -> k + 1).max().orElse(0);\n    final ECKeyPair keyPair = ECKeyPair.generate();\n    final byte[] signature = keyPair.getPrivateKey().calculateSignature(keyPair.getPublicKey().serialize());\n    final SignedPreKeyRecord signedPreKeyRecord = new SignedPreKeyRecord(nextId, System.currentTimeMillis(), keyPair, signature);\n    signedPreKeys.put(nextId, Pair.of(identity, signedPreKeyRecord));\n    return signedPreKeyRecord;\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/TestUser.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.security.SecureRandom;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ConcurrentHashMap;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.IdentityKeyPair;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.protocol.state.SignedPreKeyRecord;\nimport org.signal.libsignal.protocol.util.KeyHelper;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\n\npublic class TestUser {\n\n  private final int registrationId;\n\n  private final int pniRegistrationId;\n\n  private final IdentityKeyPair aciIdentityKey;\n\n  private final Map<Byte, TestDevice> devices = new ConcurrentHashMap<>();\n\n  private final byte[] unidentifiedAccessKey;\n\n  private String phoneNumber;\n\n  private IdentityKeyPair pniIdentityKey;\n\n  private String accountPassword;\n\n  private byte[] registrationPassword;\n\n  private UUID aciUuid;\n\n  private UUID pniUuid;\n\n\n  public static TestUser create(final String phoneNumber, final String accountPassword, final byte[] registrationPassword) {\n    // ACI identity key pair\n    final IdentityKeyPair aciIdentityKey = IdentityKeyPair.generate();\n    // PNI identity key pair\n    final IdentityKeyPair pniIdentityKey = IdentityKeyPair.generate();\n    // registration id\n    final int registrationId = KeyHelper.generateRegistrationId(false);\n    final int pniRegistrationId = KeyHelper.generateRegistrationId(false);\n    // uak\n    final byte[] unidentifiedAccessKey = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH];\n    new SecureRandom().nextBytes(unidentifiedAccessKey);\n\n    return new TestUser(\n        registrationId,\n        pniRegistrationId,\n        aciIdentityKey,\n        phoneNumber,\n        pniIdentityKey,\n        unidentifiedAccessKey,\n        accountPassword,\n        registrationPassword);\n  }\n\n  public TestUser(\n      final int registrationId,\n      final int pniRegistrationId,\n      final IdentityKeyPair aciIdentityKey,\n      final String phoneNumber,\n      final IdentityKeyPair pniIdentityKey,\n      final byte[] unidentifiedAccessKey,\n      final String accountPassword,\n      final byte[] registrationPassword) {\n    this.registrationId = registrationId;\n    this.pniRegistrationId = pniRegistrationId;\n    this.aciIdentityKey = aciIdentityKey;\n    this.phoneNumber = phoneNumber;\n    this.pniIdentityKey = pniIdentityKey;\n    this.unidentifiedAccessKey = unidentifiedAccessKey;\n    this.accountPassword = accountPassword;\n    this.registrationPassword = registrationPassword;\n    devices.put(Device.PRIMARY_ID, TestDevice.create(Device.PRIMARY_ID, aciIdentityKey, pniIdentityKey));\n  }\n\n  public int registrationId() {\n    return registrationId;\n  }\n\n  public IdentityKeyPair aciIdentityKey() {\n    return aciIdentityKey;\n  }\n\n  public String phoneNumber() {\n    return phoneNumber;\n  }\n\n  public IdentityKeyPair pniIdentityKey() {\n    return pniIdentityKey;\n  }\n\n  public String accountPassword() {\n    return accountPassword;\n  }\n\n  public byte[] registrationPassword() {\n    return registrationPassword;\n  }\n\n  public UUID aciUuid() {\n    return aciUuid;\n  }\n\n  public UUID pniUuid() {\n    return pniUuid;\n  }\n\n  public AccountAttributes accountAttributes() {\n    return new AccountAttributes(true, registrationId, pniRegistrationId, \"\".getBytes(StandardCharsets.UTF_8), \"\", true,\n        DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION)\n        .withUnidentifiedAccessKey(unidentifiedAccessKey)\n        .withRecoveryPassword(registrationPassword);\n  }\n\n  public void setAciUuid(final UUID aciUuid) {\n    this.aciUuid = aciUuid;\n  }\n\n  public void setPniUuid(final UUID pniUuid) {\n    this.pniUuid = pniUuid;\n  }\n\n  public void setPhoneNumber(final String phoneNumber) {\n    this.phoneNumber = phoneNumber;\n  }\n\n  public void setPniIdentityKey(final IdentityKeyPair pniIdentityKey) {\n    this.pniIdentityKey = pniIdentityKey;\n  }\n\n  public void setAccountPassword(final String accountPassword) {\n    this.accountPassword = accountPassword;\n  }\n\n  public void setRegistrationPassword(final byte[] registrationPassword) {\n    this.registrationPassword = registrationPassword;\n  }\n\n  public PreKeySetPublicView preKeys(final byte deviceId, final boolean pni) {\n    final IdentityKeyPair identity = pni\n        ? pniIdentityKey\n        : aciIdentityKey;\n    final TestDevice device = requireNonNull(devices.get(deviceId));\n    final SignedPreKeyRecord signedPreKeyRecord = device.latestSignedPreKey(identity);\n    try {\n      return new PreKeySetPublicView(\n          Collections.emptyList(),\n          identity.getPublicKey(),\n          new SignedPreKeyPublicView(\n              signedPreKeyRecord.getId(),\n              signedPreKeyRecord.getKeyPair().getPublicKey(),\n              signedPreKeyRecord.getSignature()\n          )\n      );\n    } catch (InvalidKeyException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public record SignedPreKeyPublicView(\n      int keyId,\n      @JsonSerialize(using = Codecs.ECPublicKeySerializer.class)\n      @JsonDeserialize(using = Codecs.ECPublicKeyDeserializer.class)\n      ECPublicKey publicKey,\n      @JsonSerialize(using = Codecs.ByteArraySerializer.class)\n      @JsonDeserialize(using = Codecs.ByteArrayDeserializer.class)\n      byte[] signature) {\n  }\n\n  public record PreKeySetPublicView(\n      List<String> preKeys,\n      @JsonSerialize(using = Codecs.IdentityKeySerializer.class)\n      @JsonDeserialize(using = Codecs.IdentityKeyDeserializer.class)\n      IdentityKey identityKey,\n      SignedPreKeyPublicView signedPreKey) {\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/config/Config.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration.config;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;\n\npublic record Config(@NotBlank String domain,\n                     @NotBlank String rootCert,\n                     @NotNull @Valid DynamoDbClientFactory dynamoDbClient,\n                     @NotNull @Valid DynamoDbTables dynamoDbTables,\n                     @NotBlank String prescribedRegistrationNumber,\n                     @NotBlank String prescribedRegistrationCode) {\n}\n"
  },
  {
    "path": "integration-tests/src/main/java/org/signal/integration/config/DynamoDbTables.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration.config;\n\nimport jakarta.validation.constraints.NotBlank;\n\npublic record DynamoDbTables(@NotBlank String registrationRecovery,\n                             @NotBlank String verificationSessions,\n                             @NotBlank String phoneNumberIdentifiers) {\n}\n"
  },
  {
    "path": "integration-tests/src/test/java/org/signal/integration/AccountTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\n\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.apache.http.HttpStatus;\nimport org.junit.jupiter.api.Test;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.usernames.BaseUsernameException;\nimport org.signal.libsignal.usernames.Username;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;\nimport org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;\nimport org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;\nimport org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;\nimport org.whispersystems.textsecuregcm.entities.UsernameHashResponse;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class AccountTest {\n\n  @Test\n  public void testCreateAccount() {\n    final TestUser user = Operations.newRegisteredUser(\"+19995550101\");\n    try {\n      final Pair<Integer, AccountIdentityResponse> execute = Operations.apiGet(\"/v1/accounts/whoami\")\n          .authorized(user)\n          .execute(AccountIdentityResponse.class);\n      assertEquals(HttpStatus.SC_OK, execute.getLeft());\n    } finally {\n      Operations.deleteUser(user);\n    }\n  }\n\n  @Test\n  public void changePhoneNumber() {\n    final TestUser user = Operations.newRegisteredUser(\"+19995550301\");\n    final String targetNumber = \"+19995550302\";\n\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final ChangeNumberRequest changeNumberRequest = new ChangeNumberRequest(null,\n        Operations.populateRandomRecoveryPassword(targetNumber),\n        targetNumber,\n        null,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Collections.emptyList(),\n        Map.of(Device.PRIMARY_ID, Operations.generateSignedECPreKey(1, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, Operations.generateSignedKEMPreKey(2, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 17));\n\n    final AccountIdentityResponse accountIdentityResponse =\n        Operations.apiPut(\"/v2/accounts/number\", changeNumberRequest)\n            .authorized(user)\n            .executeExpectSuccess(AccountIdentityResponse.class);\n\n    assertEquals(user.aciUuid(), accountIdentityResponse.uuid());\n    assertNotEquals(user.pniUuid(), accountIdentityResponse.pni());\n    assertEquals(targetNumber, accountIdentityResponse.number());\n  }\n\n  @Test\n  public void testUsernameOperations() throws Exception {\n    final TestUser user = Operations.newRegisteredUser(\"+19995550102\");\n    try {\n      verifyFullUsernameLifecycle(user);\n      // no do it again to check changing usernames\n      verifyFullUsernameLifecycle(user);\n    } finally {\n      Operations.deleteUser(user);\n    }\n  }\n\n  private static void verifyFullUsernameLifecycle(final TestUser user) throws BaseUsernameException {\n    final String preferred = \"test\";\n    final List<Username> candidates = Username.candidatesFrom(preferred, preferred.length(), preferred.length() + 1);\n\n    // reserve a username\n    final ReserveUsernameHashRequest reserveUsernameHashRequest = new ReserveUsernameHashRequest(\n        candidates.stream().map(Username::getHash).toList());\n    // try unauthorized\n    Operations\n        .apiPut(\"/v1/accounts/username_hash/reserve\", reserveUsernameHashRequest)\n        .executeExpectStatusCode(HttpStatus.SC_UNAUTHORIZED);\n\n    final ReserveUsernameHashResponse reserveUsernameHashResponse = Operations\n        .apiPut(\"/v1/accounts/username_hash/reserve\", reserveUsernameHashRequest)\n        .authorized(user)\n        .executeExpectSuccess(ReserveUsernameHashResponse.class);\n\n    // find which one is the reserved username\n    final byte[] reservedHash = reserveUsernameHashResponse.usernameHash();\n    final Username reservedUsername = candidates.stream()\n        .filter(u -> Arrays.equals(u.getHash(), reservedHash))\n        .findAny()\n        .orElseThrow();\n\n    // confirm a username\n   final ConfirmUsernameHashRequest confirmUsernameHashRequest = new ConfirmUsernameHashRequest(\n        reservedUsername.getHash(),\n        reservedUsername.generateProof(),\n        \"cluck cluck i'm a parrot\".getBytes()\n    );\n    // try unauthorized\n    Operations\n        .apiPut(\"/v1/accounts/username_hash/confirm\", confirmUsernameHashRequest)\n        .executeExpectStatusCode(HttpStatus.SC_UNAUTHORIZED);\n    Operations\n        .apiPut(\"/v1/accounts/username_hash/confirm\", confirmUsernameHashRequest)\n        .authorized(user)\n        .executeExpectSuccess(UsernameHashResponse.class);\n\n\n    // lookup username\n    final AccountIdentifierResponse accountIdentifierResponse = Operations\n        .apiGet(\"/v1/accounts/username_hash/\" + Base64.getUrlEncoder().encodeToString(reservedHash))\n        .executeExpectSuccess(AccountIdentifierResponse.class);\n    assertEquals(new AciServiceIdentifier(user.aciUuid()), accountIdentifierResponse.uuid());\n    // try authorized\n    Operations\n        .apiGet(\"/v1/accounts/username_hash/\" + Base64.getUrlEncoder().encodeToString(reservedHash))\n        .authorized(user)\n        .executeExpectStatusCode(HttpStatus.SC_BAD_REQUEST);\n\n    // delete username\n    Operations\n        .apiDelete(\"/v1/accounts/username_hash\")\n        .authorized(user)\n        .executeExpectSuccess();\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/test/java/org/signal/integration/MessagingTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.List;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessage;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessageList;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;\nimport org.whispersystems.textsecuregcm.entities.SendMessageResponse;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class MessagingTest {\n\n  @Test\n  public void testSendMessageUnsealed() {\n    final TestUser userA = Operations.newRegisteredUser(\"+19995550102\");\n    final TestUser userB = Operations.newRegisteredUser(\"+19995550103\");\n\n    try {\n      final byte[] expectedContent = \"Hello, World!\".getBytes(StandardCharsets.UTF_8);\n      final IncomingMessage message = new IncomingMessage(1, Device.PRIMARY_ID, userB.registrationId(), expectedContent);\n      final IncomingMessageList messages = new IncomingMessageList(List.of(message), false, true, System.currentTimeMillis());\n\n      Operations\n          .apiPut(\"/v1/messages/%s\".formatted(userB.aciUuid().toString()), messages)\n          .authorized(userA)\n          .execute(SendMessageResponse.class);\n\n      final Pair<Integer, OutgoingMessageEntityList> receiveMessages = Operations.apiGet(\"/v1/messages\")\n          .authorized(userB)\n          .execute(OutgoingMessageEntityList.class);\n\n      final byte[] actualContent = receiveMessages.getRight().messages().getFirst().content();\n      assertArrayEquals(expectedContent, actualContent);\n    } finally {\n      Operations.deleteUser(userA);\n      Operations.deleteUser(userB);\n    }\n  }\n}\n"
  },
  {
    "path": "integration-tests/src/test/java/org/signal/integration/RegistrationTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.integration;\n\nimport io.micrometer.common.util.StringUtils;\nimport org.junit.jupiter.api.Assertions;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;\nimport org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;\nimport org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;\nimport org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;\nimport org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;\n\npublic class RegistrationTest {\n\n  @Test\n  public void testRegistration() throws Exception {\n    final UpdateVerificationSessionRequest originalRequest = new UpdateVerificationSessionRequest(\n        \"test\", UpdateVerificationSessionRequest.PushTokenType.FCM, null, null, null, null);\n\n    final Operations.PrescribedVerificationNumber params = Operations.prescribedVerificationNumber();\n    final CreateVerificationSessionRequest input = new CreateVerificationSessionRequest(params.number(),\n        originalRequest);\n\n    final VerificationSessionResponse verificationSessionResponse = Operations\n        .apiPost(\"/v1/verification/session\", input)\n        .executeExpectSuccess(VerificationSessionResponse.class);\n\n    final String sessionId = verificationSessionResponse.id();\n    Assertions.assertTrue(StringUtils.isNotBlank(sessionId));\n\n    final String pushChallenge = Operations.peekVerificationSessionPushChallenge(sessionId);\n\n    // supply push challenge\n    final UpdateVerificationSessionRequest updatedRequest = new UpdateVerificationSessionRequest(\n        \"test\", UpdateVerificationSessionRequest.PushTokenType.FCM, pushChallenge, null, null, null);\n    final VerificationSessionResponse pushChallengeSupplied = Operations\n        .apiPatch(\"/v1/verification/session/%s\".formatted(sessionId), updatedRequest)\n        .executeExpectSuccess(VerificationSessionResponse.class);\n\n    Assertions.assertTrue(pushChallengeSupplied.allowedToRequestCode());\n\n    // request code\n    final VerificationCodeRequest verificationCodeRequest = new VerificationCodeRequest(\n        VerificationCodeRequest.Transport.SMS, \"android-ng\");\n\n    final VerificationSessionResponse codeRequested = Operations\n        .apiPost(\"/v1/verification/session/%s/code\".formatted(sessionId), verificationCodeRequest)\n        .executeExpectSuccess(VerificationSessionResponse.class);\n\n    // verify code\n    final SubmitVerificationCodeRequest submitVerificationCodeRequest = new SubmitVerificationCodeRequest(\n        params.verificationCode());\n    final VerificationSessionResponse codeVerified = Operations\n        .apiPut(\"/v1/verification/session/%s/code\".formatted(sessionId), submitVerificationCodeRequest)\n        .executeExpectSuccess(VerificationSessionResponse.class);\n  }\n}\n"
  },
  {
    "path": "mvnw",
    "content": "#!/bin/sh\n# ----------------------------------------------------------------------------\n# Licensed to the Apache Software Foundation (ASF) under one\n# or more contributor license agreements.  See the NOTICE file\n# distributed with this work for additional information\n# regarding copyright ownership.  The ASF licenses this file\n# to you under the Apache License, Version 2.0 (the\n# \"License\"); you may not use this file except in compliance\n# with the License.  You may obtain a copy of the License at\n#\n#    https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing,\n# software distributed under the License is distributed on an\n# \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n# KIND, either express or implied.  See the License for the\n# specific language governing permissions and limitations\n# under the License.\n# ----------------------------------------------------------------------------\n\n# ----------------------------------------------------------------------------\n# Apache Maven Wrapper startup batch script, version 3.2.0\n#\n# Required ENV vars:\n# ------------------\n#   JAVA_HOME - location of a JDK home dir\n#\n# Optional ENV vars\n# -----------------\n#   MAVEN_OPTS - parameters passed to the Java VM when running Maven\n#     e.g. to debug Maven itself, use\n#       set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000\n#   MAVEN_SKIP_RC - flag to disable loading of mavenrc files\n# ----------------------------------------------------------------------------\n\nif [ -z \"$MAVEN_SKIP_RC\" ] ; then\n\n  if [ -f /usr/local/etc/mavenrc ] ; then\n    . /usr/local/etc/mavenrc\n  fi\n\n  if [ -f /etc/mavenrc ] ; then\n    . /etc/mavenrc\n  fi\n\n  if [ -f \"$HOME/.mavenrc\" ] ; then\n    . \"$HOME/.mavenrc\"\n  fi\n\nfi\n\n# OS specific support.  $var _must_ be set to either true or false.\ncygwin=false;\ndarwin=false;\nmingw=false\ncase \"$(uname)\" in\n  CYGWIN*) cygwin=true ;;\n  MINGW*) mingw=true;;\n  Darwin*) darwin=true\n    # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home\n    # See https://developer.apple.com/library/mac/qa/qa1170/_index.html\n    if [ -z \"$JAVA_HOME\" ]; then\n      if [ -x \"/usr/libexec/java_home\" ]; then\n        JAVA_HOME=\"$(/usr/libexec/java_home)\"; export JAVA_HOME\n      else\n        JAVA_HOME=\"/Library/Java/Home\"; export JAVA_HOME\n      fi\n    fi\n    ;;\nesac\n\nif [ -z \"$JAVA_HOME\" ] ; then\n  if [ -r /etc/gentoo-release ] ; then\n    JAVA_HOME=$(java-config --jre-home)\n  fi\nfi\n\n# For Cygwin, ensure paths are in UNIX format before anything is touched\nif $cygwin ; then\n  [ -n \"$JAVA_HOME\" ] &&\n    JAVA_HOME=$(cygpath --unix \"$JAVA_HOME\")\n  [ -n \"$CLASSPATH\" ] &&\n    CLASSPATH=$(cygpath --path --unix \"$CLASSPATH\")\nfi\n\n# For Mingw, ensure paths are in UNIX format before anything is touched\nif $mingw ; then\n  [ -n \"$JAVA_HOME\" ] && [ -d \"$JAVA_HOME\" ] &&\n    JAVA_HOME=\"$(cd \"$JAVA_HOME\" || (echo \"cannot cd into $JAVA_HOME.\"; exit 1); pwd)\"\nfi\n\nif [ -z \"$JAVA_HOME\" ]; then\n  javaExecutable=\"$(which javac)\"\n  if [ -n \"$javaExecutable\" ] && ! [ \"$(expr \"\\\"$javaExecutable\\\"\" : '\\([^ ]*\\)')\" = \"no\" ]; then\n    # readlink(1) is not available as standard on Solaris 10.\n    readLink=$(which readlink)\n    if [ ! \"$(expr \"$readLink\" : '\\([^ ]*\\)')\" = \"no\" ]; then\n      if $darwin ; then\n        javaHome=\"$(dirname \"\\\"$javaExecutable\\\"\")\"\n        javaExecutable=\"$(cd \"\\\"$javaHome\\\"\" && pwd -P)/javac\"\n      else\n        javaExecutable=\"$(readlink -f \"\\\"$javaExecutable\\\"\")\"\n      fi\n      javaHome=\"$(dirname \"\\\"$javaExecutable\\\"\")\"\n      javaHome=$(expr \"$javaHome\" : '\\(.*\\)/bin')\n      JAVA_HOME=\"$javaHome\"\n      export JAVA_HOME\n    fi\n  fi\nfi\n\nif [ -z \"$JAVACMD\" ] ; then\n  if [ -n \"$JAVA_HOME\"  ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n      # IBM's JDK on AIX uses strange locations for the executables\n      JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n    else\n      JAVACMD=\"$JAVA_HOME/bin/java\"\n    fi\n  else\n    JAVACMD=\"$(\\unset -f command 2>/dev/null; \\command -v java)\"\n  fi\nfi\n\nif [ ! -x \"$JAVACMD\" ] ; then\n  echo \"Error: JAVA_HOME is not defined correctly.\" >&2\n  echo \"  We cannot execute $JAVACMD\" >&2\n  exit 1\nfi\n\nif [ -z \"$JAVA_HOME\" ] ; then\n  echo \"Warning: JAVA_HOME environment variable is not set.\"\nfi\n\n# traverses directory structure from process work directory to filesystem root\n# first directory with .mvn subdirectory is considered project base directory\nfind_maven_basedir() {\n  if [ -z \"$1\" ]\n  then\n    echo \"Path not specified to find_maven_basedir\"\n    return 1\n  fi\n\n  basedir=\"$1\"\n  wdir=\"$1\"\n  while [ \"$wdir\" != '/' ] ; do\n    if [ -d \"$wdir\"/.mvn ] ; then\n      basedir=$wdir\n      break\n    fi\n    # workaround for JBEAP-8937 (on Solaris 10/Sparc)\n    if [ -d \"${wdir}\" ]; then\n      wdir=$(cd \"$wdir/..\" || exit 1; pwd)\n    fi\n    # end of workaround\n  done\n  printf '%s' \"$(cd \"$basedir\" || exit 1; pwd)\"\n}\n\n# concatenates all lines of a file\nconcat_lines() {\n  if [ -f \"$1\" ]; then\n    # Remove \\r in case we run on Windows within Git Bash\n    # and check out the repository with auto CRLF management\n    # enabled. Otherwise, we may read lines that are delimited with\n    # \\r\\n and produce $'-Xarg\\r' rather than -Xarg due to word\n    # splitting rules.\n    tr -s '\\r\\n' ' ' < \"$1\"\n  fi\n}\n\nlog() {\n  if [ \"$MVNW_VERBOSE\" = true ]; then\n    printf '%s\\n' \"$1\"\n  fi\n}\n\nBASE_DIR=$(find_maven_basedir \"$(dirname \"$0\")\")\nif [ -z \"$BASE_DIR\" ]; then\n  exit 1;\nfi\n\nMAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-\"$BASE_DIR\"}; export MAVEN_PROJECTBASEDIR\nlog \"$MAVEN_PROJECTBASEDIR\"\n\n##########################################################################################\n# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central\n# This allows using the maven wrapper in projects that prohibit checking in binary data.\n##########################################################################################\nwrapperJarPath=\"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar\"\nif [ -r \"$wrapperJarPath\" ]; then\n    log \"Found $wrapperJarPath\"\nelse\n    log \"Couldn't find $wrapperJarPath, downloading it ...\"\n\n    if [ -n \"$MVNW_REPOURL\" ]; then\n      wrapperUrl=\"$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar\"\n    else\n      wrapperUrl=\"https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar\"\n    fi\n    while IFS=\"=\" read -r key value; do\n      # Remove '\\r' from value to allow usage on windows as IFS does not consider '\\r' as a separator ( considers space, tab, new line ('\\n'), and custom '=' )\n      safeValue=$(echo \"$value\" | tr -d '\\r')\n      case \"$key\" in (wrapperUrl) wrapperUrl=\"$safeValue\"; break ;;\n      esac\n    done < \"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties\"\n    log \"Downloading from: $wrapperUrl\"\n\n    if $cygwin; then\n      wrapperJarPath=$(cygpath --path --windows \"$wrapperJarPath\")\n    fi\n\n    if command -v wget > /dev/null; then\n        log \"Found wget ... using wget\"\n        [ \"$MVNW_VERBOSE\" = true ] && QUIET=\"\" || QUIET=\"--quiet\"\n        if [ -z \"$MVNW_USERNAME\" ] || [ -z \"$MVNW_PASSWORD\" ]; then\n            wget $QUIET \"$wrapperUrl\" -O \"$wrapperJarPath\" || rm -f \"$wrapperJarPath\"\n        else\n            wget $QUIET --http-user=\"$MVNW_USERNAME\" --http-password=\"$MVNW_PASSWORD\" \"$wrapperUrl\" -O \"$wrapperJarPath\" || rm -f \"$wrapperJarPath\"\n        fi\n    elif command -v curl > /dev/null; then\n        log \"Found curl ... using curl\"\n        [ \"$MVNW_VERBOSE\" = true ] && QUIET=\"\" || QUIET=\"--silent\"\n        if [ -z \"$MVNW_USERNAME\" ] || [ -z \"$MVNW_PASSWORD\" ]; then\n            curl $QUIET -o \"$wrapperJarPath\" \"$wrapperUrl\" -f -L || rm -f \"$wrapperJarPath\"\n        else\n            curl $QUIET --user \"$MVNW_USERNAME:$MVNW_PASSWORD\" -o \"$wrapperJarPath\" \"$wrapperUrl\" -f -L || rm -f \"$wrapperJarPath\"\n        fi\n    else\n        log \"Falling back to using Java to download\"\n        javaSource=\"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java\"\n        javaClass=\"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class\"\n        # For Cygwin, switch paths to Windows format before running javac\n        if $cygwin; then\n          javaSource=$(cygpath --path --windows \"$javaSource\")\n          javaClass=$(cygpath --path --windows \"$javaClass\")\n        fi\n        if [ -e \"$javaSource\" ]; then\n            if [ ! -e \"$javaClass\" ]; then\n                log \" - Compiling MavenWrapperDownloader.java ...\"\n                (\"$JAVA_HOME/bin/javac\" \"$javaSource\")\n            fi\n            if [ -e \"$javaClass\" ]; then\n                log \" - Running MavenWrapperDownloader.java ...\"\n                (\"$JAVA_HOME/bin/java\" -cp .mvn/wrapper MavenWrapperDownloader \"$wrapperUrl\" \"$wrapperJarPath\") || rm -f \"$wrapperJarPath\"\n            fi\n        fi\n    fi\nfi\n##########################################################################################\n# End of extension\n##########################################################################################\n\n# If specified, validate the SHA-256 sum of the Maven wrapper jar file\nwrapperSha256Sum=\"\"\nwhile IFS=\"=\" read -r key value; do\n  case \"$key\" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;;\n  esac\ndone < \"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties\"\nif [ -n \"$wrapperSha256Sum\" ]; then\n  wrapperSha256Result=false\n  if command -v sha256sum > /dev/null; then\n    if echo \"$wrapperSha256Sum  $wrapperJarPath\" | sha256sum -c - > /dev/null 2>&1; then\n      wrapperSha256Result=true\n    fi\n  elif command -v shasum > /dev/null; then\n    if echo \"$wrapperSha256Sum  $wrapperJarPath\" | shasum -a 256 -c > /dev/null 2>&1; then\n      wrapperSha256Result=true\n    fi\n  else\n    echo \"Checksum validation was requested but neither 'sha256sum' or 'shasum' are available.\"\n    echo \"Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties.\"\n    exit 1\n  fi\n  if [ $wrapperSha256Result = false ]; then\n    echo \"Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.\" >&2\n    echo \"Investigate or delete $wrapperJarPath to attempt a clean download.\" >&2\n    echo \"If you updated your Maven version, you need to update the specified wrapperSha256Sum property.\" >&2\n    exit 1\n  fi\nfi\n\nMAVEN_OPTS=\"$(concat_lines \"$MAVEN_PROJECTBASEDIR/.mvn/jvm.config\") $MAVEN_OPTS\"\n\n# For Cygwin, switch paths to Windows format before running java\nif $cygwin; then\n  [ -n \"$JAVA_HOME\" ] &&\n    JAVA_HOME=$(cygpath --path --windows \"$JAVA_HOME\")\n  [ -n \"$CLASSPATH\" ] &&\n    CLASSPATH=$(cygpath --path --windows \"$CLASSPATH\")\n  [ -n \"$MAVEN_PROJECTBASEDIR\" ] &&\n    MAVEN_PROJECTBASEDIR=$(cygpath --path --windows \"$MAVEN_PROJECTBASEDIR\")\nfi\n\n# Provide a \"standardized\" way to retrieve the CLI args that will\n# work with both Windows and non-Windows executions.\nMAVEN_CMD_LINE_ARGS=\"$MAVEN_CONFIG $*\"\nexport MAVEN_CMD_LINE_ARGS\n\nWRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain\n\n# shellcheck disable=SC2086 # safe args\nexec \"$JAVACMD\" \\\n  $MAVEN_OPTS \\\n  $MAVEN_DEBUG_OPTS \\\n  -classpath \"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar\" \\\n  \"-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}\" \\\n  ${WRAPPER_LAUNCHER} $MAVEN_CONFIG \"$@\"\n"
  },
  {
    "path": "mvnw.cmd",
    "content": "@REM ----------------------------------------------------------------------------\r\n@REM Licensed to the Apache Software Foundation (ASF) under one\r\n@REM or more contributor license agreements.  See the NOTICE file\r\n@REM distributed with this work for additional information\r\n@REM regarding copyright ownership.  The ASF licenses this file\r\n@REM to you under the Apache License, Version 2.0 (the\r\n@REM \"License\"); you may not use this file except in compliance\r\n@REM with the License.  You may obtain a copy of the License at\r\n@REM\r\n@REM    http://www.apache.org/licenses/LICENSE-2.0\r\n@REM\r\n@REM Unless required by applicable law or agreed to in writing,\r\n@REM software distributed under the License is distributed on an\r\n@REM \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\r\n@REM KIND, either express or implied.  See the License for the\r\n@REM specific language governing permissions and limitations\r\n@REM under the License.\r\n@REM ----------------------------------------------------------------------------\r\n\r\n@REM ----------------------------------------------------------------------------\r\n@REM Apache Maven Wrapper startup batch script, version 3.2.0\r\n@REM\r\n@REM Required ENV vars:\r\n@REM JAVA_HOME - location of a JDK home dir\r\n@REM\r\n@REM Optional ENV vars\r\n@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands\r\n@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending\r\n@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven\r\n@REM     e.g. to debug Maven itself, use\r\n@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000\r\n@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files\r\n@REM ----------------------------------------------------------------------------\r\n\r\n@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'\r\n@echo off\r\n@REM set title of command window\r\ntitle %0\r\n@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'\r\n@if \"%MAVEN_BATCH_ECHO%\" == \"on\"  echo %MAVEN_BATCH_ECHO%\r\n\r\n@REM set %HOME% to equivalent of $HOME\r\nif \"%HOME%\" == \"\" (set \"HOME=%HOMEDRIVE%%HOMEPATH%\")\r\n\r\n@REM Execute a user defined script before this one\r\nif not \"%MAVEN_SKIP_RC%\" == \"\" goto skipRcPre\r\n@REM check for pre script, once with legacy .bat ending and once with .cmd ending\r\nif exist \"%USERPROFILE%\\mavenrc_pre.bat\" call \"%USERPROFILE%\\mavenrc_pre.bat\" %*\r\nif exist \"%USERPROFILE%\\mavenrc_pre.cmd\" call \"%USERPROFILE%\\mavenrc_pre.cmd\" %*\r\n:skipRcPre\r\n\r\n@setlocal\r\n\r\nset ERROR_CODE=0\r\n\r\n@REM To isolate internal variables from possible post scripts, we use another setlocal\r\n@setlocal\r\n\r\n@REM ==== START VALIDATION ====\r\nif not \"%JAVA_HOME%\" == \"\" goto OkJHome\r\n\r\necho.\r\necho Error: JAVA_HOME not found in your environment. >&2\r\necho Please set the JAVA_HOME variable in your environment to match the >&2\r\necho location of your Java installation. >&2\r\necho.\r\ngoto error\r\n\r\n:OkJHome\r\nif exist \"%JAVA_HOME%\\bin\\java.exe\" goto init\r\n\r\necho.\r\necho Error: JAVA_HOME is set to an invalid directory. >&2\r\necho JAVA_HOME = \"%JAVA_HOME%\" >&2\r\necho Please set the JAVA_HOME variable in your environment to match the >&2\r\necho location of your Java installation. >&2\r\necho.\r\ngoto error\r\n\r\n@REM ==== END VALIDATION ====\r\n\r\n:init\r\n\r\n@REM Find the project base dir, i.e. the directory that contains the folder \".mvn\".\r\n@REM Fallback to current working directory if not found.\r\n\r\nset MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%\r\nIF NOT \"%MAVEN_PROJECTBASEDIR%\"==\"\" goto endDetectBaseDir\r\n\r\nset EXEC_DIR=%CD%\r\nset WDIR=%EXEC_DIR%\r\n:findBaseDir\r\nIF EXIST \"%WDIR%\"\\.mvn goto baseDirFound\r\ncd ..\r\nIF \"%WDIR%\"==\"%CD%\" goto baseDirNotFound\r\nset WDIR=%CD%\r\ngoto findBaseDir\r\n\r\n:baseDirFound\r\nset MAVEN_PROJECTBASEDIR=%WDIR%\r\ncd \"%EXEC_DIR%\"\r\ngoto endDetectBaseDir\r\n\r\n:baseDirNotFound\r\nset MAVEN_PROJECTBASEDIR=%EXEC_DIR%\r\ncd \"%EXEC_DIR%\"\r\n\r\n:endDetectBaseDir\r\n\r\nIF NOT EXIST \"%MAVEN_PROJECTBASEDIR%\\.mvn\\jvm.config\" goto endReadAdditionalConfig\r\n\r\n@setlocal EnableExtensions EnableDelayedExpansion\r\nfor /F \"usebackq delims=\" %%a in (\"%MAVEN_PROJECTBASEDIR%\\.mvn\\jvm.config\") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a\r\n@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%\r\n\r\n:endReadAdditionalConfig\r\n\r\nSET MAVEN_JAVA_EXE=\"%JAVA_HOME%\\bin\\java.exe\"\r\nset WRAPPER_JAR=\"%MAVEN_PROJECTBASEDIR%\\.mvn\\wrapper\\maven-wrapper.jar\"\r\nset WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain\r\n\r\nset WRAPPER_URL=\"https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar\"\r\n\r\nFOR /F \"usebackq tokens=1,2 delims==\" %%A IN (\"%MAVEN_PROJECTBASEDIR%\\.mvn\\wrapper\\maven-wrapper.properties\") DO (\r\n    IF \"%%A\"==\"wrapperUrl\" SET WRAPPER_URL=%%B\r\n)\r\n\r\n@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central\r\n@REM This allows using the maven wrapper in projects that prohibit checking in binary data.\r\nif exist %WRAPPER_JAR% (\r\n    if \"%MVNW_VERBOSE%\" == \"true\" (\r\n        echo Found %WRAPPER_JAR%\r\n    )\r\n) else (\r\n    if not \"%MVNW_REPOURL%\" == \"\" (\r\n        SET WRAPPER_URL=\"%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar\"\r\n    )\r\n    if \"%MVNW_VERBOSE%\" == \"true\" (\r\n        echo Couldn't find %WRAPPER_JAR%, downloading it ...\r\n        echo Downloading from: %WRAPPER_URL%\r\n    )\r\n\r\n    powershell -Command \"&{\"^\r\n\t\t\"$webclient = new-object System.Net.WebClient;\"^\r\n\t\t\"if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {\"^\r\n\t\t\"$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');\"^\r\n\t\t\"}\"^\r\n\t\t\"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')\"^\r\n\t\t\"}\"\r\n    if \"%MVNW_VERBOSE%\" == \"true\" (\r\n        echo Finished downloading %WRAPPER_JAR%\r\n    )\r\n)\r\n@REM End of extension\r\n\r\n@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file\r\nSET WRAPPER_SHA_256_SUM=\"\"\r\nFOR /F \"usebackq tokens=1,2 delims==\" %%A IN (\"%MAVEN_PROJECTBASEDIR%\\.mvn\\wrapper\\maven-wrapper.properties\") DO (\r\n    IF \"%%A\"==\"wrapperSha256Sum\" SET WRAPPER_SHA_256_SUM=%%B\r\n)\r\nIF NOT %WRAPPER_SHA_256_SUM%==\"\" (\r\n    powershell -Command \"&{\"^\r\n       \"$hash = (Get-FileHash \\\"%WRAPPER_JAR%\\\" -Algorithm SHA256).Hash.ToLower();\"^\r\n       \"If('%WRAPPER_SHA_256_SUM%' -ne $hash){\"^\r\n       \"  Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';\"^\r\n       \"  Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';\"^\r\n       \"  Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';\"^\r\n       \"  exit 1;\"^\r\n       \"}\"^\r\n       \"}\"\r\n    if ERRORLEVEL 1 goto error\r\n)\r\n\r\n@REM Provide a \"standardized\" way to retrieve the CLI args that will\r\n@REM work with both Windows and non-Windows executions.\r\nset MAVEN_CMD_LINE_ARGS=%*\r\n\r\n%MAVEN_JAVA_EXE% ^\r\n  %JVM_CONFIG_MAVEN_PROPS% ^\r\n  %MAVEN_OPTS% ^\r\n  %MAVEN_DEBUG_OPTS% ^\r\n  -classpath %WRAPPER_JAR% ^\r\n  \"-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%\" ^\r\n  %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*\r\nif ERRORLEVEL 1 goto error\r\ngoto end\r\n\r\n:error\r\nset ERROR_CODE=1\r\n\r\n:end\r\n@endlocal & set ERROR_CODE=%ERROR_CODE%\r\n\r\nif not \"%MAVEN_SKIP_RC%\"==\"\" goto skipRcPost\r\n@REM check for post script, once with legacy .bat ending and once with .cmd ending\r\nif exist \"%USERPROFILE%\\mavenrc_post.bat\" call \"%USERPROFILE%\\mavenrc_post.bat\"\r\nif exist \"%USERPROFILE%\\mavenrc_post.cmd\" call \"%USERPROFILE%\\mavenrc_post.cmd\"\r\n:skipRcPost\r\n\r\n@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'\r\nif \"%MAVEN_BATCH_PAUSE%\"==\"on\" pause\r\n\r\nif \"%MAVEN_TERMINATE_CMD%\"==\"on\" exit %ERROR_CODE%\r\n\r\ncmd /C exit /B %ERROR_CODE%\r\n"
  },
  {
    "path": "pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n  xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n  <modelVersion>4.0.0</modelVersion>\n  <packaging>pom</packaging>\n\n  <repositories>\n    <repository>\n      <id>central</id>\n      <name>Central Repository</name>\n      <url>https://repo.maven.apache.org/maven2</url>\n      <snapshots>\n        <enabled>false</enabled>\n      </snapshots>\n    </repository>\n    <repository>\n      <id>signal-build-artifacts</id>\n      <name>Signal Build Artifacts</name>\n      <url>https://build-artifacts.signal.org/libraries/maven</url>\n      <snapshots>\n        <enabled>false</enabled>\n      </snapshots>\n    </repository>\n  </repositories>\n\n  <pluginRepositories>\n    <pluginRepository>\n      <id>ossrh-snapshots</id>\n      <url>https://oss.sonatype.org/content/repositories/snapshots</url>\n      <releases>\n        <enabled>false</enabled>\n      </releases>\n      <snapshots>\n        <enabled>true</enabled>\n      </snapshots>\n    </pluginRepository>\n  </pluginRepositories>\n\n  <modules>\n    <module>api-doc</module>\n    <module>integration-tests</module>\n    <module>service</module>\n    <module>websocket-resources</module>\n  </modules>\n\n  <properties>\n    <aws.sdk2.version>2.41.11</aws.sdk2.version>\n    <braintree.version>3.47.0</braintree.version>\n    <commons-csv.version>1.14.1</commons-csv.version>\n    <commons-io.version>2.21.0</commons-io.version>\n    <dropwizard.version>4.0.16</dropwizard.version>\n    <!-- Note: when updating FoundationDB, also include a copy of `libfdb_c.so` from the FoundationDB release at\n    src/main/jib/usr/lib/libfdb_c.so. We use x86_64 builds without AVX instructions enabled (i.e. FoundationDB versions\n    with even-numbered patch versions). Also when updating FoundationDB, make sure to update the version of FoundationDB\n    used by GitHub Actions. -->\n    <foundationdb.version>7.3.62</foundationdb.version>\n    <foundationdb.api-version>730</foundationdb.api-version>\n    <foundationdb.client-library-sha256>bfed237b787fae3cde1222676e6bfbb0d218fc27bf9e903397a7a7aa96fb2d33</foundationdb.client-library-sha256>\n    <google-cloud-libraries.version>26.74.0</google-cloud-libraries.version>\n    <grpc.version>1.73.0</grpc.version> <!-- should be kept in sync with the value from Google libraries-bom -->\n    <gson.version>2.13.2</gson.version>\n    <guava.version>33.5.0-jre</guava.version>\n    <!-- several libraries (AWS, Google Cloud) use Apache http components transitively, and we need to align them -->\n    <httpcore.version>4.4.16</httpcore.version>\n    <httpclient.version>4.5.14</httpclient.version>\n    <jackson.version>2.21.0</jackson.version>\n    <junit-pioneer.version>2.3.0</junit-pioneer.version>\n    <jsr305.version>3.0.2</jsr305.version>\n    <kotlin.version>2.3.0</kotlin.version>\n    <logback.version>1.5.25</logback.version>\n    <logback-access-common.version>2.0.6</logback-access-common.version>\n    <lettuce.version>6.8.1.RELEASE</lettuce.version>\n    <libphonenumber.version>9.0.13</libphonenumber.version>\n    <logstash.logback.version>8.1</logstash.logback.version>\n    <log4j-bom.version>2.25.3</log4j-bom.version>\n    <luajava.version>3.5.0</luajava.version>\n    <micrometer.version>1.16.2</micrometer.version>\n    <netty.version>4.1.127.Final</netty.version>\n    <!-- Must be less than or equal to the value from Google libraries-bom which controls the protobuf runtime version.\n    See https://protobuf.dev/support/cross-version-runtime-guarantee/. -->\n    <protoc.version>4.29.4</protoc.version>\n    <pushy.version>0.15.4</pushy.version>\n    <reactor-bom.version>2025.0.2</reactor-bom.version> <!-- 3.8.2, see https://github.com/reactor/reactor#bom-versioning-scheme -->\n    <resilience4j.version>2.3.0</resilience4j.version>\n    <semver4j.version>3.1.0</semver4j.version>\n    <simple-grpc.version>0.2.0</simple-grpc.version>\n    <slf4j.version>2.0.17</slf4j.version>\n    <stripe.version>31.2.0</stripe.version>\n    <swagger.version>2.2.42</swagger.version>\n    <testcontainers.version>2.0.3</testcontainers.version>\n\n    <!-- images to use in tests via testcontainers  -->\n    <dynamodb.image>amazon/dynamodb-local:3.0.0@sha256:2fed5e3a965a4ba5aa6ac82baec57058b5a3848e959d705518f3fd579a77e76b</dynamodb.image>\n    <localstack.image>localstack/localstack:4@sha256:5a97e0f9917a3f0d9630bb13b9d8ccf10cbe52f33252807d3b4e21418cc21348</localstack.image>\n    <redis.image>redis:7.4-alpine@sha256:af1d0fc3f63b02b13ff7906c9baf7c5b390b8881ca08119cd570677fe2f60b55</redis.image>\n    <redis-cluster.image>docker.io/bitnamilegacy/redis-cluster:7.4.3@sha256:a53d023fdfaf8a8d7ddc58da040d3494e4cb45772644618ffa44c42dcd32b9af</redis-cluster.image>\n\n    <!-- eclipse-temurin:24.0.2_12-jre-noble (note: always use the multi-arch manifest *LIST* here) -->\n    <docker.image.sha256>85ecfc9bbb42af046d2bacbf1219d2005be4840cbfa16c2e6fd910d9ccfec95b</docker.image.sha256>\n\n    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>\n  </properties>\n\n  <groupId>org.whispersystems.textsecure</groupId>\n  <artifactId>TextSecureServer</artifactId>\n  <version>JGITVER</version>\n\n  <dependencyManagement>\n    <dependencies>\n      <dependency>\n        <groupId>com.fasterxml.jackson</groupId>\n        <artifactId>jackson-bom</artifactId>\n        <version>${jackson.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.dropwizard</groupId>\n        <artifactId>dropwizard-dependencies</artifactId>\n        <version>${dropwizard.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <!-- Needed for gRPC with Java 9+ -->\n      <dependency>\n        <groupId>org.apache.tomcat</groupId>\n        <artifactId>annotations-api</artifactId>\n        <version>6.0.53</version>\n        <scope>provided</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.netty</groupId>\n        <artifactId>netty-bom</artifactId>\n        <version>${netty.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>software.amazon.awssdk</groupId>\n        <artifactId>bom</artifactId>\n        <version>${aws.sdk2.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>com.google.cloud</groupId>\n        <artifactId>libraries-bom</artifactId>\n        <version>${google-cloud-libraries.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.github.resilience4j</groupId>\n        <artifactId>resilience4j-bom</artifactId>\n        <version>${resilience4j.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.micrometer</groupId>\n        <artifactId>micrometer-bom</artifactId>\n        <version>${micrometer.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.opentelemetry</groupId>\n        <artifactId>opentelemetry-bom</artifactId>\n        <version>1.54.0</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.opentelemetry.instrumentation</groupId>\n        <artifactId>opentelemetry-instrumentation-bom</artifactId>\n        <version>2.20.0</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>io.projectreactor</groupId>\n        <artifactId>reactor-bom</artifactId>\n        <version>${reactor-bom.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>org.jetbrains.kotlin</groupId>\n        <artifactId>kotlin-bom</artifactId>\n        <version>${kotlin.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>com.eatthepath</groupId>\n        <artifactId>pushy</artifactId>\n        <version>${pushy.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.eatthepath</groupId>\n        <artifactId>pushy-dropwizard-metrics-listener</artifactId>\n        <version>${pushy.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.googlecode.libphonenumber</groupId>\n        <artifactId>libphonenumber</artifactId>\n        <version>${libphonenumber.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.vdurmont</groupId>\n        <artifactId>semver4j</artifactId>\n        <version>${semver4j.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>commons-io</groupId>\n        <artifactId>commons-io</artifactId>\n        <version>${commons-io.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>io.lettuce</groupId>\n        <artifactId>lettuce-core</artifactId>\n        <version>${lettuce.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>net.logstash.logback</groupId>\n        <artifactId>logstash-logback-encoder</artifactId>\n        <version>${logstash.logback.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.apache.commons</groupId>\n        <artifactId>commons-csv</artifactId>\n        <version>${commons-csv.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.foundationdb</groupId>\n        <artifactId>fdb-java</artifactId>\n        <version>${foundationdb.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.slf4j</groupId>\n        <artifactId>slf4j-api</artifactId>\n        <version>${slf4j.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.slf4j</groupId>\n        <artifactId>slf4j-nop</artifactId>\n        <version>${slf4j.version}</version>\n        <scope>test</scope>\n      </dependency>\n      <dependency>\n        <groupId>commons-logging</groupId>\n        <artifactId>commons-logging</artifactId>\n        <version>1.3.5</version>\n      </dependency>\n      <dependency>\n        <groupId>org.ow2.asm</groupId>\n        <artifactId>asm</artifactId>\n        <version>9.8</version>\n        <scope>test</scope>\n      </dependency>\n      <dependency>\n        <groupId>com.stripe</groupId>\n        <artifactId>stripe-java</artifactId>\n        <version>${stripe.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.braintreepayments.gateway</groupId>\n        <artifactId>braintree-java</artifactId>\n        <version>${braintree.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.google.code.findbugs</groupId>\n        <artifactId>jsr305</artifactId>\n        <version>${jsr305.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.google.code.gson</groupId>\n        <artifactId>gson</artifactId>\n        <version>${gson.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.google.guava</groupId>\n        <artifactId>guava</artifactId>\n        <version>${guava.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>com.redis</groupId>\n        <artifactId>testcontainers-redis</artifactId>\n        <version>2.2.4</version>\n        <scope>test</scope>\n      </dependency>\n      <dependency>\n        <groupId>org.signal</groupId>\n        <artifactId>libsignal-server</artifactId>\n        <version>0.86.6</version>\n      </dependency>\n      <dependency>\n        <groupId>org.signal</groupId>\n        <artifactId>simple-grpc-runtime</artifactId>\n        <version>${simple-grpc.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.apache.logging.log4j</groupId>\n        <artifactId>log4j-bom</artifactId>\n        <version>${log4j-bom.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>org.apache.httpcomponents</groupId>\n        <artifactId>httpcore</artifactId>\n        <version>${httpcore.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.apache.httpcomponents</groupId>\n        <artifactId>httpclient</artifactId>\n        <version>${httpclient.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>ch.qos.logback</groupId>\n        <artifactId>logback-core</artifactId>\n        <version>${logback.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>ch.qos.logback</groupId>\n        <artifactId>logback-classic</artifactId>\n        <version>${logback.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>ch.qos.logback.access</groupId>\n        <artifactId>logback-access-common</artifactId>\n        <version>${logback-access-common.version}</version>\n      </dependency>\n      <dependency>\n        <groupId>org.testcontainers</groupId>\n        <artifactId>testcontainers-bom</artifactId>\n        <version>${testcontainers.version}</version>\n        <type>pom</type>\n        <scope>import</scope>\n      </dependency>\n      <dependency>\n        <groupId>earth.adi</groupId>\n        <artifactId>testcontainers-foundationdb</artifactId>\n        <version>1.1.0</version>\n        <scope>test</scope>\n      </dependency>\n    </dependencies>\n  </dependencyManagement>\n\n  <dependencies>\n    <dependency>\n      <groupId>org.hamcrest</groupId>\n      <artifactId>hamcrest-all</artifactId>\n      <version>1.3</version>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>aws-crt-client</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.wiremock</groupId>\n      <artifactId>wiremock</artifactId>\n      <version>3.13.1</version>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>org.mockito</groupId>\n      <artifactId>mockito-core</artifactId>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>org.assertj</groupId>\n      <artifactId>assertj-core</artifactId>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter-api</artifactId>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>org.junit-pioneer</groupId>\n      <artifactId>junit-pioneer</artifactId>\n      <version>${junit-pioneer.version}</version>\n      <scope>test</scope>\n    </dependency>\n\n  </dependencies>\n\n  <profiles>\n    <profile>\n      <id>include-spam-filter</id>\n      <activation>\n        <file>\n          <exists>spam-filter/pom.xml</exists>\n        </file>\n      </activation>\n      <modules>\n        <module>spam-filter</module>\n      </modules>\n    </profile>\n\n    <profile>\n      <id>exclude-spam-filter</id>\n      <activation>\n        <file>\n          <missing>spam-filter/pom.xml</missing>\n        </file>\n      </activation>\n    </profile>\n  </profiles>\n\n  <build>\n    <extensions>\n      <extension>\n        <groupId>kr.motd.maven</groupId>\n        <artifactId>os-maven-plugin</artifactId>\n        <version>1.7.0</version>\n      </extension>\n    </extensions>\n    <pluginManagement>\n      <plugins>\n        <plugin>\n          <groupId>com.google.cloud.tools</groupId>\n          <artifactId>jib-maven-plugin</artifactId>\n          <version>3.4.4</version>\n        </plugin>\n        <plugin>\n          <groupId>org.apache.maven.plugins</groupId>\n          <artifactId>maven-surefire-plugin</artifactId>\n          <version>3.5.2</version>\n        </plugin>\n        <plugin>\n          <groupId>org.apache.maven.plugins</groupId>\n          <artifactId>maven-failsafe-plugin</artifactId>\n          <version>3.5.2</version>\n        </plugin>\n        <plugin>\n          <groupId>org.apache.maven.plugins</groupId>\n          <artifactId>maven-jar-plugin</artifactId>\n          <version>3.4.2</version>\n        </plugin>\n        <plugin>\n          <groupId>org.apache.maven.plugins</groupId>\n          <artifactId>maven-shade-plugin</artifactId>\n          <version>3.6.0</version>\n        </plugin>\n        <plugin>\n          <groupId>org.apache.maven.plugins</groupId>\n          <artifactId>maven-assembly-plugin</artifactId>\n          <version>3.7.1</version>\n        </plugin>\n        <plugin>\n          <groupId>org.codehaus.mojo</groupId>\n          <artifactId>exec-maven-plugin</artifactId>\n          <version>3.1.0</version>\n        </plugin>\n        <plugin>\n          <groupId>org.codehaus.mojo</groupId>\n          <artifactId>properties-maven-plugin</artifactId>\n          <version>1.2.1</version>\n        </plugin>\n      </plugins>\n    </pluginManagement>\n    <plugins>\n\n      <plugin>\n        <groupId>org.xolstice.maven.plugins</groupId>\n        <artifactId>protobuf-maven-plugin</artifactId>\n        <version>0.6.1</version>\n        <configuration>\n          <checkStaleness>false</checkStaleness>\n          <protocArtifact>com.google.protobuf:protoc:${protoc.version}:exe:${os.detected.classifier}</protocArtifact>\n          <pluginId>grpc-java</pluginId>\n          <pluginArtifact>io.grpc:protoc-gen-grpc-java:${grpc.version}:exe:${os.detected.classifier}</pluginArtifact>\n\n          <protocPlugins>\n            <protocPlugin>\n              <id>simple</id>\n              <groupId>org.signal</groupId>\n              <artifactId>simple-grpc-generator</artifactId>\n              <version>${simple-grpc.version}</version>\n              <mainClass>org.signal.grpc.simple.SimpleGrpcGenerator</mainClass>\n            </protocPlugin>\n          </protocPlugins>\n        </configuration>\n        <executions>\n          <execution>\n            <goals>\n              <goal>compile</goal>\n              <goal>compile-custom</goal>\n              <goal>test-compile</goal>\n              <goal>test-compile-custom</goal>\n            </goals>\n          </execution>\n        </executions>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-compiler-plugin</artifactId>\n        <version>3.13.0</version>\n        <configuration>\n          <release>24</release>\n        </configuration>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-jar-plugin</artifactId>\n        <version>3.4.2</version>\n        <configuration>\n          <archive>\n            <manifest>\n              <addDefaultImplementationEntries>true</addDefaultImplementationEntries>\n            </manifest>\n          </archive>\n        </configuration>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-dependency-plugin</artifactId>\n        <version>3.8.1</version>\n        <executions>\n          <execution>\n            <!--\n              Set dependencies as properties for use in argLine property for mockito jar.\n              The property isn't needed until the test phase, and deferring it from the default\n              `initialize` addresses issues running lifecycle phases that precede `test` in isolation.\n            -->\n            <phase>process-test-classes</phase>\n            <goals>\n              <goal>properties</goal>\n            </goals>\n          </execution>\n        </executions>\n\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-enforcer-plugin</artifactId>\n        <version>3.5.0</version>\n        <executions>\n          <execution>\n            <goals>\n              <goal>enforce</goal>\n            </goals>\n            <configuration>\n              <rules>\n                <dependencyConvergence/>\n                <requireMavenVersion>\n                  <version>3.9.11</version>\n                </requireMavenVersion>\n              </rules>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-install-plugin</artifactId>\n        <version>3.1.3</version>\n        <configuration>\n          <skip>true</skip>\n        </configuration>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-deploy-plugin</artifactId>\n        <version>3.1.3</version>\n        <configuration>\n          <skip>true</skip>\n        </configuration>\n      </plugin>\n\n    </plugins>\n  </build>\n\n</project>\n"
  },
  {
    "path": "service/assembly.xml",
    "content": "<assembly xmlns=\"http://maven.apache.org/ASSEMBLY/2.1.0\"\n          xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n          xsi:schemaLocation=\"http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd\">\n    <id>bin</id>\n    <includeBaseDirectory>false</includeBaseDirectory>\n    <formats>\n        <format>tar.gz</format>\n    </formats>\n    <fileSets>\n        <fileSet>\n            <directory>${project.basedir}/config</directory>\n            <outputDirectory>/config</outputDirectory>\n            <includes>\n                <include>*</include>\n            </includes>\n        </fileSet>\n        <fileSet>\n            <directory>${project.build.directory}</directory>\n            <outputDirectory>/</outputDirectory>\n            <includes>\n                <include>${parent.artifactId}-${project.version}.jar</include>\n            </includes>\n        </fileSet>\n    </fileSets>\n</assembly>\n"
  },
  {
    "path": "service/config/sample-secrets-bundle.yml",
    "content": "stripe.apiKey: unset\nstripe.idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash\n\nbraintree.publicKey: unset\nbraintree.privateKey: unset\n\nappleAppStore.encodedKey: unset\n\ndirectoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users\ndirectoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users\n\nsvr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users\nsvr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users\n\nsvrb.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth tokens for Signal users\nsvrb.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth identity tokens for Signal users\n\ntus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=\n\ngcpAttachments.rsaSigningKey: |\n  -----BEGIN PRIVATE KEY-----\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  AAAAAAAA\n  -----END PRIVATE KEY-----\n\napn.teamId: team-id\napn.keyId: key-id\napn.signingKey: |\n  -----BEGIN PRIVATE KEY-----\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  AAAAAAAA\n  -----END PRIVATE KEY-----\n\nfcm.credentials: |\n  { \"json\": true }\n\ncdn.accessKey: test    # AWS Access Key ID\ncdn.accessSecret: test # AWS Access Secret\n\ncdn3StorageManager.clientSecret: test\n\nunidentifiedDelivery.privateKey: ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789AAAAAAA\n\nkeyTransparencyService.clientPrivateKey: |\n  -----BEGIN PRIVATE KEY-----\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n  AAAAAAAA\n  -----END PRIVATE KEY-----\n\nstorageService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\nzkConfig-libsignal-0.42.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdef\n\ngenericZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==\ncallingZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==\nbackupsZkConfig.serverSecret: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAA==\n\npaymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users\npaymentsService.fixerApiKey: unset\npaymentsService.coinGeckoApiKey: unset\n\ncurrentReportingKey.secret: AAAAAAAAAAA=\ncurrentReportingKey.salt: AAAAAAAAAAA=\n\nregistrationService.collationKeySalt: AAAAAAAAAAA=\n\nturn.cloudflare.apiToken: ABCDEFGHIJKLM\n\nlinkDevice.secret: AAAAAAAAAAA=\n\ntlsKeyStore.password: unset\n\nhlrLookup.apiKey: AAAAAAAAAAA\nhlrLookup.apiSecret: AAAAAAAAAAA\n"
  },
  {
    "path": "service/config/sample.yml",
    "content": "# Example, relatively minimal, configuration that passes validation (see `io.dropwizard.cli.CheckCommand`)\n#\n# `unset` values will need to be set to work properly.\n# Most other values are technically valid for a local/demonstration environment, but are probably not production-ready.\n\nlogging:\n  level: INFO\n  appenders:\n    - type: console\n      threshold: ALL\n      timeZone: UTC\n      target: stdout\n    - type: otlp\n\ntlsKeyStore:\n  password: secret://tlsKeyStore.password\n\nstripe:\n  apiKey: secret://stripe.apiKey\n  idempotencyKeyGenerator: secret://stripe.idempotencyKeyGenerator\n  boostDescription: >\n    Example\n  supportedCurrenciesByPaymentMethod:\n    CARD:\n      - usd\n      - eur\n    SEPA_DEBIT:\n      - eur\n\nbraintree:\n  merchantId: unset\n  publicKey: secret://braintree.publicKey\n  privateKey: secret://braintree.privateKey\n  environment: unset\n  graphqlUrl: unset\n  merchantAccounts:\n    # ISO 4217 currency code and its corresponding sub-merchant account\n    'xts': unset\n  supportedCurrenciesByPaymentMethod:\n    PAYPAL:\n      - usd\n  pubSubPublisher:\n    project: example-project\n    topic: example-topic\n    credentialConfiguration: |\n      {\n        \"credential\": \"configuration\"\n      }\n\ngooglePlayBilling:\n  credentialsJson: |\n    {\n      \"credential\": \"configuration\"\n    }\n  packageName: package.name\n  applicationName: test\n  productIdToLevel: {}\n\nappleAppStore:\n  env: SANDBOX\n  bundleId: bundle.name\n  appAppleId: 12345\n  issuerId: abcdefg\n  keyId: abcdefg\n  encodedKey: secret://appleAppStore.encodedKey\n  subscriptionGroupId: example_subscriptionGroupId\n  productIdToLevel: {}\n  appleRootCerts: []\n\nappleDeviceCheck:\n  production: false\n  teamId: 0123456789\n  bundleId: bundle.name\n\ndeviceCheck:\n  backupRedemptionDuration: P30D\n  backupRedemptionLevel: 201\n\ndynamoDbClient:\n  region: us-west-2 # AWS Region\n\ndynamoDbTables:\n  accounts:\n    tableName: Example_Accounts\n    phoneNumberTableName: Example_Accounts_PhoneNumbers\n    phoneNumberIdentifierTableName: Example_Accounts_PhoneNumberIdentifiers\n    usernamesTableName: Example_Accounts_Usernames\n    usedLinkDeviceTokensTableName: Example_Accounts_UsedLinkDeviceTokens\n  appleDeviceChecks:\n    tableName: Example_AppleDeviceChecks\n  appleDeviceCheckPublicKeys:\n    tableName: Example_AppleDeviceCheckPublicKeys\n  backups:\n    tableName: Example_Backups\n  clientReleases:\n    tableName: Example_ClientReleases\n  deletedAccounts:\n    tableName: Example_DeletedAccounts\n  deletedAccountsLock:\n    tableName: Example_DeletedAccountsLock\n  issuedReceipts:\n    tableName: Example_IssuedReceipts\n    expiration: P30D # Duration of time until rows expire\n    generator: abcdefg12345678= # random base64-encoded binary sequence\n    maxIssuedReceiptsPerPaymentId:\n      STRIPE: 1\n      BRAINTREE: 1\n      GOOGLE_PLAY_BILLING: 1\n      APPLE_APP_STORE: 1\n  ecKeys:\n    tableName: Example_Keys\n  ecSignedPreKeys:\n    tableName: Example_EC_Signed_Pre_Keys\n  pagedPqKeys:\n    tableName: Example_PQ_Paged_Keys\n  pqLastResortKeys:\n    tableName: Example_PQ_Last_Resort_Keys\n  messages:\n    tableName: Example_Messages\n    expiration: P30D # Duration of time until rows expire\n  onetimeDonations:\n    tableName: Example_OnetimeDonations\n    expiration: P90D\n  phoneNumberIdentifiers:\n    tableName: Example_PhoneNumberIdentifiers\n  profiles:\n    tableName: Example_Profiles\n  pushChallenge:\n    tableName: Example_PushChallenge\n  pushNotificationExperimentSamples:\n    tableName: Example_PushNotificationExperimentSamples\n  redeemedReceipts:\n    tableName: Example_RedeemedReceipts\n    expiration: P30D # Duration of time until rows expire\n  registrationRecovery:\n    tableName: Example_RegistrationRecovery\n    expiration: P300D # Duration of time until rows expire\n  remoteConfig:\n    tableName: Example_RemoteConfig\n  reportMessage:\n    tableName: Example_ReportMessage\n  scheduledJobs:\n    tableName: Example_ScheduledJobs\n    expiration: P7D\n  subscriptions:\n    tableName: Example_Subscriptions\n  clientPublicKeys:\n    tableName: Example_ClientPublicKeys\n  verificationSessions:\n    tableName: Example_VerificationSessions\n\npagedSingleUseKEMPreKeyStore:\n  bucket: preKeyBucket # S3 Bucket name\n  region: us-west-2    # AWS region\n\ncacheCluster: # Redis server configuration for cache cluster\n  configurationUri: redis://redis.example.com:6379/\n\npubsub: # Redis server configuration for pubsub cluster\n  uri: redis://redis.example.com:6379/\n\npushSchedulerCluster: # Redis server configuration for push scheduler cluster\n  configurationUri: redis://redis.example.com:6379/\n\nrateLimitersCluster: # Redis server configuration for rate limiters cluster\n  configurationUri: redis://redis.example.com:6379/\n\ndirectoryV2:\n  client: # Configuration for interfacing with Contact Discovery Service v2 cluster\n    userAuthenticationTokenSharedSecret: secret://directoryV2.client.userAuthenticationTokenSharedSecret\n    userIdTokenSharedSecret: secret://directoryV2.client.userIdTokenSharedSecret\n\nsvr2:\n  uri: svr2.example.com\n  userAuthenticationTokenSharedSecret: secret://svr2.userAuthenticationTokenSharedSecret\n  userIdTokenSharedSecret: secret://svr2.userIdTokenSharedSecret\n  svrCaCertificates:\n    - |\n      -----BEGIN CERTIFICATE-----\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      AAAAAAAAAAAAAAAAAAAA\n      -----END CERTIFICATE-----\n\nsvrb:\n  uri: svrb.example.com\n  userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret\n  userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret\n  svrCaCertificates:\n    - |\n      -----BEGIN CERTIFICATE-----\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      AAAAAAAAAAAAAAAAAAAA\n      -----END CERTIFICATE-----\n\nmessageCache: # Redis server configuration for message store cache\n  persistDelayMinutes: 1\n  cluster:\n    configurationUri: redis://redis.example.com:6379/\n\ngcpAttachments: # GCP Storage configuration\n  domain: example.com\n  email: user@example.cocm\n  maxSizeInBytes: 1024\n  pathPrefix:\n  rsaSigningKey: secret://gcpAttachments.rsaSigningKey\n\ntus:\n  uploadUri: https://example.org/upload\n  userAuthenticationTokenSharedSecret: secret://tus.userAuthenticationTokenSharedSecret\n\napn: # Apple Push Notifications configuration\n  sandbox: true\n  bundleId: com.example.textsecuregcm\n  keyId: secret://apn.keyId\n  teamId: secret://apn.teamId\n  signingKey: secret://apn.signingKey\n\nfcm: # FCM configuration\n  credentials: secret://fcm.credentials\n\ncdn:\n  bucket: cdn        # S3 Bucket name\n  credentials:\n    accessKeyId: secret://cdn.accessKey\n    secretAccessKey: secret://cdn.accessSecret\n  region: us-west-2  # AWS region\n\ncdn3StorageManager:\n  baseUri: https://storage-manager.example.com\n  clientId: example\n  clientSecret: secret://cdn3StorageManager.clientSecret\n  sourceSchemes:\n    2: gcs\n    3: r2\n\nopenTelemetry:\n  enabled: true\n  environment: dev\n  url: http://127.0.0.1:4318/\n\nunidentifiedDelivery:\n  certificate: CgIIAQ==\n  privateKey: secret://unidentifiedDelivery.privateKey\n  expiresDays: 7\n  embedSigner: true\n\nshortCode:\n  baseUrl: https://example.com/shortcodes/\n\nstorageService:\n  uri: storage.example.com\n  userAuthenticationTokenSharedSecret: secret://storageService.userAuthenticationTokenSharedSecret\n  storageCaCertificates:\n    - |\n      -----BEGIN CERTIFICATE-----\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n      AAAAAAAAAAAAAAAAAAAA\n      -----END CERTIFICATE-----\n\nzkConfig:\n  serverPublic: ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyzAB==\n  serverSecret: secret://zkConfig-libsignal-0.42.serverSecret\n\ncallingZkConfig:\n  serverSecret: secret://callingZkConfig.serverSecret\n\nbackupsZkConfig:\n  serverSecret: secret://backupsZkConfig.serverSecret\n\ndynamicConfig:\n  s3Region: a-region\n  s3Bucket: a-bucket\n  objectKey: dynamic-config.yaml\n  maxSize: 100000\n  refreshInterval: PT10S\n\nremoteConfig:\n  globalConfig: # keys and values that are given to clients on GET /v1/config\n    EXAMPLE_KEY: VALUE\n\npaymentsService:\n  userAuthenticationTokenSharedSecret: secret://paymentsService.userAuthenticationTokenSharedSecret\n  paymentCurrencies:\n    # list of symbols for supported currencies\n    - MOB\n  externalClients:\n    fixerApiKey: secret://paymentsService.fixerApiKey\n    coinGeckoApiKey: secret://paymentsService.coinGeckoApiKey\n    coinGeckoCurrencyIds:\n      MOB: mobilecoin\n\nbadges:\n  badges:\n    - id: TEST\n      category: other\n      sprites: # exactly 6\n        - sprite-1.png\n        - sprite-2.png\n        - sprite-3.png\n        - sprite-4.png\n        - sprite-5.png\n        - sprite-6.png\n      svg: example.svg\n      svgs:\n        - light: example-light.svg\n          dark: example-dark.svg\n  badgeIdsEnabledForAll:\n    - TEST\n  receiptLevels:\n    '1': TEST\n\nsubscription: # configuration for Stripe subscriptions\n  badgeExpiration: P30D\n  badgeGracePeriod: P15D\n  backupExpiration: P30D\n  backupGracePeriod: P15D\n  backupFreeTierMediaDuration: P30D\n  levels:\n    500:\n      badge: EXAMPLE\n      prices:\n        # list of ISO 4217 currency codes and amounts for the given badge level\n        xts:\n          amount: '10'\n          processorIds:\n            STRIPE: price_example   # stripe Price ID\n            BRAINTREE: plan_example # braintree Plan ID\n\noneTimeDonations:\n  sepaMaximumEuros: '10000'\n  boost:\n    level: 1\n    expiration: P90D\n    badge: EXAMPLE\n  gift:\n    level: 10\n    expiration: P90D\n    badge: EXAMPLE\n  currencies:\n    # ISO 4217 currency codes and amounts in those currencies\n    xts:\n      minimum: '0.5'\n      gift: '2'\n      boosts:\n        - '1'\n        - '2'\n        - '4'\n        - '8'\n        - '20'\n        - '40'\n\nregistrationService:\n  host: registration.example.com\n  port: 443\n  credentialConfigurationJson: |\n    {\n      \"example\": \"example\"\n    }\n  identityTokenAudience: https://registration.example.com\n  collationKeySalt: secret://registrationService.collationKeySalt\n  registrationCaCertificate: | # Registration service TLS certificate trust root\n    -----BEGIN CERTIFICATE-----\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    AAAAAAAAAAAAAAAAAAAA\n    -----END CERTIFICATE-----\n\nkeyTransparencyService:\n  host: kt.example.com\n  port: 443\n  tlsCertificate: |\n    -----BEGIN CERTIFICATE-----\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    AAAAAAAAAAAAAAAAAAAA\n    -----END CERTIFICATE-----\n  clientCertificate: |\n    -----BEGIN CERTIFICATE-----\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    ABCDEFGHIJKLMNOPQRSTUVWXYZ/0123456789+abcdefghijklmnopqrstuvwxyz\n    AAAAAAAAAAAAAAAAAAAA\n    -----END CERTIFICATE-----\n  clientPrivateKey: secret://keyTransparencyService.clientPrivateKey\n\nturn:\n  cloudflare:\n    apiToken: secret://turn.cloudflare.apiToken\n    endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate\n    urls:\n      - turn:turn.example.com:80\n    urlsWithIps:\n      - turn:%s\n      - turn:%s:80?transport=tcp\n      - turns:%s:443?transport=tcp\n    requestedCredentialTtl: PT24H\n    clientCredentialTtl: PT12H\n    hostname: turn.cloudflare.example.com\n    numHttpClients: 1\n\nlinkDevice:\n  secret: secret://linkDevice.secret\n\nexternalRequestFilter:\n  grpcMethods:\n    - com.example.grpc.ExampleService/exampleMethod\n  paths:\n    - /example\n  permittedInternalRanges:\n    - 127.0.0.0/8\n\nidlePrimaryDeviceReminder:\n  minIdleDuration: P30D\n\ngrpc:\n  port: 50051\n\nasnTable:\n  s3Region: a-region\n  s3Bucket: a-bucket\n  objectKey: asn.tsv\n  maxSize: 100000\n  refreshInterval: PT10S\n\ncallQualitySurvey:\n  pubSubPublisher:\n    project: example-project\n    topic: example-topic\n    credentialConfiguration: |\n      {\n        \"credential\": \"configuration\"\n      }\n\nhlrLookup:\n  apiKey: secret://hlrLookup.apiKey\n  apiSecret: secret://hlrLookup.apiSecret\n"
  },
  {
    "path": "service/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n  xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n  <parent>\n    <artifactId>TextSecureServer</artifactId>\n    <groupId>org.whispersystems.textsecure</groupId>\n    <version>JGITVER</version>\n  </parent>\n  <modelVersion>4.0.0</modelVersion>\n  <artifactId>service</artifactId>\n\n  <properties>\n    <apollo-api-jvm.version>3.8.5</apollo-api-jvm.version>\n    <commons-compress.version>1.28.0</commons-compress.version>\n    <dynamodb-lock-client.version>1.4.0</dynamodb-lock-client.version>\n    <firebase-admin.version>9.7.0</firebase-admin.version>\n    <libphonenumber-geocoder.version>3.23</libphonenumber-geocoder.version>\n    <google-androidpublisher.version>v3-rev20250904-2.0.0</google-androidpublisher.version>\n    <java-jwt.version>4.5.0</java-jwt.version>\n    <java-uuid-generator.version>5.2.0</java-uuid-generator.version>\n    <!-- *all* opentelemetry-logback-appender versions are \"alpha\" despite the advanced version number -->\n    <opentelemetry-logback-appender-1.0.version>2.22.0-alpha</opentelemetry-logback-appender-1.0.version>\n    <storekit.version>4.0.0</storekit.version>\n    <webauthn4j.version>0.30.2.RELEASE</webauthn4j.version>\n  </properties>\n\n  <dependencies>\n    <dependency>\n      <groupId>com.auth0</groupId>\n      <artifactId>java-jwt</artifactId>\n      <version>${java-jwt.version}</version>\n    </dependency>\n    <dependency>\n      <groupId>com.google.apis</groupId>\n      <artifactId>google-api-services-androidpublisher</artifactId>\n      <version>${google-androidpublisher.version}</version>\n    </dependency>\n    <dependency>\n      <groupId>com.apple.itunes.storekit</groupId>\n      <artifactId>app-store-server-library</artifactId>\n      <version>${storekit.version}</version>\n      <exclusions>\n        <!-- conflicts with other users; resolved manually with explicit import -->\n        <exclusion>\n          <groupId>com.squareup.okio</groupId>\n          <artifactId>okio-jvm</artifactId>\n        </exclusion>\n      </exclusions>\n    </dependency>\n    <dependency>\n      <groupId>com.webauthn4j</groupId>\n      <artifactId>webauthn4j-appattest</artifactId>\n      <version>${webauthn4j.version}</version>\n    </dependency>\n    <dependency>\n      <groupId>io.swagger.core.v3</groupId>\n      <artifactId>swagger-jaxrs2-jakarta</artifactId>\n      <version>${swagger.version}</version>\n      <exclusions>\n        <!-- conflicts with jackson-dataformat-yaml -->\n        <exclusion>\n          <groupId>org.yaml</groupId>\n          <artifactId>snakeyaml</artifactId>\n        </exclusion>\n      </exclusions>\n    </dependency>\n    <dependency>\n      <groupId>jakarta.servlet</groupId>\n      <artifactId>jakarta.servlet-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>jakarta.validation</groupId>\n      <artifactId>jakarta.validation-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>jakarta.ws.rs</groupId>\n      <artifactId>jakarta.ws.rs-api</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>org.whispersystems.textsecure</groupId>\n      <artifactId>websocket-resources</artifactId>\n      <version>${project.version}</version>\n    </dependency>\n    <dependency>\n      <groupId>org.signal</groupId>\n      <artifactId>libsignal-server</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>org.signal</groupId>\n      <artifactId>simple-grpc-runtime</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-auth</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-client</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-http2</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-logging</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-metrics</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-util</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-servlets</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-lifecycle</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-jersey</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-jetty</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-validation</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-migrations</artifactId>\n      <scope>runtime</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>org.slf4j</groupId>\n      <artifactId>slf4j-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>ch.qos.logback</groupId>\n      <artifactId>logback-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>ch.qos.logback.access</groupId>\n      <artifactId>logback-access-common</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>ch.qos.logback</groupId>\n      <artifactId>logback-classic</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>net.logstash.logback</groupId>\n      <artifactId>logstash-logback-encoder</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.dropwizard.metrics</groupId>\n      <artifactId>metrics-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard.metrics</groupId>\n      <artifactId>metrics-healthchecks</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard.metrics</groupId>\n      <artifactId>metrics-annotation</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.glassfish.jersey.core</groupId>\n      <artifactId>jersey-common</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.glassfish.jersey.core</groupId>\n      <artifactId>jersey-server</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.glassfish.jersey.core</groupId>\n      <artifactId>jersey-client</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-testing</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>io.opentelemetry</groupId>\n      <artifactId>opentelemetry-sdk</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.opentelemetry</groupId>\n      <artifactId>opentelemetry-exporter-otlp</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.opentelemetry.instrumentation</groupId>\n      <artifactId>opentelemetry-logback-appender-1.0</artifactId>\n      <version>${opentelemetry-logback-appender-1.0.version}</version>\n      <exclusions>\n        <!-- incubator packages aren't included in the opentelemetry BOM, and we don't use them -->\n        <exclusion>\n          <groupId>io.opentelemetry.instrumentation</groupId>\n          <artifactId>opentelemetry-instrumentation-api-incubator</artifactId>\n        </exclusion>\n      </exclusions>\n    </dependency>\n\n    <dependency>\n      <groupId>party.iroiro.luajava</groupId>\n      <artifactId>luajava</artifactId>\n      <version>${luajava.version}</version>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>party.iroiro.luajava</groupId>\n      <artifactId>lua51</artifactId>\n      <version>${luajava.version}</version>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>party.iroiro.luajava</groupId>\n      <artifactId>lua51-platform</artifactId>\n      <version>${luajava.version}</version>\n      <classifier>natives-desktop</classifier>\n      <scope>runtime</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>org.eclipse.jetty.websocket</groupId>\n      <artifactId>websocket-jetty-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.eclipse.jetty</groupId>\n      <artifactId>jetty-servlets</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.eclipse.jetty.websocket</groupId>\n      <artifactId>websocket-jetty-client</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>org.apache.commons</groupId>\n      <artifactId>commons-lang3</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.apache.commons</groupId>\n      <artifactId>commons-csv</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.apache.commons</groupId>\n      <artifactId>commons-compress</artifactId>\n      <version>${commons-compress.version}</version>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.cloud</groupId>\n      <artifactId>google-cloud-pubsub</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.firebase</groupId>\n      <artifactId>firebase-admin</artifactId>\n      <version>${firebase-admin.version}</version>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.cloud</groupId>\n      <artifactId>google-cloud-firestore</artifactId>\n      <exclusions>\n        <!-- incubator packages aren't included in the opentelemetry BOM, and we don't use them -->\n        <exclusion>\n          <groupId>io.opentelemetry.instrumentation</groupId>\n          <artifactId>opentelemetry-instrumentation-api-incubator</artifactId>\n        </exclusion>\n      </exclusions>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.code.findbugs</groupId>\n      <artifactId>jsr305</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.github.resilience4j</groupId>\n      <artifactId>resilience4j-circuitbreaker</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.github.resilience4j</groupId>\n      <artifactId>resilience4j-retry</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.github.resilience4j</groupId>\n      <artifactId>resilience4j-reactor</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.grpc</groupId>\n      <artifactId>grpc-netty</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.grpc</groupId>\n      <artifactId>grpc-protobuf</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.grpc</groupId>\n      <artifactId>grpc-stub</artifactId>\n    </dependency>\n    <!-- Needed for gRPC with Java 9+ -->\n    <dependency>\n      <groupId>org.apache.tomcat</groupId>\n      <artifactId>annotations-api</artifactId>\n      <scope>provided</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>io.micrometer</groupId>\n      <artifactId>micrometer-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.micrometer</groupId>\n      <artifactId>micrometer-registry-otlp</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.core</groupId>\n      <artifactId>jackson-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.core</groupId>\n      <artifactId>jackson-annotations</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.core</groupId>\n      <artifactId>jackson-databind</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.dataformat</groupId>\n      <artifactId>jackson-dataformat-yaml</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.datatype</groupId>\n      <artifactId>jackson-datatype-jsr310</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.jaxrs</groupId>\n      <artifactId>jackson-jaxrs-json-provider</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>org.foundationdb</groupId>\n      <artifactId>fdb-java</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>apache-client</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>netty-nio-client</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>sts</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>s3</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>software.amazon.awssdk</groupId>\n      <artifactId>dynamodb</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.amazonaws</groupId>\n      <artifactId>dynamodb-lock-client</artifactId>\n      <version>${dynamodb-lock-client.version}</version>\n    </dependency>\n\n    <dependency>\n      <groupId>io.lettuce</groupId>\n      <artifactId>lettuce-core</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.eatthepath</groupId>\n      <artifactId>pushy</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.eatthepath</groupId>\n      <artifactId>pushy-dropwizard-metrics-listener</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.vdurmont</groupId>\n      <artifactId>semver4j</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.guava</groupId>\n      <artifactId>guava</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.protobuf</groupId>\n      <artifactId>protobuf-java</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.googlecode.libphonenumber</groupId>\n      <artifactId>libphonenumber</artifactId>\n    </dependency>\n\n    <!-- Provides tools for mapping phone numbers to time zones, which is helpful for scheduling push notifications\n    during waking hours -->\n    <dependency>\n      <groupId>com.googlecode.libphonenumber</groupId>\n      <artifactId>geocoder</artifactId>\n      <version>${libphonenumber-geocoder.version}</version>\n    </dependency>\n\n    <dependency>\n      <groupId>net.sourceforge.argparse4j</groupId>\n      <artifactId>argparse4j</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.netty</groupId>\n      <artifactId>netty-codec-haproxy</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>io.netty</groupId>\n      <artifactId>netty-transport-native-epoll</artifactId>\n      <classifier>linux-x86_64</classifier>\n    </dependency>\n\n    <dependency>\n      <groupId>org.glassfish.jersey.test-framework</groupId>\n      <artifactId>jersey-test-framework-core</artifactId>\n      <scope>test</scope>\n      <exclusions>\n        <exclusion>\n          <groupId>junit</groupId>\n          <artifactId>junit</artifactId>\n        </exclusion>\n      </exclusions>\n    </dependency>\n    <dependency>\n      <groupId>org.glassfish.jersey.test-framework.providers</groupId>\n      <artifactId>jersey-test-framework-provider-grizzly2</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>io.projectreactor</groupId>\n      <artifactId>reactor-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.projectreactor</groupId>\n      <artifactId>reactor-core-micrometer</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter-params</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>io.projectreactor</groupId>\n      <artifactId>reactor-test</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.redis</groupId>\n      <artifactId>testcontainers-redis</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>com.fasterxml.uuid</groupId>\n      <artifactId>java-uuid-generator</artifactId>\n      <version>${java-uuid-generator.version}</version>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>org.testcontainers</groupId>\n      <artifactId>testcontainers-localstack</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>org.testcontainers</groupId>\n      <artifactId>testcontainers-junit-jupiter</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>earth.adi</groupId>\n      <artifactId>testcontainers-foundationdb</artifactId>\n      <scope>test</scope>\n    </dependency>\n\n    <dependency>\n      <groupId>com.google.auth</groupId>\n      <artifactId>google-auth-library-oauth2-http</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.stripe</groupId>\n      <artifactId>stripe-java</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.braintreepayments.gateway</groupId>\n      <artifactId>braintree-java</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>com.apollographql.apollo3</groupId>\n      <artifactId>apollo-api-jvm</artifactId>\n      <version>${apollo-api-jvm.version}</version>\n\n      <exclusions>\n        <exclusion>\n          <groupId>org.jetbrains</groupId>\n          <artifactId>annotations</artifactId>\n        </exclusion>\n        <!-- conflicts with other users; resolved manually with explicit import -->\n        <exclusion>\n          <groupId>com.squareup.okio</groupId>\n          <artifactId>okio-jvm</artifactId>\n        </exclusion>\n      </exclusions>\n    </dependency>\n\n    <!-- to resolve conflicting imports from other dependencies -->\n    <dependency>\n      <groupId>com.squareup.okio</groupId>\n      <artifactId>okio-jvm</artifactId>\n      <version>3.15.0</version>\n    </dependency>\n\n  </dependencies>\n\n  <profiles>\n    <profile>\n      <id>exclude-spam-filter</id>\n      <build>\n        <plugins>\n          <plugin>\n            <groupId>io.github.download-maven-plugin</groupId>\n            <artifactId>download-maven-plugin</artifactId>\n            <version>2.0.0</version>\n\n            <executions>\n              <execution>\n                <id>install-foundationdb-client-library</id>\n                <phase>prepare-package</phase>\n                <goals>\n                  <goal>wget</goal>\n                </goals>\n              </execution>\n            </executions>\n\n            <configuration>\n              <url>https://github.com/apple/foundationdb/releases/download/${foundationdb.version}/libfdb_c.x86_64.so</url>\n              <outputDirectory>${project.build.directory}/jib-extra/usr/lib</outputDirectory>\n              <sha256>${foundationdb.client-library-sha256}</sha256>\n            </configuration>\n          </plugin>\n\n          <plugin>\n            <groupId>org.apache.maven.plugins</groupId>\n            <artifactId>maven-shade-plugin</artifactId>\n            <configuration>\n              <createDependencyReducedPom>true</createDependencyReducedPom>\n              <filters>\n                <filter>\n                  <artifact>*:*</artifact>\n                  <excludes>\n                    <exclude>META-INF/*.SF</exclude>\n                    <exclude>META-INF/*.DSA</exclude>\n                    <exclude>META-INF/*.RSA</exclude>\n                  </excludes>\n                </filter>\n              </filters>\n            </configuration>\n            <executions>\n              <execution>\n                <phase>package</phase>\n                <goals>\n                  <goal>shade</goal>\n                </goals>\n                <configuration>\n                  <transformers>\n                    <transformer implementation=\"org.apache.maven.plugins.shade.resource.ServicesResourceTransformer\"/>\n                    <transformer implementation=\"org.apache.maven.plugins.shade.resource.ManifestResourceTransformer\">\n                      <mainClass>org.whispersystems.textsecuregcm.WhisperServerService</mainClass>\n                    </transformer>\n                  </transformers>\n                </configuration>\n              </execution>\n            </executions>\n          </plugin>\n\n          <plugin>\n            <groupId>org.apache.maven.plugins</groupId>\n            <artifactId>maven-assembly-plugin</artifactId>\n            <configuration>\n              <descriptors>\n                <descriptor>assembly.xml</descriptor>\n              </descriptors>\n            </configuration>\n            <executions>\n              <execution>\n                <id>make-assembly</id> <!-- this is used for inheritance merges -->\n                <phase>package</phase> <!-- bind to the packaging phase -->\n                <goals>\n                  <goal>single</goal>\n                </goals>\n              </execution>\n            </executions>\n          </plugin>\n\n          <plugin>\n            <groupId>org.codehaus.mojo</groupId>\n            <artifactId>properties-maven-plugin</artifactId>\n            <executions>\n              <execution>\n                <id>read-deploy-configuration</id>\n                <phase>deploy</phase>\n                <goals>\n                  <goal>read-project-properties</goal>\n                </goals>\n                <configuration>\n                  <files>${project.basedir}/config/deploy.properties</files>\n                </configuration>\n              </execution>\n            </executions>\n          </plugin>\n\n          <plugin>\n            <groupId>com.google.cloud.tools</groupId>\n            <artifactId>jib-maven-plugin</artifactId>\n            <executions>\n              <execution>\n                <phase>deploy</phase>\n                <goals>\n                  <goal>build</goal>\n                </goals>\n              </execution>\n            </executions>\n            <configuration>\n              <from>\n                <image>eclipse-temurin@sha256:${docker.image.sha256}</image>\n                <platforms>\n                  <platform>\n                    <architecture>amd64</architecture>\n                    <os>linux</os>\n                  </platform>\n                  <platform>\n                    <architecture>arm64</architecture>\n                    <os>linux</os>\n                  </platform>\n                </platforms>\n              </from>\n              <to>\n                <image>${docker.repo}:${project.version}</image>\n              </to>\n              <container>\n                <mainClass>org.whispersystems.textsecuregcm.WhisperServerService</mainClass>\n                <jvmFlags>\n                  <jvmFlag>-server</jvmFlag>\n                  <jvmFlag>-Djava.awt.headless=true</jvmFlag>\n                  <jvmFlag>-Djdk.nio.maxCachedBufferSize=262144</jvmFlag>\n                  <jvmFlag>-Dlog4j2.formatMsgNoLookups=true</jvmFlag>\n                  <jvmFlag>-Djdk.tls.server.newSessionTicketCount=0</jvmFlag>\n                  <jvmFlag>-XX:MaxRAMPercentage=75</jvmFlag>\n                  <jvmFlag>-XX:+HeapDumpOnOutOfMemoryError</jvmFlag>\n                  <jvmFlag>-XX:HeapDumpPath=/tmp/heapdump.bin</jvmFlag>\n                </jvmFlags>\n                <ports>\n                  <port>8080</port>\n                </ports>\n                <creationTime>USE_CURRENT_TIMESTAMP</creationTime>\n              </container>\n              <extraDirectories>\n                <paths>\n                  <path>\n                    <from>${project.basedir}/config</from>\n                    <includes>*.yml</includes>\n                    <into>/usr/share/signal/</into>\n                  </path>\n                  <path>\n                    <from>${project.build.directory}/jib-extra</from>\n                  </path>\n                </paths>\n              </extraDirectories>\n            </configuration>\n          </plugin>\n        </plugins>\n      </build>\n    </profile>\n    <profile>\n      <id>include-spam-filter</id>\n      <build>\n        <plugins>\n          <plugin>\n            <groupId>com.google.cloud.tools</groupId>\n            <artifactId>jib-maven-plugin</artifactId>\n            <configuration>\n              <!-- we don't want jib to execute on this module -->\n              <skip>true</skip>\n            </configuration>\n          </plugin>\n        </plugins>\n      </build>\n    </profile>\n    <profile>\n      <id>test-server</id>\n      <build>\n        <plugins>\n          <plugin>\n            <groupId>org.codehaus.mojo</groupId>\n            <artifactId>exec-maven-plugin</artifactId>\n            <executions>\n              <execution>\n                <id>start-test-server</id>\n                <phase>integration-test</phase>\n                <goals>\n                  <goal>java</goal>\n                </goals>\n                <configuration>\n                  <mainClass>org.whispersystems.textsecuregcm.LocalWhisperServerService</mainClass>\n                  <classpathScope>test</classpathScope>\n                </configuration>\n              </execution>\n            </executions>\n          </plugin>\n        </plugins>\n      </build>\n    </profile>\n  </profiles>\n\n  <build>\n    <finalName>${project.parent.artifactId}-${project.version}</finalName>\n    <plugins>\n      <plugin>\n        <groupId>org.codehaus.mojo</groupId>\n        <artifactId>templating-maven-plugin</artifactId>\n        <version>3.0.0</version>\n        <executions>\n          <execution>\n            <id>filter-src</id>\n            <goals>\n              <goal>filter-sources</goal>\n            </goals>\n          </execution>\n          <execution>\n            <id>filter-test-src</id>\n            <goals>\n              <goal>filter-test-sources</goal>\n            </goals>\n          </execution>\n        </executions>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-surefire-plugin</artifactId>\n        <configuration>\n          <!-- add-opens: work around PATCH not being a supported method on HttpUrlConnection -->\n          <argLine>-javaagent:${org.mockito:mockito-core:jar} --add-opens=java.base/java.net=ALL-UNNAMED</argLine>\n        </configuration>\n      </plugin>\n\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-jar-plugin</artifactId>\n        <executions>\n          <execution>\n            <goals>\n              <goal>test-jar</goal>\n            </goals>\n          </execution>\n        </executions>\n      </plugin>\n\n      <plugin>\n        <groupId>org.codehaus.mojo</groupId>\n        <artifactId>exec-maven-plugin</artifactId>\n        <executions>\n          <execution>\n            <id>check-all-service-config</id>\n            <phase>verify</phase>\n            <goals>\n              <goal>java</goal>\n            </goals>\n            <configuration>\n              <mainClass>org.whispersystems.textsecuregcm.CheckServiceConfigurations</mainClass>\n              <classpathScope>test</classpathScope>\n              <arguments>\n                <argument>${project.basedir}/config</argument>\n              </arguments>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n\n      <plugin>\n        <groupId>com.github.aoudiamoncef</groupId>\n        <artifactId>apollo-client-maven-plugin</artifactId>\n        <version>7.1.0</version>\n        <executions>\n          <execution>\n            <goals>\n              <goal>generate</goal>\n            </goals>\n            <configuration>\n              <services>\n                <braintree>\n                  <compilationUnit>\n                    <name>braintree</name>\n                    <compilerParams>\n                      <schemaPackageName>com.braintree.graphql.client</schemaPackageName>\n                      <!-- override the default in 7.1.0, see https://github.com/aoudiamoncef/apollo-client-maven-plugin/issues/84 -->\n                      <operationManifestFormat>none</operationManifestFormat>\n                    </compilerParams>\n                  </compilationUnit>\n                </braintree>\n              </services>\n            </configuration>\n          </execution>\n        </executions>\n      </plugin>\n    </plugins>\n  </build>\n</project>\n"
  },
  {
    "path": "service/src/main/graphql/braintree/ChargePayPalOneTimePayment.graphql",
    "content": "# https://graphql.braintreepayments.com/reference/#Mutation--chargePaymentMethod\nmutation ChargePayPalOneTimePayment($input: ChargePaymentMethodInput!) {\n  chargePaymentMethod(input: $input) {\n    transaction {\n      id,\n      status\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/graphql/braintree/CreatePayPalBillingAgreement.graphql",
    "content": "mutation CreatePayPalBillingAgreement($input: CreatePayPalBillingAgreementInput!) {\n  createPayPalBillingAgreement(input: $input) {\n    approvalUrl,\n    billingAgreementToken\n  }\n}\n"
  },
  {
    "path": "service/src/main/graphql/braintree/CreatePayPalOneTimePayment.graphql",
    "content": "# https://graphql.braintreepayments.com/reference/#Mutation--createPayPalOneTimePayment\nmutation CreatePayPalOneTimePayment($input: CreatePayPalOneTimePaymentInput!) {\n  createPayPalOneTimePayment(input: $input) {\n    approvalUrl,\n    paymentId\n  }\n}\n"
  },
  {
    "path": "service/src/main/graphql/braintree/TokenizePayPalBillingAgreement.graphql",
    "content": "mutation TokenizePayPalBillingAgreement($input: TokenizePayPalBillingAgreementInput!) {\n  tokenizePayPalBillingAgreement(input: $input) {\n    paymentMethod {\n      id\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/graphql/braintree/TokenizePayPalOneTimePayment.graphql",
    "content": "# https://graphql.braintreepayments.com/reference/#Mutation--tokenizePayPalOneTimePayment\nmutation TokenizePayPalOneTimePayment($input: TokenizePayPalOneTimePaymentInput!) {\n  tokenizePayPalOneTimePayment(input: $input) {\n    paymentMethod {\n      id\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/graphql/braintree/VaultPaymentMethod.graphql",
    "content": "mutation VaultPaymentMethod($input: VaultPaymentMethodInput!) {\n  vaultPaymentMethod(input: $input) {\n    paymentMethod {\n      id\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/graphql/braintree/schema.json",
    "content": "{\n  \"data\": {\n    \"__schema\": {\n      \"queryType\": {\n        \"name\": \"Query\"\n      },\n      \"mutationType\": {\n        \"name\": \"Mutation\"\n      },\n      \"subscriptionType\": null,\n      \"types\": [\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ACHStandardEntryClassCode\",\n          \"description\": \"A NACHA standard entry class (SEC) code, which designates how an ACH transaction was authorized.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CCD\",\n              \"description\": \"Corporate credit or debit.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PPD\",\n              \"description\": \"Prearranged payment and deposit.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TEL\",\n              \"description\": \"Telephone-initiated.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WEB\",\n              \"description\": \"Internet-initiated/mobile.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ACRType\",\n          \"description\": \"The authentication context class reference that indcates how a universal access token can be used.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CLIENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SERVER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AcceptDisputeInput\",\n          \"description\": \"Top-level input fields for accepting a dispute.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disputeId\",\n              \"description\": \"The ID of the dispute to be accepted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"AcceptDisputePayload\",\n          \"description\": \"Top-level field returned when accepting a dispute.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"dispute\",\n              \"description\": \"Information about the dispute that was accepted.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Dispute\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"AccessToken\",\n          \"description\": \"An OAuth access token.\",\n          \"fields\": [\n            {\n              \"name\": \"accessToken\",\n              \"description\": \"The access token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refreshToken\",\n              \"description\": \"The refresh token for getting a new access token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenType\",\n              \"description\": \"The type of token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"OAuthTokenType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"expiresAt\",\n              \"description\": \"Expiration in ISO time format.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"AccountCreationStatus\",\n          \"description\": \"The status of the business account creation request.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"COMPLETED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DECLINED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IN_SETUP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IN_VETTING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUBMITTED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AccountCreationStatusSearchInput\",\n          \"description\": \"Input fields for searching for BusinessAccountCreationRequests by their `AccountCreationStatus`.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The creation status is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AccountCreationStatus\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"The creation status is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"AccountCreationStatus\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Address\",\n          \"description\": \"Representation of an address.\",\n          \"fields\": [\n            {\n              \"name\": \"company\",\n              \"description\": \"Company name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `addressLine1` instead.\"\n            },\n            {\n              \"name\": \"addressLine1\",\n              \"description\": \"The first line of the street address, such as street number, street name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"Extended address information, such as an apartment or suite number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `addressLine2` instead.\"\n            },\n            {\n              \"name\": \"addressLine2\",\n              \"description\": \"Extended address information, such as an apartment number or suite number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"First name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `fullName` instead.\"\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"Last name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `fullName` instead.\"\n            },\n            {\n              \"name\": \"fullName\",\n              \"description\": \"Full name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"Locality/city.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `adminArea2` instead.\"\n            },\n            {\n              \"name\": \"adminArea2\",\n              \"description\": \"A city, town, or village.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or province.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `adminArea1` instead.\"\n            },\n            {\n              \"name\": \"adminArea1\",\n              \"description\": \"Highest level subdivision, such as state, province, or ISO-3166-2 subdivison.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code for the address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"Phone number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AddressInput\",\n          \"description\": \"Input fields for an Address.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"company\",\n              \"description\": \"Company name. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"addressLine1\",\n              \"description\": \"The first line of the street address, such as street number, street name. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"Extended address information, such as an apartment or suite number. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"addressLine2\",\n              \"description\": \"Extended address information, such as apartment number or suite number. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"First name. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"Last name. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"Locality/city. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"adminArea2\",\n              \"description\": \"A city, town or village. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or province. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"adminArea1\",\n              \"description\": \"Highest level subdivision, such as state, province or ISO-3166-2 subdivision. 255 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code. Nine alphanumeric characters maximum, may also contain spaces and hyphens.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code for the address.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCodeAlpha3\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `countryCode` instead.\\n\\nCountry code for the address in ISO 3166-1 alpha-3 format.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCodeAlpha2\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `countryCode` instead.\\n\\nCountry code for the address in ISO 3166-1 alpha-2 format.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCodeNumeric\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `countryCode` instead.\\n\\nCountry code for the address in ISO 3166-1 numeric format.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryName\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `countryCode` instead.\\n\\nCountry name for the address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Amount\",\n          \"description\": \"A monetary amount, either a whole number or a number with exactly two or three decimal places.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ApplePayConfiguration\",\n          \"description\": \"Configuration for Apple Pay on iOS.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The environment being used for Apple Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ApplePayStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The country code of the acquiring bank where the transaction is likely to be processed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCodeAlpha2\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The merchant's Apple Pay currency code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantIdentifier\",\n              \"description\": \"The merchant identifier that must be supplied when making an Apple Pay request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for Apple Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ApplePayOriginDetails\",\n          \"description\": \"Additional information about the payment method specific to Apple Pay.\",\n          \"fields\": [\n            {\n              \"name\": \"paymentInstrumentName\",\n              \"description\": \"A human-readable description of the Apple Pay payment method. This usually consists of the Apple Pay card type and its last four digits. If there is no underlying credit card, this will describe the customer's payment method and the parent CreditCardDetail object's last4 field will be null.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN may differ from the BIN of the customer's actual card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ApplePayStatus\",\n          \"description\": \"The environment being used for Apple Pay.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"MOCK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OFF\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRODUCTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"mock\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"off\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"production\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ApplePayWebConfiguration\",\n          \"description\": \"Configuration for Apple Pay on web.\",\n          \"fields\": [\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The merchant's Apple Pay country code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCodeAlpha2\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The merchant's Apple Pay currency code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantIdentifier\",\n              \"description\": \"The merchant identifier that must be supplied when making an Apple Pay request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for Apple Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ApplicationBankAccountPurpose\",\n          \"description\": \"The purpose of the merchant application bank account.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CHECKING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SAVINGS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ApplicationStatus\",\n          \"description\": \"The status of a merchant account application.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"APPROVED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROCESSING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REJECTED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"AuthenticationInsight\",\n          \"description\": \"Information about the [customer authentication regulation environment](https://developers.braintreepayments.com/guides/3d-secure/migration/javascript/v3#authentication-insight) that applies to the payment method when processed with the provided merchant account.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The merchant account used to determine authentication insight.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customerAuthenticationRegulationEnvironment\",\n              \"description\": \"The customer authentication regulation environment that applies when transacting with this payment method and merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CustomerAuthenticationRegulationEnvironment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customerAuthenticationIndicator\",\n              \"description\": \"A value indicating when to perform further customer authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CustomerAuthenticationIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthenticationInsightInput\",\n          \"description\": \"Input fields when requesting authentication insight for a payment method.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account that will be used when charging this payment method.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The intended transaction amount to be authorized on this payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"recurringCustomerConsent\",\n              \"description\": \"A flag indicating whether the customer has consented to further recurring transactions.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"recurringMaxAmount\",\n              \"description\": \"The maximum amount permitted for recurring transactions set by the customer.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"AuthorizationAdjustment\",\n          \"description\": \"Records of authorization adjustments performed when a transaction is captured for less or more than its original authorization amount.\",\n          \"fields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"Difference between the authorized amount and the amount captured. Negative values indicate the authorized amount was adjusted down.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"successful\",\n              \"description\": \"Indicates if the adjustment was successful or not.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when this adjustment was performed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Processor response from this adjustment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionAuthorizationAdjustmentProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"AuthorizationExpiredEvent\",\n          \"description\": \"Accompanying information for an authorization expired transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the authorization for this transaction was marked expired.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthorizeCreditCardInput\",\n          \"description\": \"Top-level input fields for creating a transaction by authorizing a credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a credit card payment method to be authorized.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields related to the credit card being authorized.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardTransactionOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the authorization, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthorizePayPalAccountInput\",\n          \"description\": \"Top-level input fields for creating a transaction by authorizing a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a PayPal payment method to be authorized.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields related to the PayPal account being authorized.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AuthorizePayPalAccountOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the authorization, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthorizePayPalAccountOptionsInput\",\n          \"description\": \"Input fields for authorizing a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"customField\",\n              \"description\": \"Variable passed directly to PayPal for your own tracking purposes. Customers do not see this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Description of the transaction that is displayed to customers in PayPal email receipts.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"payee\",\n              \"description\": \"Deprecated: This field is no longer supported.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PayPalPayeeOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthorizePaymentMethodInput\",\n          \"description\": \"Top-level input fields for creating a transaction by authorizing a payment method.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a payment method to be authorized.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the authorization, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthorizeVenmoAccountInput\",\n          \"description\": \"Top-level input fields for creating a transaction by authorizing a Venmo account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a Venmo payment method to be authorized.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields related to the Venmo account being authorized.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AuthorizeVenmoAccountOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the authorization, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"AuthorizeVenmoAccountOptionsInput\",\n          \"description\": \"Input fields for authorizing a Venmo account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"profileId\",\n              \"description\": \"Specifies which Venmo business profile to use for the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"AuthorizedEvent\",\n          \"description\": \"Accompanying information for an authorized transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction was authorized.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount the transaction was authorized for. This will match the amount on the transaction itself. In most cases, you can't request to settle more than this amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response to the authorization request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionAuthorizationProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"networkResponse\",\n              \"description\": \"Fields describing the network response to the authorization request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentNetworkResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"riskDecision\",\n              \"description\": \"Risk decision for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"RiskDecision\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authorizationExpiresAt\",\n              \"description\": \"The date/time the transaction will expire if it has the authorized status. For more details on authorization expiration timeframes, see the [Statuses reference](https://developers.braintreepayments.com/reference/general/statuses#authorization-expired).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"AvsCvvResponseCode\",\n          \"description\": \"Response codes from the processing bank's Address Verification System (AVS) and CVV verification.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BYPASS\",\n              \"description\": \"AVS or CVV checks were skipped via the API.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DOES_NOT_MATCH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ISSUER_DOES_NOT_PARTICIPATE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MATCHES\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NOT_APPLICABLE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NOT_PROVIDED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NOT_VERIFIED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SYSTEM_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"BinRecord\",\n          \"description\": \"Information about the credit card based on its BIN.\",\n          \"fields\": [\n            {\n              \"name\": \"prepaid\",\n              \"description\": \"Whether or not the card is prepaid, such as a gift card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"BinRecordValue\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"healthcare\",\n              \"description\": \"Whether the card is designated only to be used for healthcare expenses.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"BinRecordValue\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"debit\",\n              \"description\": \"Whether or not the card is a debit card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"BinRecordValue\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"durbinRegulated\",\n              \"description\": \"Whether the card is regulated by the Durbin Amendment due to the bank's assets, and therefore has a maximum interchange rate.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"BinRecordValue\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"commercial\",\n              \"description\": \"Whether or not the card is a commercial card and capable of processing Level 2 transactions.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"BinRecordValue\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payroll\",\n              \"description\": \"Whether or not the card is designated for employee wages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"BinRecordValue\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"issuingBank\",\n              \"description\": \"The name of the bank that issued the card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"countryOfIssuance\",\n              \"description\": \"The country code of the country that issued the card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"productId\",\n              \"description\": \"A code representing any special program from the card issuer the card is part of.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"BinRecordValue\",\n          \"description\": \"A boolean-like value that includes `UNKNOWN` in the case where the information isn't available.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"NO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNKNOWN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"YES\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"No\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"Unknown\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"Yes\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Boolean\",\n          \"description\": \"Built-in Boolean\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"BraintreeApiConfiguration\",\n          \"description\": \"Configuration for payment methods in legacy clients.\",\n          \"fields\": [\n            {\n              \"name\": \"url\",\n              \"description\": \"The URL for tokenizing payment methods.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"accessToken\",\n              \"description\": \"The authentication for tokenizing payment methods.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"BusinessAccountCreationRequest\",\n          \"description\": \"Record of onboarding request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier generated by PayPal for the onboarding request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccount\",\n              \"description\": \"Information about the merchant account that is being created as a result of the request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MerchantAccount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"creationStatus\",\n              \"description\": \"The account creation status for this account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AccountCreationStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"BusinessAccountCreationRequestConnection\",\n          \"description\": \"A paginated list of BusinessAccountCreationRequests.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of BusinessAccountCreationRequests.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"BusinessAccountCreationRequestConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of BusinessAccountCreationRequests contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"BusinessAccountCreationRequestConnectionEdge\",\n          \"description\": \"A BusinessAccountCreationRequest within a BusinessAccountCreationRequestConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This BusinessAccountCreationRequest's location within the BusinessAccountCreationRequestConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The business account creation request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"BusinessAccountCreationRequest\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"BusinessAccountCreationRequestSearchInput\",\n          \"description\": \"Input fields for searching for BusinessAccountCreationRequests.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find BusinessAccountCreationRequests with an ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"externalId\",\n              \"description\": \"Find BusinessAccountCreationRequests by their external ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Find BusinessAccountCreationRequests by their creation status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AccountCreationStatusSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"BusinessType\",\n          \"description\": \"The type of the business.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"GOVERNMENT_AGENCY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LIMITED_LIABILITY_CORPORATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NONPROFIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PARTNERSHIP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PARTNERSHIP_LLP\",\n              \"description\": null,\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"No longer applicable, use PARTNERSHIP instead.\"\n            },\n            {\n              \"name\": \"PRIVATE_CORPORATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PUBLIC_CORPORATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SOLE_PROPRIETORSHIP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TAX_EXEMPT\",\n              \"description\": null,\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"No longer applicable, use NONPROFIT instead.\"\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CVV\",\n          \"description\": \"A three- or four-digit string CVV (card verification value), otherwise known as CSC or CVC.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CaptureTransactionInput\",\n          \"description\": \"Top-level input fields for capturing an authorized transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"ID of the transaction to be captured.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `transaction.amount` instead.\\n\\nThe amount to capture on the transaction. Must be greater than 0. You can't capture more than the authorized amount unless your industry and processor support settlement adjustment (capturing a certain percentage over the authorized amount); [contact us for assistance](https://help.braintreepayments.com?issue=TransactionProcessingQuestion). If you capture an amount that is less than what was authorized, the transaction object will return the amount captured.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the capture, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CaptureTransactionOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CaptureTransactionOptionsInput\",\n          \"description\": \"Input fields for a capture, with details that will define the resulting transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount to capture on the transaction. Must be greater than 0. You can't capture more than the authorized amount unless your industry and processor support settlement adjustment (capturing a certain percentage over the authorized amount); [contact us for assistance](https://help.braintreepayments.com?issue=TransactionProcessingQuestion). If you capture an amount that is less than what was authorized, the transaction object will return the amount captured.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase. If specified, this will update the existing descriptor on the transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionDescriptorInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"Discount amount that was included in the total transaction amount. Does not add to the total amount the payment method will be charged. This value can't be negative. Please note that this field is not used on PayPal transactions.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"Line items for this transaction. Up to 249 line items may be specified.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"TransactionLineItemInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Additional information about the transaction. On PayPal transactions, this field maps to the PayPal invoice number. PayPal invoice numbers must be unique in your PayPal business account. Maximum 255 characters or 127 for PayPal transactions. If specified, this will update the existing order ID on the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"purchaseOrderNumber\",\n              \"description\": \"A purchase order identification value you associate with this transaction.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shipping\",\n              \"description\": \"Shipping information.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionShippingInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tax\",\n              \"description\": \"Tax information about the transaction.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionTaxInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"CardAccountType\",\n          \"description\": \"The type of account to be used when transacting with a combo card.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CREDIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEBIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CardPresentOriginDetails\",\n          \"description\": \"Additional information about a card present payment method supplied by an in-store payment reader.\",\n          \"fields\": [\n            {\n              \"name\": \"authorizationMode\",\n              \"description\": \"The authorization mode used to perform the transaction on the payment reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"InStoreReaderAuthorizationMode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pinVerified\",\n              \"description\": \"An indicator for whether the transaction was verified via pin.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inputMode\",\n              \"description\": \"The input mode used on the payment reader to facilitate an in-store transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentReaderInputMode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalId\",\n              \"description\": \"The ID of the terminal that was processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreReaderOriginDetails\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"Challenge\",\n          \"description\": \"A list of challenges that are required by the current merchant to process a given credit card.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CVV\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"POSTAL_CODE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cvv\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"postal_code\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargeCreditCardInput\",\n          \"description\": \"Top-level input fields for creating a transaction by charging a credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a credit card payment method to be charged.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields for creating a credit card transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardTransactionOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the charge, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargePayPalAccountInput\",\n          \"description\": \"Top-level input fields for creating a transaction by charging a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"The ID of an existing PayPal account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields related to the PayPal account being charged.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ChargePayPalAccountOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the charge, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargePayPalAccountOptionsInput\",\n          \"description\": \"Input fields for creating a transaction with a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"customField\",\n              \"description\": \"Variable passed directly to PayPal for your own tracking purposes. Customers do not see this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Description of the transaction that is displayed to customers in PayPal email receipts.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"payee\",\n              \"description\": \"Deprecated: This field is no longer supported.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PayPalPayeeOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"selectedFinancingOption\",\n              \"description\": \"Buyer selected PayPal financing option.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SelectedPayPalFinancingOptionInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargePaymentMethodInput\",\n          \"description\": \"Top-level input fields for creating a transaction by charging a payment method.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a payment method to be charged.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the charge, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargeUsBankAccountInput\",\n          \"description\": \"Top-level input fields for creating a transaction by charging a US bank account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"The ID of an existing US bank account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields related to the US bank account being charged.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ChargeUsBankAccountOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the charge, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargeUsBankAccountOptionsInput\",\n          \"description\": \"Input fields for creating a transaction with a US bank account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"standardEntryClassCode\",\n              \"description\": \"A NACHA standard entry class (SEC) code, which designates how the transaction was authorized. Most internet-based sales should use the `WEB` code.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ACHStandardEntryClassCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargeVenmoAccountInput\",\n          \"description\": \"Top-level input fields for creating a transaction by charging a Venmo account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"The ID of an existing Venmo account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields for creating a Pay with Venmo transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ChargeVenmoAccountOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the charge, with details that will define the resulting transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"TransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ChargeVenmoAccountOptionsInput\",\n          \"description\": \"Input fields for creating a Pay with Venmo transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"profileId\",\n              \"description\": \"Specifies which Venmo business profile to use for the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ChargebackProtectionLevel\",\n          \"description\": \"The chargeback protection level indicates the transaction or dispute's protection status.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"EFFORTLESS\",\n              \"description\": \"The transaction or dispute is protected by the effortless chargeback protection product.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NOT_PROTECTED\",\n              \"description\": \"The merchant has not enrolled any chargeback protection products, or the merchant is registered, but the transaction or dispute is not protected.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"STANDARD\",\n              \"description\": \"The transaction or dispute is protected by the standard chargeback protection product.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ChildCapture\",\n          \"description\": \"A partial capture's relationship to its original authorization transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"parentAuthorization\",\n              \"description\": \"The original authorization whose funds have been partially captured.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ClientConfiguration\",\n          \"description\": \"Top-level fields returned from the client configuration query.\",\n          \"fields\": [\n            {\n              \"name\": \"analyticsUrl\",\n              \"description\": \"URL to send analytics.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting SDKs that send analytics.\"\n            },\n            {\n              \"name\": \"applePay\",\n              \"description\": \"Configuration for Apple Pay on iOS.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ApplePayConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applePayWeb\",\n              \"description\": \"Configuration for Apple Pay on the web.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ApplePayWebConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"assetsUrl\",\n              \"description\": \"A URL pointing to the base path of Braintree's web pages used for various browser switches and popups.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"clientApiUrl\",\n              \"description\": \"A URL pointing to the base path of Braintree's client API.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"supportedFeatures\",\n              \"description\": \"A list of client features the merchant supports.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"ClientFeature\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"braintreeApi\",\n              \"description\": \"Configuration for payment methods in legacy clients.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"BraintreeApiConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"creditCard\",\n              \"description\": \"Configuration for credit card tokenization.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreditCardConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"environment\",\n              \"description\": \"The enum of the current environment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ClientConfigurationEnvironment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"fraudProvider\",\n              \"description\": \"Configuration for fraud protection provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"FraudProviderConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"googlePay\",\n              \"description\": \"Configuration for Google Pay on Android and the web.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"GooglePayConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ideal\",\n              \"description\": \"Deprecated, this field will always be null.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"IDealConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"kount\",\n              \"description\": \"Deprecated, formerly configuration for Kount fraud tools, now this configuration lives under fraudProvider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"KountConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"masterpass\",\n              \"description\": \"Configuration for Masterpass.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MasterpassConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantId\",\n              \"description\": \"The merchant ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paypal\",\n              \"description\": \"Configuration for PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"samsungPay\",\n              \"description\": \"Configuration for Samsung Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"SamsungPayConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"unionPay\",\n              \"description\": \"Configuration for UnionPay cards.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UnionPayConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"usBankAccount\",\n              \"description\": \"Configuration for US bank account processing.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UsBankAccountConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"venmo\",\n              \"description\": \"Configuration for Pay with Venmo.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VenmoConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"visaCheckout\",\n              \"description\": \"Configuration for Visa Checkout.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VisaCheckoutConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"challenges\",\n              \"description\": \"A list of challenges that are required by the current merchant to process a given credit card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"Challenge\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ClientConfigurationEnvironment\",\n          \"description\": \"The client configuration environment being used.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"DEVELOPMENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRODUCTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"QA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SANDBOX\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TEST\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"development\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"production\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"qa\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sandbox\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"test\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ClientFeature\",\n          \"description\": \"A value used by Braintree client SDKs to determine what operations are supported through this GraphQL API.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"TOKENIZE_CREDIT_CARDS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenize_credit_cards\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ClientTokenInput\",\n          \"description\": \"Input fields for creating a client token.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The merchant account ID used to create the client token. Defaults to your default merchant account ID.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"The ID of an existing customer. Including this will allow your customer to vault and manage their payment methods.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ConfirmMicroTransferAmountsInput\",\n          \"description\": \"Top-level input field for confirming micro-transfer values.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verificationId\",\n              \"description\": \"The ID of the verification from vaulting the bank account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amountsInCents\",\n              \"description\": \"The amounts, in cents, of two deposits made into the customer's bank account after initiating a MICRO_TRANSFERS verification. These values should be collected from your customer.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"SCALAR\",\n                      \"name\": \"Int\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ConfirmMicroTransferAmountsPayload\",\n          \"description\": \"Top-level output field from confirming micro-transfer amounts on bank account.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"The verification that was run on the payment method prior to vaulting.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Verification\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the micro-transfer amounts confirmation.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ConfirmMicroTransferAmountsStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ConfirmMicroTransferAmountsStatus\",\n          \"description\": \"The status of a micro-transfer amount confirmation.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AMOUNTS_DO_NOT_MATCH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CONFIRMED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TOO_MANY_ATTEMPTS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ConfirmationPromptAlignment\",\n          \"description\": \"The alignment of the confirmation prompt text when displayed on the in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CENTER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LEFT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CountryCode\",\n          \"description\": \"An [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code. Braintree only accepts [specific alpha-2 values](https://developers.braintreepayments.com/reference/general/countries#list-of-countries). Clients using a Braintree version prior to 2021-02-01 should use an [ISO 3166-1 alpha-3](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3) country code.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CountryCodeAlpha2\",\n          \"description\": \"An [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) country code. Braintree only accepts [specific alpha-2 values](https://developers.braintreepayments.com/reference/general/countries#list-of-countries).\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateClientTokenInput\",\n          \"description\": \"Top-level input field for generating a client token.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"clientToken\",\n              \"description\": \"Input fields for creating a client token.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ClientTokenInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateClientTokenPayload\",\n          \"description\": \"Top-level fields returned when creating a client token.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"clientToken\",\n              \"description\": \"A Base64 encoded string used to initialize client SDKs.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateCustomerInput\",\n          \"description\": \"Top-level field for creating a customer.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Input fields for creating a customer.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CustomerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateCustomerPayload\",\n          \"description\": \"Top-level fields returned when creating a customer.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Information about the customer that was created. Can be used when vaulting payment methods or creating transactions to associate those objects.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Customer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateDisputeFileEvidenceInput\",\n          \"description\": \"Top-level input fields for adding file evidence to a dispute.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disputeId\",\n              \"description\": \"The ID of the dispute to be accepted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"category\",\n              \"description\": \"The category for the evidence file.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeFileEvidenceCategory\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateDisputeFileEvidencePayload\",\n          \"description\": \"Top-level field returned when creating file evidence for a dispute.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"evidence\",\n              \"description\": \"The evidence object created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DisputeFileEvidence\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"dispute\",\n              \"description\": \"Information about the dispute the evidence is attached to.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Dispute\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateDisputeTextEvidenceInput\",\n          \"description\": \"Top-level input fields for creating text evidence for a dispute.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disputeId\",\n              \"description\": \"The ID of the dispute to create the evidence for.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"category\",\n              \"description\": \"The category of the text evidence.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeTextEvidenceCategory\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"content\",\n              \"description\": \"The content of the text evidence.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateDisputeTextEvidencePayload\",\n          \"description\": \"Top-level field returned when creating text evidence for a dispute.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"evidence\",\n              \"description\": \"The evidence object created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DisputeTextEvidence\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateInStoreLocationInput\",\n          \"description\": \"Input fields for creating an in store location.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"location\",\n              \"description\": \"Input fields to create an in-store Location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"InStoreLocationInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateInStoreLocationPayload\",\n          \"description\": \"Top-level fields returned when creating an in-store location.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"location\",\n              \"description\": \"The in-store location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreLocation\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateNonInstantLocalPaymentContextInput\",\n          \"description\": \"Top-level input fields for creating a non-instant local payment context.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentContext\",\n              \"description\": \"Input fields for creating a non-instant local payment context.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"NonInstantLocalPaymentContextInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateNonInstantLocalPaymentContextPayload\",\n          \"description\": \"The result of a request to make a local payment context.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentContext\",\n              \"description\": \"Details about the local payment context.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"LocalPaymentContext\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreatePayPalBillingAgreementInput\",\n          \"description\": \"Top-level input field for creating a PayPal Billing Agreement Token.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Braintree merchant account ID associated with the PayPal account to be used for the Billing Agreement creation.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"returnUrl\",\n              \"description\": \"URL for redirect back to merchant app on the client indicating successful approval.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"URL\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cancelUrl\",\n              \"description\": \"URL for redirect back to merchant app on the client indicating unsuccessful approval.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"URL\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Description of the PayPal Billing Agreement, displayed to the PayPal user on paypal.com and other PayPal user experiences.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Email of the payer (if known). This will prepopulate the input field in the PayPal approval page.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"EmailAddress\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"offerPayPalCredit\",\n              \"description\": \"Indicates whether PayPal Credit should be offered in the PayPal approval flow.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paypalRiskCorrelationId\",\n              \"description\": \"PayPal Risk correlation ID (also known as the Client Metadata ID).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\" : \"paypalExperienceProfile\",\n              \"description\" : \"Defines the experience profile used to render the billing agreement approval flow.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"PayPalBillingAgreementExperienceProfileInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"shippingAddress\",\n              \"description\" : \"Merchant-provided shipping address. Fields addressLine1, adminArea2, and countryCode are required for Billing Agreements.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"AddressInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"paypalProductAttributes\",\n              \"description\" : \"Product attributes input for PayPal billing agreement.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"PayPalProductAttributesInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreatePayPalBillingAgreementPayload\",\n          \"description\": \"Top-level fields returned from setting up a PayPal Billing Agreement Token.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAgreementToken\",\n              \"description\": \"The Billing Agreement token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"approvalUrl\",\n              \"description\": \"The URL for getting user approval of the PayPal Billing Agreement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"URL\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreatePayPalOneTimePaymentInput\",\n          \"description\": \"Top-level input field for creating a PayPal One-Time Payment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Braintree merchant account ID associated with the PayPal account to be used for the One-Time payment creation.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Total amount for payment to be charged to consumer.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"MonetaryAmountInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cancelUrl\",\n              \"description\": \"URL for redirect back to merchant app on the client indicating unsuccessful approval.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"URL\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Email of the payer. This will prepopulate the input field in the PayPal approval login page.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"EmailAddress\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"intent\",\n              \"description\": \"The payment intent.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"PayPalIntent\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"The line items for this transaction. Maximum 249 line items.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"PayPalLineItemInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"offerPayLater\",\n              \"description\": \"Indicates whether PayPal Pay Later should be offered in the PayPal approval flow.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paypalRiskCorrelationId\",\n              \"description\": \"PayPal Risk correlation ID (also known as the Client Metadata ID).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paypalExperienceProfile\",\n              \"description\": \"Defines the experience profile used to render the approval flow.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PayPalExperienceProfileInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"requestBillingAgreement\",\n              \"description\": \"Indicates whether this payment uses the [Billing Agreement with Purchase flow](https://developers.braintreepayments.com/guides/paypal/checkout-with-paypal/javascript/v3#checkout-using-paypal-billing-agreement-with-purchase-flow). This will request Billing Agreement approval from the customer, and a multi-use PayPal payment method will be created alongside the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAgreementDescription\",\n              \"description\": \"A description of the Billing Agreement being requested. This is displayed to the customer on paypal.com when `requestBillingAgreement` is true. Maximum 127 characters.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"returnUrl\",\n              \"description\": \"URL for redirect back to merchant app on the client indicating successful approval.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"URL\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"Merchant-provided shipping address. If passing a shipping address, fields addressLine1, adminArea2, and countryCode are required.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingOptions\",\n              \"description\": \"List of shipping options offered by the payee or merchant to the payer to ship or pick up their items. **Note:** `shippingOptions` may not be passed with intent `ORDER` payments.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"PayPalShippingOptionInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreatePayPalOneTimePaymentPayload\",\n          \"description\": \"Top-level fields returned from setting up a PayPal One-Time Payment.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"approvalUrl\",\n              \"description\": \"The URL for getting user approval of the PayPal payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"URL\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"The PayPal payment ID. This ID is prefixed with \\\"PAYID-\\\".\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreateUniversalAccessTokenInput\",\n          \"description\": \"Top-level input field for generating a PayPal access token.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"The ID of an existing customer. Including this will allow the access token to interact with this customer's data.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"Authentication context class reference for the universal access token.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"ACRType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreateUniversalAccessTokenPayload\",\n          \"description\": \"Top-level fields returned when creating a universal access token.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"accessToken\",\n              \"description\": \"The created universal access token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"AccessToken\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"CreditCardBrandCode\",\n          \"description\": \"A code identifying the card brand.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AMERICAN_EXPRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CITI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DINERS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DISCOVER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ELO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"HIPER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"HIPERCARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INTERNATIONAL_MAESTRO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"JCB\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MASTERCARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SOLO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SWITCH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UK_MAESTRO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNION_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNKNOWN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VISA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"american_express\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"citi\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"diners\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discover\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"elo\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"hiper\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"hipercard\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"international_maestro\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"jcb\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"mastercard\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"solo\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"switch\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"uk_maestro\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"union_pay\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"unknown\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"visa\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreditCardConfiguration\",\n          \"description\": \"Configuration for credit card tokenization.\",\n          \"fields\": [\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for credit card processing.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"challenges\",\n              \"description\": \"A list of challenges that are required by the merchant to process a given credit card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"Challenge\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"threeDSecureEnabled\",\n              \"description\": \"Whether or not the merchant supports 3D Secure.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `threeDSecure` instead.\"\n            },\n            {\n              \"name\": \"threeDSecure\",\n              \"description\": \"Configuration for 3D Secure.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ThreeDSecureConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"fraudDataCollectionEnabled\",\n              \"description\": \"Whether or not fraud data collection is enabled for the merchant.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreditCardDetails\",\n          \"description\": \"Details about a credit card.\",\n          \"fields\": [\n            {\n              \"name\": \"brandCode\",\n              \"description\": \"A static code identifying the card brand.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CreditCardBrandCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"last4\",\n              \"description\": \"The last four digits of the card number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card number, known as the Bank Identification Number. If this card originates from a third party such as a wallet provider, this BIN may not be present and the PaymentMethodOriginDetails will contain a BIN instead.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"binData\",\n              \"description\": \"Information about the card based on its BIN.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"BinRecord\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"expirationMonth\",\n              \"description\": \"The month of the expiration date, formatted MM.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"expirationYear\",\n              \"description\": \"The year of the expiration date, formatted YYYY.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cardholderName\",\n              \"description\": \"The cardholder's name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"uniqueNumberIdentifier\",\n              \"description\": \"An identifier that uniquely represents any credit card number, for cards stored in a merchant's vault. If the same credit card is added to a merchant's vault multiple times, each will have the same identifier. This identifier will only be returned if the field \\\"origin\\\" is null.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"origin\",\n              \"description\": \"Additional information if the credit card was provided from a third-party origin, such as Apple Pay, Google Pay, or another digital wallet.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethodOrigin\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address associated with the credit card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"threeDSecure\",\n              \"description\": \"3D Secure information for the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ThreeDSecureDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"imageUrl\",\n              \"description\": \"A URL to an image logo representing the card brand.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"brand\",\n              \"description\": \"The display name of the card brand, e.g. \\\"Visa\\\" or \\\"American Express\\\".\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `brandCode` instead.\"\n            },\n            {\n              \"name\": \"cardOnFileNetworkTokenized\",\n              \"description\": \"Indicates whether the card on file is network tokenized.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreditCardFraudToolsOptionsInput\",\n          \"description\": \"Input fields that allow you to skip certain fraud checks. These will override Control Panel settings.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"skipCvv\",\n              \"description\": \"Skip CVV checks. Will result in a `cvvResponse` of `BYPASS` in the response from the processor.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"skipAvs\",\n              \"description\": \"Skip AVS checks. Will result in an `avsPostalCodeResponse` of `BYPASS` in the response from the processor.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"skipAdvancedFraudChecking\",\n              \"description\": \"Skip [advanced fraud checks](https://developers.braintreepayments.com/guides/advanced-fraud-management-tools/overview).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreditCardInput\",\n          \"description\": \"Input fields for a credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"number\",\n              \"description\": \"The 12-to-19-digit value that uniquely identifies this credit card, also known as the primary account number or PAN.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationYear\",\n              \"description\": \"The two- or four-digit year associated with a credit card, formatted `YYYY` or `YY`.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationMonth\",\n              \"description\": \"The expiration month of a credit card, formatted `MM`.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cvv\",\n              \"description\": \"A three- or four-digit card verification value assigned to credit cards. The CVV will never be stored, but it can be provided with one-time requests to verify the card.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cardholderName\",\n              \"description\": \"When supplied, the cardholder name that will be tokenized with the contents of the fields.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address for the credit card.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CreditCardLast4\",\n          \"description\": \"A four-digit string.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CreditCardNumber\",\n          \"description\": \"A number that passes Luhn validation.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreditCardTransactionDetails\",\n          \"description\": \"Credit card specific details on a transaction or verification.\",\n          \"fields\": [\n            {\n              \"name\": \"creditCard\",\n              \"description\": \"The details of the credit card itself.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreditCardDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"networkTransactionId\",\n              \"description\": \"The network transaction identifier provided by the payment network. If this transaction was created in order to verify a payment method before storing it in an external vault, then this value can be pased when creating subsequent transactions with the same payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"For combo cards, what account type was used for this specific transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CardAccountType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"acquirerReferenceNumber\",\n              \"description\": \"Reference value assigned to a card transaction once it has been processed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processedWithCardOnFileNetworkToken\",\n              \"description\": \"Indicates whether the transaction was processed with a card on file network token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"accountBalance\",\n              \"description\": \"The remaining balance in the account after this transaction. This field is only returned for payment methods such as prepaid cards.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreditCardTransactionOptionsInput\",\n          \"description\": \"Input fields for creating a transaction by authorizing or charging a credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"externalVault\",\n              \"description\": \"Details about this transaction if it's being created from a credit card that is or will be stored in an non-Braintree vault.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionExternalVaultOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"A billing address to use for the transaction. If a billing address was provided when tokenizing or is present on the vaulted credit card, it will be *merged* with this input value, with priority given to this input value.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"The type of account to be used when transacting with a combo card.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CardAccountType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tokenizedCvv\",\n              \"description\": \"The CVV for the credit card to be used when creating this transction, securely tokenized with the `tokenizeCvv` mutation.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"fraudTools\",\n              \"description\": \"Control which fraud tools will be applied to this transaction. Fraud tools cannot be retroactively applied to a transaction if skipped.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardFraudToolsOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"threeDSecureAuthentication\",\n              \"description\": \"3D Secure authentication information performed for this transaction. Only use these fields if you are charging or authorizing a single-use payment method ID that was *not* generated by a 3DS flow on on the client.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecureAuthenticationInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"scaExemption\",\n              \"description\": \"The type of Strong Customer Authentication Exemption requested.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ScaExemptionType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"installmentCount\",\n              \"description\": \"Number of monthly installments (can be anywhere between 2 and 12).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CreditCardVerificationDetails\",\n          \"description\": \"Information specific to verifications of credit card payment methods.\",\n          \"fields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount used when performing the verification. May be 0.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CreditCardVerificationOptionsInput\",\n          \"description\": \"Input fields that specify options for verifying the credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Deprecated: Please use `merchantAccountId` in the base input instead.\\n\\nID of the merchant account to use when verifying the credit card.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"The type of account to be used when verifying a combo card.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CardAccountType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"riskData\",\n              \"description\": \"Customer device information, which is sent directly to supported processors for fraud analysis.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"RiskDataInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"fraudTools\",\n              \"description\": \"Control which fraud tools will be applied to this verification. Fraud tools cannot be retroactively applied to a verification if skipped.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardFraudToolsOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tokenizedCvv\",\n              \"description\": \"The CVV for the credit card to be used when verifying the credit card, securely tokenized with the `tokenizeCvv` mutation.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount to use to verify the credit card.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"skip\",\n              \"description\": \"Whether to opt out of verifying the credit card. Defaults to `false`. Clients should only pass `true` in the uncommon scenario that the credit card has been verified externally to Braintree.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CurrencyCodeAlpha\",\n          \"description\": \"An [ISO 4217 alpha](https://en.wikipedia.org/wiki/ISO_4217) currency code. Braintree only accepts [specific alpha values](https://developers.braintreepayments.com/reference/general/currencies).\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomActionsPaymentContext\",\n          \"description\": \"Top-level fields returned from a Custom Actions payment context.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"The identifier of the payment context.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the payment context was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Timestamp\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updatedAt\",\n              \"description\": \"Date and time when the payment context was updated.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Timestamp\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"A list of fields stored on a PaymentContext during execution of a Custom Actions handler (Five (5) entries maximum).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"CustomActionsPaymentContextField\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentContext\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomActionsPaymentContextField\",\n          \"description\": \"Fields returned by the createPaymentContext custom actions event handler.\",\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"An alphanumeric string used as a key to lookup a CustomField value (255 characters maximum).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"value\",\n              \"description\": \"An alphanumeric string used to store a CustomField value (7168 characters maximum).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomActionsPaymentContextFieldInput\",\n          \"description\": \"Fields that are provided when creating the payment context.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"An alphanumeric string used as a key to lookup a CustomField value (255 characters maximum).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"value\",\n              \"description\": \"An alphanumeric string used to store a CustomField value (7168 characters maximum).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomActionsPaymentMethodDetails\",\n          \"description\": \"Details about a custom actions payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"actionName\",\n              \"description\": \"The action to be invoked when using the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"fields\",\n              \"description\": \"Fields that your action requires.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"CustomActionsPaymentMethodField\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomActionsPaymentMethodField\",\n          \"description\": \"Fields that are provided during tokenization and are presented to the invoked action to be consumed.\",\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The name of this field, e.g. \\\"accountNumber\\\".\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"displayValue\",\n              \"description\": \"The value displayed in the Control Panel or API, e.g. \\\"*****6789\\\".\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomActionsPaymentMethodFieldInput\",\n          \"description\": \"Fields that are provided during tokenization and are presented to the invoked action to be consumed.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The name of this field. e.g. \\\"accountNumber\\\".\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"value\",\n              \"description\": \"The value of this field. e.g. \\\"123456789\\\".\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"displayValue\",\n              \"description\": \"The value displayed in the Control Panel or API. e.g. \\\"*****6789\\\".\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomActionsPaymentMethodInput\",\n          \"description\": \"Input fields for a Custom Actions payment method.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"actionName\",\n              \"description\": \"The action you wish to invoke when using the tokenized payment method.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"fields\",\n              \"description\": \"Fields that your action requires.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CustomActionsPaymentMethodFieldInput\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomField\",\n          \"description\": \"A merchant-defined custom field to store additional information.\",\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The name of the custom field.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"value\",\n              \"description\": \"The value of the custom field.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomFieldInput\",\n          \"description\": \"Custom field name/value pairs. Maximum 255 characters. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"Name of the custom field as defined in the Control Panel.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CustomFieldName\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"value\",\n              \"description\": \"Value for the named custom field. A null value will ignore (on create) or remove (on update) the custom field.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"CustomFieldName\",\n          \"description\": \"A string representing a custom field value. Contains letters, numbers, and underscores.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Customer\",\n          \"description\": \"Information about a customer and their associated payment methods and transactions.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"company\",\n              \"description\": \"Company or business name associated with this customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time at which the customer was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. Custom fields are [defined in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#store-and-pass-back-fields).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"CustomField\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"defaultPaymentMethod\",\n              \"description\": \"Customer's default payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Email address for this customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"Customer's first name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"Customer's last name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The phone number for this customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethods\",\n              \"description\": \"Payment methods belonging to this customer.\",\n              \"args\": [\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethodConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactions\",\n              \"description\": \"Transactions associated with this customer. This includes transactions created by charging a vaulted payment method that belongs or belonged to the customer, or by passing a customer ID when charging a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"CustomerAuthenticationIndicator\",\n          \"description\": \"A value indicating when to perform further customer authentication.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"OPTIONAL\",\n              \"description\": \"Indicates further authentication is optional.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REQUIRED\",\n              \"description\": \"Indicates further authentication should be performed.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNAVAILABLE\",\n              \"description\": \"Customer authentication indicator information is unavailable at this time.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"CustomerAuthenticationRegulationEnvironment\",\n          \"description\": \"The customer authentication regulation environment that applies to the transaction, such as [PSD2](https://www.braintreepayments.com/blog/understanding-and-preparing-for-psd2-strong-customer-authentication/).\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"PSDTWO\",\n              \"description\": \"EU Regulation [PSD2 Strong Customer Authentication](https://www.braintreepayments.com/blog/understanding-and-preparing-for-psd2-strong-customer-authentication/) applies to this transaction.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RBI\",\n              \"description\": \"Reserve Bank of India regulations apply to this transactions.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNAVAILABLE\",\n              \"description\": \"Customer authentication regulation environment information is unavailable for this transaction at this time.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNREGULATED\",\n              \"description\": \"No customer authentication regulations apply to this transaction.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomerConnection\",\n          \"description\": \"A paginated list of customers.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of customers.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"CustomerConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of customers contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"CustomerConnectionEdge\",\n          \"description\": \"A customer within a CustomerConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This customer's location within the CustomerConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Customer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomerInput\",\n          \"description\": \"Input fields for creating or updating a customer. On update, omitted fields will not be updated. Passing a null value will assign null to that field.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"company\",\n              \"description\": \"Company or business name associated with the customer.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CustomFieldInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Email address for the customer.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"Customer's first name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"Customer's last name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The customer's phone number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"taxIdentifiers\",\n              \"description\": \"A set of country code ID pairs, analogous to Social Security numbers in the United States.\\n\\nA customer may have multiple tax identifiers, but only one per tax jurisdiction. The values provided for an update will be stored and previous entries will be updated.\\n\\n**Note:** You will only need to use these fields for processing in certain countries.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CustomerTaxIdentifierInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomerSearchInput\",\n          \"description\": \"Input fields for searching for customers.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find customers with an id or ids.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"company\",\n              \"description\": \"Find customers with a given company or business name.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Find customers with a given created at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Find customers with a given email address.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"Find customers with a given first name.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"Find customers with a given last name.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"Find customers with a given phone number.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"CustomerTaxIdentifierInput\",\n          \"description\": \"The customer's tax identifer for a given tax jurisdiction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"identifier\",\n              \"description\": \"The identifier provided in the format required for the given tax jurisdiction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The country code of the tax jurisdiction for this tax identifier.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CountryCode\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Date\",\n          \"description\": \"A date in the format YYYY-MM-DD.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DeleteCustomerInput\",\n          \"description\": \"Top-level input fields for deleting a customer.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"The ID of the customer to be deleted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DeleteCustomerPayload\",\n          \"description\": \"Top-level output field from deleting a customer.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DeleteDisputeEvidenceInput\",\n          \"description\": \"Input fields for deleting dispute evidence.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"evidenceId\",\n              \"description\": \"The ID of the evidence to be deleted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disputeId\",\n              \"description\": \"The ID of the dispute that the evidence belongs to.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DeleteDisputeEvidencePayload\",\n          \"description\": \"Top-level field returned when deleting evidence from a dispute.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DeletePaymentMethodFromSingleUseTokenInput\",\n          \"description\": \"Top-level input fields for deleting a payment method referenced by a single-use token.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"singleUseTokenId\",\n              \"description\": \"A single-use token ID referencing a payment method.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DeletePaymentMethodFromSingleUseTokenPayload\",\n          \"description\": \"Top-level output field from deleting a payment method referenced by a single-use token.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DeletePaymentMethodFromVaultInput\",\n          \"description\": \"Top-level input fields for deleting a multi-use payment method from the vault.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"The ID of the multi-use payment method to be deleted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"initiatedBy\",\n              \"description\": \"Indicates whether this deletion was initiated by the merchant or the customer (via the merchant site/app).\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentMethodDeletionInitiator\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"deleteRelatedPaymentMethods\",\n              \"description\": \"Additionally request deletion of all related payment methods (ones that store the same underlying payment instrument as the one specified by `paymentMethodId`) across all customers for current merchant.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"fraudRelated\",\n              \"description\": \"Indicates if this deletion is related to suspected fraud, as determined by the merchant.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DeletePaymentMethodFromVaultPayload\",\n          \"description\": \"Top-level output field from deleting a multi-use payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DetachedRefundInput\",\n          \"description\": \"Specific input fields for describing a detached refund.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount to refund.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The refund's order ID.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account that will be used when performing the refund.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CustomFieldInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's statement (for instance, credit card or bank statement) for this refund. This should match the original transaction if possible.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionDescriptorInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisbursementBankAccount\",\n          \"description\": \"Details about the disbursement bank account.\",\n          \"fields\": [\n            {\n              \"name\": \"last4\",\n              \"description\": \"The last four digits of the bank account number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"routingNumber\",\n              \"description\": \"The routing number of the bank.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisbursementDetails\",\n          \"description\": \"Disbursement details contain information about how and when the transaction was disbursed, including timing and currency information. This field is only available if you have an eligible merchant account.\",\n          \"fields\": [\n            {\n              \"name\": \"date\",\n              \"description\": \"The date that the funds associated with this transaction were disbursed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Amount of money disbursed in the settlement currency, which may be different than the transaction's [presentment currency](https://articles.braintreepayments.com/get-started/currencies).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"exchangeRate\",\n              \"description\": \"The exchange rate from the presentment currency to the settlement currency. If the currencies are the same, this will be 1.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"fundsHeld\",\n              \"description\": \"Indicates whether funds have been withheld from a disbursement to the merchant's account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisplayItemType\",\n          \"description\": \"The display item type to be displayed on the in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CHARGE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DISCOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LINE_BREAK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TEXT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Dispute\",\n          \"description\": \"[A case raised by a customer to either request information about or to challenge a charge](https://articles.braintreepayments.com/risk-and-security/chargebacks-retrievals/overview). These are initiated via a customer's payment provider, such as their bank, and require a merchant to provide evidence or further information.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amountDisputed\",\n              \"description\": \"The amount of money from the original charge that the customer is disputing. Can be 0. This amount is debited from a merchant's account and held in a third-party account until the dispute is resolved, at which time it is sent to either the merchant or customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amountWon\",\n              \"description\": \"If an amount was disputed, the amount of money awarded back to the merchant if the dispute was reversed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"caseNumber\",\n              \"description\": \"The case number for the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time at which the dispute was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"receivedDate\",\n              \"description\": \"Date the dispute was received by the merchant.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"referenceNumber\",\n              \"description\": \"The transaction reference number for the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"responseDeadline\",\n              \"description\": \"The deadline for the merchant to submit a response to the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"replyByDate\",\n              \"description\": \"The reply by date for the merchant to submit a response to the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"The type of dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"evidence\",\n              \"description\": \"Evidence records submitted by the merchant for the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INTERFACE\",\n                    \"name\": \"DisputeEvidence\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"originalDispute\",\n              \"description\": \"If this dispute is a follow-up to a previous chargeback or retrieval, the original dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Dispute\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Additional information from the payment processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DisputeProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"statusHistory\",\n              \"description\": \"A log of history events containing status changes by date for this dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"DisputeStatusEvent\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"The disputed transaction which the customer is either requesting further information on or challenging.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"chargebackProtectionLevel\",\n              \"description\": \"The chargeback protection status of the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ChargebackProtectionLevel\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `protectionLevel` instead.\"\n            },\n            {\n              \"name\" : \"protectionLevel\",\n              \"description\" : \"The protection level of the dispute.\",\n              \"args\" : [],\n              \"type\" : {\n                \"kind\" : \"ENUM\",\n                \"name\" : \"DisputeProtectionLevel\",\n                \"ofType\" : null\n              },\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"preDisputeProgram\",\n              \"description\" : \"The pre-dispute program of the dispute.\",\n              \"args\" : [],\n              \"type\" : {\n                \"kind\" : \"ENUM\",\n                \"name\" : \"PreDisputeProgram\",\n                \"ofType\" : null\n              },\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisputeConnection\",\n          \"description\": \"A paginated list of disputes.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of disputes.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"DisputeConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of disputes contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisputeConnectionEdge\",\n          \"description\": \"A dispute within a DisputeConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This dispute's location within the DisputeConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Dispute\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INTERFACE\",\n          \"name\": \"DisputeEvidence\",\n          \"description\": \"Evidence provided by a merchant to respond to a dispute.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the evidence was created with Braintree.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sentToProcessorAt\",\n              \"description\": \"Date and time when the evidence was sent to the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"category\",\n              \"description\": \"The evidence category.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeEvidenceCategory\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"DisputeFileEvidence\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"DisputeTextEvidence\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeEvidenceCategory\",\n          \"description\": \"The evidence category that specifies which requirement it satisfies.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AVS_RESPONSE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CARRIER_NAME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CARRIER_NAME_OTHER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_ISSUED_AMOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_ISSUED_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEVICE_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEVICE_NAME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DOWNLOAD_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EVIDENCE_TYPE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GENERAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GEOGRAPHICAL_LOCATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LEGIT_PAYMENTS_FOR_SAME_MERCHANDISE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MERCHANT_WEBSITE_OR_APP_ACCESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_DIGITAL_GOODS_TRANSACTION_ARN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_DIGITAL_GOODS_TRANSACTION_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_DIGITAL_GOODS_TRANSACTION_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_ARN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_EMAIL_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_IP_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_PHONE_NUMBER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_PHYSICAL_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROFILE_SETUP_OR_APP_ACCESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_3D_SECURE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_AUTHORIZED_SIGNER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_DELIVERY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_DELIVERY_EMP_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_POSSESSION_OR_USAGE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PURCHASER_EMAIL_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PURCHASER_IP_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PURCHASER_NAME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_TRANSACTION_ARN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_TRANSACTION_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_TRANSACTION_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REFUND_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SIGNED_DELIVERY_FORM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SIGNED_ORDER_FORM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TICKET_PROOF\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRACKING_NUMBER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRACKING_URL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisputeFileEvidence\",\n          \"description\": \"Images, files, or other evidence supporting a dispute case.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time at which the evidence was created with Braintree.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sentToProcessorAt\",\n              \"description\": \"Date and time at which the evidence was sent to the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"url\",\n              \"description\": \"A URL where you can retrieve the dispute evidence.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"category\",\n              \"description\": \"The evidence category.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeEvidenceCategory\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"DisputeEvidence\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeFileEvidenceCategory\",\n          \"description\": \"For file evidence: the evidence category that specifies which requirement it satisfies.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"GENERAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LEGIT_PAYMENTS_FOR_SAME_MERCHANDISE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MERCHANT_WEBSITE_OR_APP_ACCESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROFILE_SETUP_OR_APP_ACCESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_3D_SECURE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_AUTHORIZED_SIGNER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_DELIVERY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_DELIVERY_EMP_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROOF_OF_POSSESSION_OR_USAGE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SIGNED_DELIVERY_FORM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SIGNED_ORDER_FORM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TICKET_PROOF\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisputeProcessorResponse\",\n          \"description\": \"Information about the dispute provided by the processor.\",\n          \"fields\": [\n            {\n              \"name\": \"processorComments\",\n              \"description\": \"Additional comments forwarded by the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reason\",\n              \"description\": \"The reason the dispute was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeReason\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reasonCode\",\n              \"description\": \"The reason code provided by the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reasonDescription\",\n              \"description\": \"The reason code description based on the `reasonCode`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"receivedDate\",\n              \"description\": \"Date the dispute was received by the merchant.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"referenceNumber\",\n              \"description\": \"The string value representing the reference number provided by the processor (if any).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeProtectionLevel\",\n          \"description\": \"The Protection level indicates if dispute is eligible for protection through any feature enabled on your account.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CHARGEBACK_PROTECTION_TOOL\",\n              \"description\": \"The dispute is protected by the standard chargeback protection product.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EFFORTLESS_CHARGEBACK_PROTECTION_TOOL\",\n              \"description\": \"The dispute is protected by the effortless chargeback protection product.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NO_PROTECTION\",\n              \"description\": \"The merchant has not enrolled in any chargeback protection products, or the merchant is enrolled, but the dispute is not protected.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeReason\",\n          \"description\": \"The reason a customer opened a chargeback, pre-arbitration, or retrieval.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CANCELLED_RECURRING_TRANSACTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_NOT_PROCESSED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DUPLICATE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAUD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GENERAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INVALID_ACCOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NOT_RECOGNIZED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRODUCT_NOT_RECEIVED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRODUCT_UNSATISFACTORY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RETRIEVAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRANSACTION_AMOUNT_DIFFERS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DisputeSearchInput\",\n          \"description\": \"Input fields for searching for Disputes.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find disputes with an id or ids.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Find disputes with a given status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDisputeStatusInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"Find disputes with a given type.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDisputeTypeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"reason\",\n              \"description\": \"Find disputes with a given reason description.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDisputeReasonInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"caseNumber\",\n              \"description\": \"Find disputes with a given processor's caseNumber.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"referenceNumber\",\n              \"description\": \"Find disputes with a given transaction referenceNumber.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amountDisputed\",\n              \"description\": \"Find disputes for a given amount or currency.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"MonetaryAmountSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amountWon\",\n              \"description\": \"Find disputes by the amount won.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"MonetaryAmountSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"receivedDate\",\n              \"description\": \"Find disputes by the date received.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"replyByDate\",\n              \"description\": \"Find disputes by the reply by date.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"effectiveDate\",\n              \"description\": \"Find disputes by the date a status change history event took effect.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Find disputes based on a set of transaction criteria.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"DisputeTransactionSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"chargebackProtectionLevel\",\n              \"description\": \"Deprecated: Please use `protectionLevel` instead.\\n\\nFind disputes with a given computed chargeback protection level.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchChargebackProtectionLevelInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"protectionLevel\",\n              \"description\" : \"Find disputes with a given protection level.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchDisputeProtectionLevelInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"preDisputeProgram\",\n              \"description\" : \"Find disputes with a given pre-dispute program.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchPreDisputeProgramInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeStatus\",\n          \"description\": \"The status of the dispute.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ACCEPTED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DISPUTED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EXPIRED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOST\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OPEN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WON\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisputeStatusEvent\",\n          \"description\": \"A record of a status the dispute has passed through.\",\n          \"fields\": [\n            {\n              \"name\": \"disbursementDate\",\n              \"description\": \"The date any funds associated with this event were disbursed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the dispute.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the status event occurred.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"effectiveDate\",\n              \"description\": \"The date the status event took effect.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"DisputeTextEvidence\",\n          \"description\": \"Text evidence supporting a dispute case.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time at which the evidence was created with Braintree.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sentToProcessorAt\",\n              \"description\": \"Date and time at which the evidence was sent to the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"comment\",\n              \"description\": \"The body for text evidence.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `content` for name instead.\"\n            },\n            {\n              \"name\": \"content\",\n              \"description\": \"The body for text evidence.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"category\",\n              \"description\": \"The evidence category.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeEvidenceCategory\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"DisputeEvidence\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeTextEvidenceCategory\",\n          \"description\": \"For text evidence: the evidence category that specifies which requirement it satisfies.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AVS_RESPONSE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_ISSUED_AMOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_ISSUED_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEVICE_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEVICE_NAME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DOWNLOAD_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GEOGRAPHICAL_LOCATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_DIGITAL_GOODS_TRANSACTION_ARN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_DIGITAL_GOODS_TRANSACTION_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_DIGITAL_GOODS_TRANSACTION_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_ARN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_EMAIL_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_IP_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_PHONE_NUMBER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIOR_NON_DISPUTED_TRANSACTION_PHYSICAL_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PURCHASER_EMAIL_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PURCHASER_IP_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PURCHASER_NAME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_TRANSACTION_ARN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_TRANSACTION_DATE_TIME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_TRANSACTION_ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"DisputeTransactionSearchInput\",\n          \"description\": \"Transaction input fields for searching for disputes.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"Find disputes for a transaction id or ids.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"Find disputes for a customer id or ids.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionSource\",\n              \"description\": \"Find disputes with a given transaction source.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTransactionSourceInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshotType\",\n              \"description\": \"Find disputes on transactions charging payment methods of the given type.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentMethodSnapshotTypeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"facilitatorOAuthApplicationClientId\",\n              \"description\": \"Find disputes on transactions created by a third party via the Grant API using a given OAuth application client ID.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disbursementDate\",\n              \"description\": \"Find disputes by the transaction's disbursement date.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Find disputes on transactions associated with a merchant account ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"DisputeType\",\n          \"description\": \"Type of dispute.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CHARGEBACK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRE_ARBITRATION\",\n              \"description\": \"A [second challenge to a charge](https://articles.braintreepayments.com/risk-and-security/chargebacks-retrievals/overview#pre-arbitrations), in the case that you have won an initial chargeback.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RETRIEVAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Duration\",\n          \"description\": \"An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) Duration that accepts Days, Hours, Minutes and Seconds.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"ECommerceIndicator\",\n          \"description\": \"A card brand-specific two-digit string describing the mode of the transaction.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"EmailAddress\",\n          \"description\": \"The internationalized email address.<blockquote><strong>Note:</strong> Up to 64 characters are allowed before and 255 characters are allowed after the <code>@</code> sign.\\nHowever, the generally accepted maximum length for an email address is 254 characters.\\nThe pattern verifies that an unquoted <code>@</code> sign exists.</blockquote>\\n\\nminLength: 3\\nmaxLength: 254\\npattern: <code>^.+@[^\\\\\\\"\\\\\\\\-].+$</code>.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"EmvCardOriginDetails\",\n          \"description\": \"Additional information about an integrated circuit card (ICC) payment method supplied by an in-store payment reader.\",\n          \"fields\": [\n            {\n              \"name\": \"authorizationMode\",\n              \"description\": \"The authorization mode used to perform the transaction on the payment reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"InStoreReaderAuthorizationMode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pinVerified\",\n              \"description\": \"An indicator for whether the transaction was verified via pin.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inputMode\",\n              \"description\": \"The input mode used on the payment reader to facilitate an in-store transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentReaderInputMode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalId\",\n              \"description\": \"The ID of the terminal that was processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applicationPreferredName\",\n              \"description\": \"The preferred name associated with the application used to process an EMV transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applicationIdentifier\",\n              \"description\": \"The identifier specifying which EMV application was used to process the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalVerificationResult\",\n              \"description\": \"A status code representing the result of a series of validations performed against an EMV enabled credit card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cardSequenceNumber\",\n              \"description\": \"A unique identifier for credit cards that share the same PAN.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applicationInterchangeProfile\",\n              \"description\": \"An indicator of the credit card's capabilities within the processing application.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalTransactionDate\",\n              \"description\": \"The local date that the transaction requested authorization from the payment reader, formatted YYMMDD.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalTransactionType\",\n              \"description\": \"An indicator of the type of transaction specified during authorization processing.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cashbackAmount\",\n              \"description\": \"An additional amount associated with the transaction that represents the cashback amount requested by the cardholder.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applicationUsageControl\",\n              \"description\": \"An indicator used to specify an issuer's restrictions for processing in a geographic region.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalCountryCode\",\n              \"description\": \"The country code indicated by the payment reader to process the transaction with.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applicationCryptogram\",\n              \"description\": \"The cryptogram provided by an integrated circuit card (ICC) used for processing the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cryptogramInformationData\",\n              \"description\": \"An indicator for the type of application cryptogram provided by an integrated circuit card (ICC) to process the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cardholderVerificationMethodResults\",\n              \"description\": \"An indicator of the cardholder verification method and if it was successful or unsuccessful.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"applicationTransactionCounter\",\n              \"description\": \"A counter managed by an integrated circuit card (ICC) that provides a reference to each transaction using that card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"unpredictableNumber\",\n              \"description\": \"A value used to uniquely differentiate an application cryptogram used during authorization processing.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"issuerActionCodeDefault\",\n              \"description\": \"An indicator of the conditions that caused a transaction to be offline declined by the issuer, in a scenario where the transaction may have authorized if the payment reader made a processor request but was unable to.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"issuerActionCodeDenial\",\n              \"description\": \"An indicator of the conditions that caused a transaction to be offline declined by the issuer, in a scenario where the payment reader did not attempt to make a processor request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"issuerActionCodeOnline\",\n              \"description\": \"An indicator of the conditions that caused the payment reader to attempt to make a processor request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreReaderOriginDetails\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"ExchangeRate\",\n          \"description\": \"A value with more than one decimal place, representing an exchange rate between currencies. For example, `0.93014065558374`.\\nminLength: 3\\npattern: <code>^\\\\\\\\d+[.]\\\\\\\\d+$</code>\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ExchangeRateQuote\",\n          \"description\": \"Details of the generated exchange rate quote.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier, which must be passed in the payment request in order to honor the exchange rate during settlement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"baseAmount\",\n              \"description\": \"The amount in the `baseCurrency` to be converted to the `quoteCurrency`. If no amount was provided, then this amount is 1 unit of `baseCurrency`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"quoteAmount\",\n              \"description\": \"The amount in the `quoteCurrency` converted from the `baseCurrency`.\\nIf no amount was provided, then this amount is converted from 1 unit of `baseCurrency`, which will be the same as `exchangeRate` after rounding-off.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"exchangeRate\",\n              \"description\": \"This much of `quoteCurrency` is required to buy 1 unit of `baseCurrency`. This includes merchant `markupPercentage` if any.\\nIf a `markupPercentage` is specified, this field will be the sum of that percentage and the `tradeRate`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ExchangeRate\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tradeRate\",\n              \"description\": \"This is the rate at which PayPal will settle with the merchant.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ExchangeRate\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"expiresAt\",\n              \"description\": \"When the exchange rate quote represents expires.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refreshesAt\",\n              \"description\": \"When the exchange rate quote represents will be refreshed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ExchangeRateQuoteInput\",\n          \"description\": \"Input to generate the exchange rate quote.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"baseCurrency\",\n              \"description\": \"The currency code from which the exchange rate will be used to convert to the `quoteCurrency`.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CurrencyCodeAlpha\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"quoteCurrency\",\n              \"description\": \"The currency code to which the exchange rate will be used to convert from `baseCurrency`.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CurrencyCodeAlpha\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"baseAmount\",\n              \"description\": \"The amount in the `baseCurrency` to be converted to the `quoteCurrency`. If this is provided, the result will include the converted amount properly rounded.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"markup\",\n              \"description\": \"A percentage added into the exchange rate. This allows the merchant to settle for more than the quoted `tradeRate`.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ExchangeRateQuotePayload\",\n          \"description\": \"Exchange rate quotes for a specific customer.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"quotes\",\n              \"description\": \"Exchange rate quote details for each base and quote currency combination.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"ExchangeRateQuote\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ExternalVaultStatus\",\n          \"description\": \"A credit card's assocation with an external vault.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"VAULTED\",\n              \"description\": \"The payment method for this transaction has been vaulted in an external vault.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WILL_VAULT\",\n              \"description\": \"The payment method has not been vaulted in an exernal vault, but it will be if this transaction is successfully processed.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"FacilitatorDetails\",\n          \"description\": \"Fields capturing information about a third party that provided payment information for this transaction via the Grant API, Shared Vault, or Google Pay.\",\n          \"fields\": [\n            {\n              \"name\": \"oauthApplication\",\n              \"description\": \"The OAuth application that owns the payment information used to create the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"OAuthApplication\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"FailedEvent\",\n          \"description\": \"Accompanying information for a transaction that failed because it could not be successfully sent to the processor.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction failed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response, or an explanation for the lack thereof.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionAuthorizationProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"networkResponse\",\n              \"description\": \"Fields describing the network response to the authorization request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentNetworkResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"riskDecision\",\n              \"description\": \"Risk decision for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"RiskDecision\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"FinalizeDisputeInput\",\n          \"description\": \"Top-level input fields for finalizing a dispute.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disputeId\",\n              \"description\": \"The ID of the dispute to be finalized.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"FinalizeDisputePayload\",\n          \"description\": \"Top-level field returned when finalizing a dispute.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"dispute\",\n              \"description\": \"Information about the dispute that was finalized.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Dispute\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Float\",\n          \"description\": \"Built-in Float\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"FraudProviderConfiguration\",\n          \"description\": \"Configuration for fraud protection provider.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantId\",\n              \"description\": \"The merchant ID used by the fraud protection provider to identify the fraud data collection request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"The name of the fraud provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"FraudServiceProvider\",\n          \"description\": \"The fraud service provider used to generate the risk decision.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CHARGEBACK_PROTECTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EFFORTLESS_CHARGEBACK_PROTECTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAUD_PROTECTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAUD_PROTECTION_ADVANCED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAUD_PROTECTION_ENTERPRISE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"KOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"GatewayRejectedEvent\",\n          \"description\": \"Accompanying information for a gateway rejected transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction was rejected by the gateway.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"gatewayRejectionReason\",\n              \"description\": \"The reason the transaction was rejected, based on your gateway settings.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"GatewayRejectionReason\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response. Depending on your gateway settings, the AVS and CVV responses may be the reason for the rejection.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionAuthorizationProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"networkResponse\",\n              \"description\": \"Fields describing the network response to the authorization request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentNetworkResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"riskDecision\",\n              \"description\": \"Risk decision for this transaction. If the gatewayRejectionReason is fraud, this may be the reason for the rejection.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"RiskDecision\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"duplicateOf\",\n              \"description\": \"The original transaction if the gateway rejection reason was `DUPLICATE`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"GatewayRejectionReason\",\n          \"description\": \"Possible reasons why a transaction was rejected by the gateway.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"APPLICATION_INCOMPLETE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AVS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AVS_AND_CVV\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CVV\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DUPLICATE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EXCESSIVE_RETRY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAUD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MANUAL_TRANSACTIONS_DISABLED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYMENT_METHOD_BLOCKED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RISK_THRESHOLD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"THREE_D_SECURE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TOKEN_ISSUANCE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TOO_MANY_CONFIRMATION_ATTEMPTS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNION_PAY_ENROLLMENT_REQUIRED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"GenerateExchangeRateQuoteInput\",\n          \"description\": \"Input to generate a list of exchange rate quotes.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"quotes\",\n              \"description\": \"Base and quote currency combinations for which the quote will be generated.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ExchangeRateQuoteInput\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"GeoCoordinates\",\n          \"description\": \"Coordinates describing a geographic position.\",\n          \"fields\": [\n            {\n              \"name\": \"latitude\",\n              \"description\": \"The angular distance of a place north or south of the earth's equator.\\nA positive value is north of the equator, a negative value is south of the equator.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Float\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"longitude\",\n              \"description\": \"The angular distance of a place east or west of the meridian at Greenwich, England.\\nA positive value is east of the prime meridian, a negative value is west of the prime meridian.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Float\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"GeoCoordinatesInput\",\n          \"description\": \"Coordinates describing a geographic position.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"latitude\",\n              \"description\": \"The angular distance of a place north or south of the earth's equator.\\nA positive value is north of the equator, a negative value is south of the equator.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Float\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"longitude\",\n              \"description\": \"The angular distance of a place east or west of the meridian at Greenwich, England.\\nA positive value is east of the prime meridian, a negative value is west of the prime meridian.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Float\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"GooglePayConfiguration\",\n          \"description\": \"Configuration for Google Pay on Android and the web.\",\n          \"fields\": [\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The country code of the acquiring bank where the transaction is likely to be processed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCodeAlpha2\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"displayName\",\n              \"description\": \"A string used to identify the merchant to the customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"environment\",\n              \"description\": \"The environment being used for Google Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"GooglePayEnvironment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"googleAuthorization\",\n              \"description\": \"Authorization to use when tokenizing a Google Pay payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is included for supporting legacy clients.\"\n            },\n            {\n              \"name\": \"paypalClientId\",\n              \"description\": \"A string used to identify the merchant's PayPal account when generating a PayPal Closed Loop Token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for Google Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"GooglePayEnvironment\",\n          \"description\": \"The environment being used for Google Pay.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"PRODUCTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SANDBOX\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"production\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sandbox\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"GooglePayOriginDetails\",\n          \"description\": \"Additional information about the payment method specific to Google Pay.\",\n          \"fields\": [\n            {\n              \"name\": \"googleTransactionId\",\n              \"description\": \"A reference ID for the Google transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN may differ from the BIN of the customer's actual card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"HyperwalletAccountDetails\",\n          \"description\": \"Details about a Hyperwallet account.\",\n          \"fields\": [\n            {\n              \"name\": \"userId\",\n              \"description\": \"The ID of the Hyperwallet account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"ID\",\n          \"description\": null,\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"IDealConfiguration\",\n          \"description\": \"Configuration for iDEAL.\",\n          \"fields\": [\n            {\n              \"name\": \"routeId\",\n              \"description\": \"The route ID used to process an iDEAL payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"assetsUrl\",\n              \"description\": \"A URL used to redirect the customer to the bank's web page.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreContext\",\n          \"description\": \"Reference object for an in-store request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this in-store request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use the id field from the InStoreContextPayload\"\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"The transaction representing the charge on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use a Node query for a RequestTransactionInStoreContext\"\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"The refund representing the refund on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Refund\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use a Node query for a RequestRefundInStoreContext\"\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader associated with the in-store request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use the reader field from the InStoreContextPayload\"\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use the status field from the InStoreContextPayload\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreContextPayload\",\n          \"description\": \"Top-level fields returned when requesting a state change on an in-store reader.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inStoreContext\",\n              \"description\": \"The in-store context created when an in-store flow is initiated.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContext\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use top-level fields\"\n            },\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this in-store context request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader associated with the in-store request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INTERFACE\",\n          \"name\": \"InStoreContextResult\",\n          \"description\": \"Reference object for an in-store request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this in-store request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader associated with the in-store request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"InStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestChargeInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestConfirmationPromptInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestDisplayInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestFirmwareUpdateInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestRefundInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestSignaturePromptInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestVaultInStoreContext\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"InStoreContextStatus\",\n          \"description\": \"Potential statuses of a context created as part of an in-store request.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CANCELLED\",\n              \"description\": \"The context was successfully canceled.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"COMPLETE\",\n              \"description\": \"Successful. The context was ended.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FAILED\",\n              \"description\": \"Not successful. The context was ended.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PENDING\",\n              \"description\": \"Flow in-progress. Waiting for reader or point of sale interaction.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROCESSING\",\n              \"description\": \"Payment flow in-progress. Customer payment method submitted for transaction processing.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreDisplayItemInput\",\n          \"description\": \"Input fields for an individual display item on an in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"kind\",\n              \"description\": \"The display item type to be displayed on the in-store reader.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"DisplayItemType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"The display item text to be displayed on the in-store reader. 35 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"quantity\",\n              \"description\": \"The number of units for a CHARGE or DISCOUNT item. Must be greater than 0.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Float\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The total amount of a CHARGE or DISCOUNT item.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreLocation\",\n          \"description\": \"An in-store location.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"Name of the in-store location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"internalName\",\n              \"description\": \"A merchant-assigned internal name of this location, unique to this merchant.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"address\",\n              \"description\": \"The address of the in-store location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreLocationAddress\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"geoCoordinates\",\n              \"description\": \"The coordinates of this location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"GeoCoordinates\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payerId\",\n              \"description\": \"The PayPal account ID to which this location was added.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"qrCodePaymentsEnabled\",\n              \"description\": \"Whether QR code payments will be enabled for this location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreLocationAddress\",\n          \"description\": \"Input fields for an in-store location address.\",\n          \"fields\": [\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"Extended address information, such as an apartment or suite number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"Locality/city.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or province.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code for the address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreLocationAddressInput\",\n          \"description\": \"Input fields for an in-store Location Address.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"Extended address information, such as an apartment or suite number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"Locality/city.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or province.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code for the address.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CountryCode\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreLocationAddressUpdateInput\",\n          \"description\": \"Input fields for an in-store Location Address update.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"Extended address information, such as an apartment or suite number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"Locality/city.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or province.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code for the address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreLocationConnection\",\n          \"description\": \"A paginated list of in-store locations.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of in-store locations.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"InStoreLocationConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of in-store locations contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreLocationConnectionEdge\",\n          \"description\": \"An in-store location within an InStoreLocationConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"The in-store locations's location within the InStoreLocationConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The in-store location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreLocation\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreLocationInput\",\n          \"description\": \"Fields required for an instore location.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The publicly visible label of this Location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"internalName\",\n              \"description\": \"Name assigned by the merchant to uniquely identify this Location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"address\",\n              \"description\": \"The address of the in-store Location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"InStoreLocationAddressInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"geoCoordinates\",\n              \"description\": \"The coordinates of this location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"GeoCoordinatesInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"payerId\",\n              \"description\": \"The PayPal account ID to which this Location will be added.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"enableQRCodePayments\",\n              \"description\": \"Whether QR code payments will be enabled for this location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreLocationUpdateInput\",\n          \"description\": \"Fields required to update an in-store location.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The publicly visible label of this location.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"internalName\",\n              \"description\": \"Name assigned by the merchant to uniquely identify this location.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"address\",\n              \"description\": \"The address of the location.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"InStoreLocationAddressUpdateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"geoCoordinates\",\n              \"description\": \"The coordinates of this location.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"GeoCoordinatesInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"payerId\",\n              \"description\": \"The PayPal account ID to which this location will be added.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"enableQRCodePayments\",\n              \"description\": \"Whether QR code payments will be enabled for this location.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreReader\",\n          \"description\": \"An in-store payment card reader.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"Name given to the reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"vendor\",\n              \"description\": \"Vendor-specific information about the reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"InStoreReaderVendor\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"location\",\n              \"description\": \"The in-store location the reader is attached to.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreLocation\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Current status of the reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ReaderStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pairedAt\",\n              \"description\": \"Date and time when the reader was paired.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"lastSeenAt\",\n              \"description\": \"Date and time when the reader last established a connection.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"offlineSince\",\n              \"description\": \"Date and time when the reader last disconnected.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"softwareVersion\",\n              \"description\": \"The version of the payment application running on the Reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"InStoreReaderAuthorizationMode\",\n          \"description\": \"The authorization mode used to perform the transaction.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ISSUER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreReaderConnection\",\n          \"description\": \"A paginated list of in-store readers.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of in-store readers.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"InStoreReaderConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of in-store readers contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreReaderConnectionEdge\",\n          \"description\": \"An in-store reader within an InStoreReaderConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"The in-store reader's location within the InStoreReaderConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The in-store reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INTERFACE\",\n          \"name\": \"InStoreReaderOriginDetails\",\n          \"description\": \"Additional information about the payment method supplied by an in-store payment reader.\",\n          \"fields\": [\n            {\n              \"name\": \"authorizationMode\",\n              \"description\": \"The authorization mode used to perform the transaction on the payment reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"InStoreReaderAuthorizationMode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pinVerified\",\n              \"description\": \"An indicator for whether the transaction was verified via pin.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inputMode\",\n              \"description\": \"The input mode used on the payment reader to facilitate an in-store transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentReaderInputMode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminalId\",\n              \"description\": \"The ID of the terminal that was processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CardPresentOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"EmvCardOriginDetails\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"InStoreReaderPayload\",\n          \"description\": \"Top-level fields returned for an in-store reader.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreReaderSearchInput\",\n          \"description\": \"Input fields for searching for in-store readers.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"locationId\",\n              \"description\": \"Find in-store readers with location ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"softwareVersion\",\n              \"description\": \"Find in-store readers with software version.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchSoftwareVersionInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerStatus\",\n              \"description\": \"Find in-store readers with reader status.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ReaderStatus\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreReaderSetupInput\",\n          \"description\": \"Fields that are reader specific for pairing a reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"locationId\",\n              \"description\": \"In-Store Location to attach Reader to.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"Name given to the Reader.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"InStoreReaderVendor\",\n          \"description\": \"A union of all possible in-store reader vendors.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"VerifoneVendor\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreRefundInput\",\n          \"description\": \"Input fields for creating an in-store transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"Refund amount of the request. This value must be greater than 0, and must match the currency format of the merchant account. This can only contain numbers and one decimal point (e.g. x.xx). Can't be greater than the maximum allowed by the processor.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Merchant account ID used to process the refund. Currency is also determined by merchant account ID. If no merchant account ID is specified, we will use your default merchant account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Additional information about the refund. On PayPal refunds, this field maps to the PayPal invoice number. PayPal invoice numbers must be unique in your PayPal business account. Maximum 255 characters or 127 for PayPal refunds.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CustomFieldInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionDescriptorInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"InStoreTransactionInput\",\n          \"description\": \"Input fields for creating an in-store transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"Billing amount of the request. This value must be greater than 0, and must match the currency format of the merchant account. This can only contain numbers and one decimal point (e.g. x.xx). Can't be greater than the maximum allowed by the processor.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Merchant account ID used to process the transaction. Currency is also determined by merchant account ID. If no merchant account ID is specified, we will use your default merchant account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Additional information about the transaction. On PayPal transactions, this field maps to the PayPal invoice number. PayPal invoice numbers must be unique in your PayPal business account. Maximum 255 characters or 127 for PayPal transactions.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CustomFieldInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionDescriptorInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"If charging a single-use payment method, optional ID of a customer to associate the transaction with. If vaulting the single-use payment method, this customer will be associated with the resulting multi-use payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"vaultPaymentMethodAfterTransacting\",\n              \"description\": \"When a single-use payment method is used to create this transaction, it can be automatically stored in the vault after transacting. If this field is left blank, the single-use payment method will not be vaulted.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"VaultInStorePaymentMethodAfterTransactingInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"channel\",\n              \"description\": \"For partners and shopping carts only. If you are a shopping cart provider or other Braintree partner, pass a string identifier for your service. For PayPal transactions, this maps to paypal.bn_code.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Int\",\n          \"description\": \"Built-in Int\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"KountConfiguration\",\n          \"description\": \"Configuration for Kount fraud tools.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantId\",\n              \"description\": \"The Kount merchant ID used to identify the fraud data collection request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Language\",\n          \"description\": \"The [language tag](https://tools.ietf.org/html/bcp47#section-2) for the language in which to localize the error-related strings, such as messages, issues, and suggested actions.\\nThe tag is made up of the [ISO 639-2 language code](https://www.loc.gov/standards/iso639-2/php/code_list.php), the optional [ISO-15924 script tag](http://www.unicode.org/iso15924/codelists.html), and the [ISO-3166 alpha-2 country code](https://developer.paypal.com/braintree/docs/reference/general/countries).\\nmaxLength: 10\\nminLength: 2\\npattern: <code>^[a-z]{2}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}))?$</code>\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"LegacyIdType\",\n          \"description\": \"The type of object the legacy ID represents when converting it to a global ID.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CUSTOMER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DISPUTE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MERCHANT_ACCOUNT_APPLICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYMENT_CONTEXT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYMENT_METHOD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REFUND\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRANSACTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"US_BANK_ACCOUNT_VERIFICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VERIFICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"LiabilityShift\",\n          \"description\": \"A scenario detailing which party assumes liability for certain conditions in the event of a transaction being disputed.\",\n          \"fields\": [\n            {\n              \"name\": \"responsibleParty\",\n              \"description\": \"The party taking responsibility for liability.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"LiabilityShiftResponsibleParty\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"conditions\",\n              \"description\": \"The specific conditions under which the responsible party assumes liability, in the event of a chargeback.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"LiabilityShiftCondition\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"LiabilityShiftCondition\",\n          \"description\": \"If enrolled in Effortless Chargeback Protection, and in the event the transaction is disputed, these are the specific conditions under which the responsible party assumes liability for that chargeback.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ITEM_NOT_RECEIVED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNAUTHORIZED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"LiabilityShiftResponsibleParty\",\n          \"description\": \"If enrolled in Effortless Chargeback Protection, and in the event the transaction is disputed, these are the possible parties which can assume liability.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ISSUER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"LocalPaymentAddressInput\",\n          \"description\": \"Input fields for local payment addresses.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"Extended address information, such as an apartment or suite number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"Locality/city.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or province.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code in any country's format, otherwise known as CAP, CEP, Eircode, NPA, PIN, PLZ, or ZIP code.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code for the address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"LocalPaymentContext\",\n          \"description\": \"The LocalPayment object.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier for the payment context.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"The type of the local payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"LocalPaymentMethodType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"approvalUrl\",\n              \"description\": \"The URL to which a customer should be redirected to approve the local payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount charged in this local payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The merchant account used to create the payment context.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactedAt\",\n              \"description\": \"Date and time when the local payment context was used to create a transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"approvedAt\",\n              \"description\": \"Date and time when the local payment context was approved by the customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the local payment context was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Timestamp\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updatedAt\",\n              \"description\": \"Date and time when the local payment context was updated.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Timestamp\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"expiredAt\",\n              \"description\": \"Date and time when the local payment context was expired.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"Unique identifier for the local payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The PayPal Invoice ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentContext\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"LocalPaymentDetails\",\n          \"description\": \"Local payment specific details on a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"origin\",\n              \"description\": \"Additional information about the local payment method provided from a third-party origin, such as PayPal or another regional payment method provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethodOrigin\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"Regional payment method selected by the customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"LocalPaymentMethodType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"displayName\",\n              \"description\": \"Description of the payment method that can be displayed to customers.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"LocalPaymentMethodType\",\n          \"description\": \"A value identifying the type of regional payment method.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ALIPAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BANCONTACT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BLIK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BOLETOBANCARIO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EPS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GIROPAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GRABPAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IDEAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MULTIBANCO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MYBANK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OXXO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"P24\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYU\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_UPON_INVOICE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SATISPAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SEPA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SOFORT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SWISH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRUSTLY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VERKKOPANKKI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VIPPS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WECHAT_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"LocalPaymentPayerInfoInput\",\n          \"description\": \"Input fields for the payer of a local payment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"givenName\",\n              \"description\": \"The payer's given (first) name.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"surname\",\n              \"description\": \"The payer's surname (last name).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"The payer's email.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"EmailAddress\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The payer's phone number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"The payer's shipping address.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"LocalPaymentAddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The payer's billing address.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"LocalPaymentAddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"taxInfo\",\n              \"description\": \"The payer's tax information. This is only required for Boleto Bancário payments.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TaxInfoInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"MVVAcceptanceChannel\",\n          \"description\": \"Means by which customers by their bills.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"FACE_TO_FACE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MAIL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PHONE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WEB\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"MVVRegistrationType\",\n          \"description\": \"Supported MVV (Merchant Verification Value) programs.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"LOAN_VPP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TAX_DEBIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UTIL_RATE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UTIL_VPP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"MVVUtilityType\",\n          \"description\": \"Supported MVV (Merchant Verification Value) utility types.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ELECTRIC\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GAS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRASH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WATER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"MandateType\",\n          \"description\": \"Mandate type for SEPA Direct Debit Account.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ONE_OFF\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MasterpassConfiguration\",\n          \"description\": \"Configuration for Masterpass.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantCheckoutId\",\n              \"description\": \"The Masterpass merchant checkout ID used to identify the merchant in Masterpass requests.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for Masterpass.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MasterpassOriginDetails\",\n          \"description\": \"Additional information about the payment method specific to Masterpass.\",\n          \"fields\": [\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN may differ from the BIN of the customer's actual card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Merchant\",\n          \"description\": \"Details about a merchant and its current settings.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Current status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"companyName\",\n              \"description\": \"Company name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"website\",\n              \"description\": \"The merchant's main website.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timezone\",\n              \"description\": \"The timezone that the merchant operates in.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccounts\",\n              \"description\": \"A paginated list of merchant accounts that belong to this merchant. Filtered by search criteria, if provided.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"MerchantAccountSearchInput\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MerchantAccountConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MerchantAccount\",\n          \"description\": \"Information about a merchant account associated with a merchant.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier for the merchant account. Used to determine what merchant account processed or will process a given Payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bankAccount\",\n              \"description\": \"The disbursement bank account linked with the merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DisbursementBankAccount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The ISO code for the currency the merchant account uses.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"dbaName\",\n              \"description\": \"Business name of the account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"externalId\",\n              \"description\": \"A unique identifier for this account in external systems.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of a merchant account. This determines whether the merchant account can be used to create a Payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"MerchantAccountStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"isDefault\",\n              \"description\": \"Whether this merchant account is the default for this merchant. The default merchant account is used to process all Payments where a merchant account ID is not specified.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paypalAccount\",\n              \"description\": \"The PayPal account linked with the merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalAccountDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"hyperwalletAccount\",\n              \"description\": \"The Hyperwallet account linked with the merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"HyperwalletAccountDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"venmoAccount\",\n              \"description\": \"The Venmo account linked with the merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VenmoAccountDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"threeDSecure\",\n              \"description\": \"The 3D Secure configuration for the merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MerchantAccountThreeDSecureConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MerchantAccountApplication\",\n          \"description\": \"A record of a merchant account application.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for the account application. Can be used to query the status of the onboarding request in the future.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the application.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ApplicationStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MerchantAccountConnection\",\n          \"description\": \"A paginated list of merchant accounts.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of merchant accounts.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"MerchantAccountConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of merchant accounts contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MerchantAccountConnectionEdge\",\n          \"description\": \"A merchant account within a MerchantAccountConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This merchant account's location within the MerchantAccountConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MerchantAccount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"MerchantAccountSearchInput\",\n          \"description\": \"Input fields for searching for merchant accounts.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find merchant accounts with an id or ids.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paypalAccountId\",\n              \"description\": \"Find merchant accounts associated with a given PayPal account ID.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"MerchantAccountStatus\",\n          \"description\": \"The status of a merchant account. This determines whether the merchant account can be used to create a Payment, and whether funds can continue to flow to the associated bank account.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ACTIVE\",\n              \"description\": \"The merchant account can be used to create transactions and refunds.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PENDING\",\n              \"description\": \"The merchant account is still being set up, and cannot be used to create transactions or refunds yet.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUSPENDED\",\n              \"description\": \"The merchant account cannot be used to process transactions or refunds.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MerchantAccountThreeDSecureConfiguration\",\n          \"description\": \"Details about the 3D Secure configuration of the merchant account.\",\n          \"fields\": [\n            {\n              \"name\": \"v1\",\n              \"description\": \"Configuration for 3D Secure v1.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MerchantAccountThreeDSecureVersionConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"v2\",\n              \"description\": \"Configuration for 3D Secure v2.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MerchantAccountThreeDSecureVersionConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MerchantAccountThreeDSecureVersionConfiguration\",\n          \"description\": \"Details about the configuration of a version of 3D Secure for the merchant account.\",\n          \"fields\": [\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"Card types enabled for this 3D Secure version.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"MonetaryAmount\",\n          \"description\": \"A monetary amount with currency.\",\n          \"fields\": [\n            {\n              \"name\": \"value\",\n              \"description\": \"The amount of money, either a whole number or a number with up to 3 decimal places.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"currencyIsoCode\",\n              \"description\": \"The ISO code for the money's currency.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `currencyCode` instead.\"\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The currency code for the monetary amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"MonetaryAmountInput\",\n          \"description\": \"Input fields representing an amount with currency.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"value\",\n              \"description\": \"The amount of money, either a whole number or a number with up to 3 decimal places.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The currency code for the monetary amount.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CurrencyCodeAlpha\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"MonetaryAmountSearchInput\",\n          \"description\": \"Input fields for searching for a transaction or refund amount.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"value\",\n              \"description\": \"Find transactions for a given amount.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchRangeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"currencyIsoCode\",\n              \"description\": \"Deprecated: Please use `currencyCode` instead.\\n\\nFind transactions with a given currency.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"Find transactions with a given currency.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Month\",\n          \"description\": \"A two-digit, zero-padded month.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Mutation\",\n          \"description\": \"The top-level Mutation type. Mutations are used to make requests that create or modify data.\",\n          \"fields\": [\n            {\n              \"name\": \"authorizePaymentMethod\",\n              \"description\": \"Authorize an eligible payment method and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AuthorizePaymentMethodInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authorizePayPalAccount\",\n              \"description\": \"Authorize an eligible PayPal account and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AuthorizePayPalAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalTransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authorizeVenmoAccount\",\n              \"description\": \"Authorize an eligible Venmo account and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AuthorizeVenmoAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authorizeCreditCard\",\n              \"description\": \"Authorize a credit card of any origin and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AuthorizeCreditCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"captureTransaction\",\n              \"description\": \"Capture an authorized transaction and return a payload that includes details of the transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CaptureTransactionInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"chargePaymentMethod\",\n              \"description\": \"Charge any payment method and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ChargePaymentMethodInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"chargeUsBankAccount\",\n              \"description\": \"Charge a US bank account and return a payload that includes details of the resulting transaction. See https://developers.braintreepayments.com/guides/ach/configuration for information on eligibility and setup.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ChargeUsBankAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"chargePayPalAccount\",\n              \"description\": \"Charge a PayPal account and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ChargePayPalAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalTransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"chargeVenmoAccount\",\n              \"description\": \"Charge a Venmo account and return a payload that includes details of the resulting transaction. See https://articles.braintreepayments.com/guides/payment-methods/venmo for information on eligibility and setup.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ChargeVenmoAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"chargeCreditCard\",\n              \"description\": \"Charge a credit card of any origin and return a payload that includes details of the resulting transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ChargeCreditCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"vaultPaymentMethod\",\n              \"description\": \"Vault payment information from a single-use payment method and return a payload that includes a new multi-use payment method. When vaulting a credit card, by default, this mutation will also verify that card before vaulting.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VaultPaymentMethodInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VaultPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"vaultUsBankAccount\",\n              \"description\": \"Vault payment information from a single-use US bank account payment method and return a payload that includes a new multi-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VaultUsBankAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VaultPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"vaultCreditCard\",\n              \"description\": \"Vault payment information from a single-use credit card and return a payload that includes a new multi-use payment method. By default, this mutation will also verify the card before vaulting.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VaultCreditCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VaultPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundTransaction\",\n              \"description\": \"Refund a settled transaction and return a payload that includes details of the refund.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RefundTransactionInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RefundTransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reverseTransaction\",\n              \"description\": \"Reverse a transaction and return a payload that includes either the voided transaction or a refund.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ReverseTransactionInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ReverseTransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reverseRefund\",\n              \"description\": \"Reverse a refund and return a payload that includes voided refund.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ReverseRefundInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RefundTransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundCreditCard\",\n              \"description\": \"Create a detached refund (unassociated with any previous Braintree payment) to a credit card and return a payload that includes details of the refund.\\n\\nWe have previously referred to this as issuing a \\\"detached credit,\\\" and it is disallowed by default. See the [documentation](https://articles.braintreepayments.com/control-panel/transactions/refunds-voids-credits#detached-credits) for more information regarding eligibility and configuration.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RefundCreditCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RefundCreditCardPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundUsBankAccount\",\n              \"description\": \"Create a detached refund (unassociated with any previous Braintree payment) to a  US Bank Account and return a payload that includes details of the refund.\\n\\nWe have previously referred to this as issuing a \\\"detached credit,\\\" and it is disallowed by default. See the [documentation](https://articles.braintreepayments.com/control-panel/transactions/refunds-voids-credits#detached-credits) for more information regarding eligibility and configuration.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RefundUsBankAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RefundUsBankAccountPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updateTransactionCustomFields\",\n              \"description\": \"Update custom fields on a transaction. Custom fields are [defined in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#store-and-pass-back-fields).\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"UpdateTransactionCustomFieldsInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UpdateTransactionCustomFieldsPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verifyPaymentMethod\",\n              \"description\": \"Run a verification on a multi-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VerifyPaymentMethodInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VerifyPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verifyCreditCard\",\n              \"description\": \"Run a verification on a multi-use credit card payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VerifyCreditCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VerifyPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verifyUsBankAccount\",\n              \"description\": \"Run a verification on a multi-use US bank account payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VerifyUsBankAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VerifyPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"confirmMicroTransferAmounts\",\n              \"description\": \"Confirm micro-transfer amounts initiated by vaultUsBankAccount or verifyUsBankAccount, completing the verification process for a US Bank Account via micro-transfer.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"ConfirmMicroTransferAmountsInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ConfirmMicroTransferAmountsPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deletePaymentMethodFromVault\",\n              \"description\": \"Delete a multi-use payment method from the vault.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"DeletePaymentMethodFromVaultInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DeletePaymentMethodFromVaultPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createClientToken\",\n              \"description\": \"Create a client token that can be used to initialize a client in order to tokenize payment information.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CreateClientTokenInput\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateClientTokenPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createUniversalAccessToken\",\n              \"description\": \"Create a PayPal access token that can be used to make additional API calls or initialize a client.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreateUniversalAccessTokenInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateUniversalAccessTokenPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"partialCaptureTransaction\",\n              \"description\": \"Partially capture funds from a transaction that was successfully authorized and return a payload that includes a new transaction with information about the capture. This is available for [Venmo](https://developers.braintreepayments.com/guides/venmo/submit-for-partial-settlement) and [PayPal](https://articles.braintreepayments.com/guides/payment-methods/paypal/processing#multiple-partial-settlements) transactions.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"PartialCaptureTransactionInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PartialCaptureTransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeCustomActionsPaymentMethod\",\n              \"description\": \"Tokenize Custom Actions fields and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeCustomActionsPaymentMethodInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeCustomActionsPaymentMethodPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeCreditCard\",\n              \"description\": \"Tokenize credit card fields and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeCreditCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeCreditCardPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeCvv\",\n              \"description\": \"Tokenize a credit card's CVV and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeCvvInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeCvvPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeNetworkToken\",\n              \"description\": \"Tokenize a network tokenized payment instrument and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeNetworkTokenInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeNetworkTokenPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeSamsungPayCard\",\n              \"description\": \"Tokenize Samsung Pay card fields and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeSamsungPayCardInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeSamsungPayCardPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeUsBankAccount\",\n              \"description\": \"Tokenize US bank account fields and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeUsBankAccountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeUsBankAccountPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizeUsBankLogin\",\n              \"description\": \"Tokenize US bank login fields and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizeUsBankLoginInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizeUsBankAccountPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizePayPalOneTimePayment\",\n              \"description\": \"Tokenize PayPal One-Time Payment and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizePayPalOneTimePaymentInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizePayPalOneTimePaymentPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createPayPalOneTimePayment\",\n              \"description\": \"Set up a PayPal One-Time Payment for approval by a PayPal user. See [documentation](https://developer.paypal.com/braintree/docs/guides/paypal/checkout-with-paypal) for more information. Your account must be enabled for this feature.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreatePayPalOneTimePaymentInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreatePayPalOneTimePaymentPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizePayPalBillingAgreement\",\n              \"description\": \"Tokenize PayPal account and return a payload that includes a single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TokenizePayPalBillingAgreementInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizePayPalBillingAgreementPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createPayPalBillingAgreement\",\n              \"description\": \"Set up a PayPal Billing Agreement Token for approval by a PayPal user.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreatePayPalBillingAgreementInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreatePayPalBillingAgreementPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createCustomer\",\n              \"description\": \"Create a customer for storing individual customer information and/or grouping transactions and multi-use payment methods.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CreateCustomerInput\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateCustomerPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updateCustomer\",\n              \"description\": \"Update a customer's information.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"UpdateCustomerInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UpdateCustomerPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deleteCustomer\",\n              \"description\": \"Delete a customer, breaking association between any of the customer's transactions. Will not delete if the customer has existing payment methods.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"DeleteCustomerInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DeleteCustomerPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deletePaymentMethodFromSingleUseToken\",\n              \"description\": \"Delete a payment method referenced by a single-use token.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"DeletePaymentMethodFromSingleUseTokenInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DeletePaymentMethodFromSingleUseTokenPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `deletePaymentMethodFromVault` instead.\"\n            },\n            {\n              \"name\": \"updateCreditCardBillingAddress\",\n              \"description\": \"Set a new billing address for a multi-use credit card payment method. By default, this mutation will also verify the card with the new billing address before updating.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"UpdateCreditCardBillingAddressInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UpdateCreditCardBillingAddressPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"performThreeDSecureLookup\",\n              \"description\": \"Attempt to perform 3D Secure Authentication on credit card payment method. This may consume the payment method and return a new single-use payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"PerformThreeDSecureLookupInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PerformThreeDSecureLookupPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"acceptDispute\",\n              \"description\": \"Accepts a dispute and returns a payload that includes the dispute that was accepted. Only disputes with a status of OPEN can be accepted.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AcceptDisputeInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"AcceptDisputePayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"finalizeDispute\",\n              \"description\": \"Finalizes a dispute and returns a payload that includes the dispute that was finalized. Only disputes with a status of OPEN can be finalized.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"FinalizeDisputeInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"FinalizeDisputePayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createDisputeTextEvidence\",\n              \"description\": \"Creates text evidence to a dispute and returns a payload that includes the evidence that was created. Only disputes with a status of OPEN can have text evidence created for them.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreateDisputeTextEvidenceInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateDisputeTextEvidencePayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deleteDisputeEvidence\",\n              \"description\": \"Deletes evidence from a dispute.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"DeleteDisputeEvidenceInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DeleteDisputeEvidencePayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createDisputeFileEvidence\",\n              \"description\": \"Uploads an evidence file and associates it with a dispute. **Note:**: file upload requires a special request format. See the ['Uploading Files' integration guide](https://graphql.braintreepayments.com/integration_guides/uploading_files) for instructions on how to perform this mutation.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreateDisputeFileEvidenceInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateDisputeFileEvidencePayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"vaultPayPalBillingAgreement\",\n              \"description\": \"Vault an existing PayPal Billing Agreement that was not created through Braintree. Only use this mutation if you need to import PayPal Billing Agreements from an existing PayPal integration into your Braintree account.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VaultPayPalBillingAgreementInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VaultPayPalBillingAgreementPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sandboxSettleTransaction\",\n              \"description\": \"Force a transaction to settle in the sandbox environment. Generates an error elsewhere.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"SandboxSettleTransactionInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createInStoreLocation\",\n              \"description\": \"Creates a new In-Store Location to associate Readers.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreateInStoreLocationInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateInStoreLocationPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updateInStoreLocation\",\n              \"description\": \"Updates an In-Store Location.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"UpdateInStoreLocationInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UpdateInStoreLocationPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pairInStoreReader\",\n              \"description\": \"Pairs a Reader to an account and In-Store Location.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"PairInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReaderPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updateInStoreReader\",\n              \"description\": \"Updates an In-Store Reader.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"UpdateInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReaderPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestChargeFromInStoreReader\",\n              \"description\": \"Request an in-store reader to begin the charge flow.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestChargeFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestCancelFromInStoreReader\",\n              \"description\": \"Request an in-store reader to cancel the charge flow.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestCancelFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestRefundFromInStoreReader\",\n              \"description\": \"Request an in-store reader to start an unreferenced refund flow.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestRefundFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestVaultFromInStoreReader\",\n              \"description\": \"Request an in-store reader to vault a payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestVaultFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestTextDisplayFromInStoreReader\",\n              \"description\": \"Request an in-store reader to display text.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestTextDisplayFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestItemDisplayFromInStoreReader\",\n              \"description\": \"Request an in-store reader to display line items.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestItemDisplayFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestFirmwareUpdateFromInStoreReader\",\n              \"description\": \"Request an in-store reader to update to the latest version of software.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestFirmwareUpdateFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestSignaturePromptFromInStoreReader\",\n              \"description\": \"Request an in-store reader to display a signature prompt.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestSignaturePromptFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"requestConfirmationPromptFromInStoreReader\",\n              \"description\": \"Request an in-store reader to display a confirmation prompt.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RequestConfirmationPromptFromInStoreReaderInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updateTransactionAmount\",\n              \"description\": \"Updates the authorization amount of the transaction.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"UpdateTransactionAmountInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"generateExchangeRateQuote\",\n              \"description\": \"Generate a customized currency exchange rate quote for items on a merchant's page. This allows merchants to advertise products in their customer's currency. Your account must be enabled to use this feature.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"GenerateExchangeRateQuoteInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ExchangeRateQuotePayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createNonInstantLocalPaymentContext\",\n              \"description\": \"Creates a non-instant local payment context. Your account must be enabled to use this feature.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CreateNonInstantLocalPaymentContextInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreateNonInstantLocalPaymentContextPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"NameInput\",\n          \"description\": \"The name of the party.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"prefix\",\n              \"description\": \"The prefix, or title, to the party name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"givenName\",\n              \"description\": \"The party's given, or first, name. Required if the party is a person.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"surname\",\n              \"description\": \"The party's surname or family name. Also known as the last name. Required if\\nthe party is a person. Use also to store multiple surnames including the\\nmatronymic, or mother's, surname.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"middleName\",\n              \"description\": \"The party's middle name. Use also to store multiple middle names including the patronymic, or father's, middle name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"suffix\",\n              \"description\": \"The suffix for the party's name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"alternateFullName\",\n              \"description\": \"The party's alternate name. Can be a business name, nickname, or any other\\nname that cannot be split into first, last name. Required for a business party name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"NetworkTokenInput\",\n          \"description\": \"Input fields for a network tokenized card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"cryptogram\",\n              \"description\": \"A one-time-use string generated by the token requester to validate the transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"eCommerceIndicator\",\n              \"description\": \"A two-digit string that should be passed along in the authorization message.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ECommerceIndicator\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationMonth\",\n              \"description\": \"A two-digit string representing the expiration month of the DPAN.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Month\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationYear\",\n              \"description\": \"A four-digit string representing the expiration year of the DPAN.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Year\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"number\",\n              \"description\": \"The card number used in processing. This is a device PAN (DPAN), not the backing card number (FPAN).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CreditCardNumber\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"originDetails\",\n              \"description\": \"Additional information about a network token.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"NetworkTokenOriginDetailsInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"NetworkTokenOrigin\",\n          \"description\": \"The source of the network token.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"APPLE_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GOOGLE_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NETWORK_TOKEN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"NetworkTokenOriginDetails\",\n          \"description\": \"Additional information about the payment method specific to Network Token.\",\n          \"fields\": [\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN may differ from the BIN of the customer's actual card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"NetworkTokenOriginDetailsInput\",\n          \"description\": \"Information about the network token, such as the origin of the network token, source card details, and other token requestor data.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"origin\",\n              \"description\": \"The origin of the network token.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"NetworkTokenOrigin\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"sourceCardDescription\",\n              \"description\": \"A string, suitable for display, that describes the backing card.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"sourceCardLast4\",\n              \"description\": \"The last 4 digits of the backing card number (FPAN).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CreditCardLast4\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"sourceCardType\",\n              \"description\": \"The card type of the backing card.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CreditCardBrandCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tokenRequestorId\",\n              \"description\": \"The token requestor ID of the entity that generated this network token.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"The transaction ID for this network token.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INTERFACE\",\n          \"name\": \"Node\",\n          \"description\": \"Relay compatible Node interface.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Global ID for a given object.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"BusinessAccountCreationRequest\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CustomActionsPaymentContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Customer\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Dispute\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"InStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"LocalPaymentContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"PaymentMethod\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Refund\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestChargeInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestConfirmationPromptInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestDisplayInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestFirmwareUpdateInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestRefundInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestSignaturePromptInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"RequestVaultInStoreContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Transaction\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Verification\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"NonInstantLocalPaymentContextInput\",\n          \"description\": \"Input fields for non-instant local payment context.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The order id of the eventual Braintree transaction and the invoice number of the local payment context. Maximum 127 characters.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the local payment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"MonetaryAmountInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"The type of the non-instant local payment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"NonInstantLocalPaymentMethodType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The country code of the local payment. For local payments supported in multiple countries, this value may determine which banks are presented to the customer.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locale\",\n              \"description\": \"The language tag for the language in which to localize the error-related strings.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Language\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"returnUrl\",\n              \"description\": \"The URL where the customer is redirected after the customer approves the payment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cancelUrl\",\n              \"description\": \"The URL where the customer is redirected after the customer cancels the payment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the PayPal merchant account that will be used when charging this payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"payerInfo\",\n              \"description\": \"The payer's information.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"LocalPaymentPayerInfoInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expiryDate\",\n              \"description\": \"Overrides the default date at which the local payment context will expire. MULTIBANCO is not overridable.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"NonInstantLocalPaymentMethodType\",\n          \"description\": \"A value identifying the type of non-instant regional payment method.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BOLETOBANCARIO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MULTIBANCO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OXXO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"OAuthApplication\",\n          \"description\": \"Information about an OAuth Application.\",\n          \"fields\": [\n            {\n              \"name\": \"clientId\",\n              \"description\": \"The unique identifier of the OAuth application.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"The name of the OAuth application.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"OAuthTokenType\",\n          \"description\": \"OAuth access token type.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BEARER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"OwnerAddressType\",\n          \"description\": \"The owner's address type.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"HOME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MAILING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"OwnerIDType\",\n          \"description\": \"The type of identity number provided for the owner.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"SOCIAL_SECURITY_NUMBER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"OwnerPhoneType\",\n          \"description\": \"The owner's phone type.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"HOME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MOBILE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"OwnerPosition\",\n          \"description\": \"The position that the owner holds in the business.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BENEFICIAL_OWNER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CHAIRMAN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DIRECTOR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PARTNER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SECRETARY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TREASURER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"OwnerRole\",\n          \"description\": \"The role that the owner holds in the business.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BENEFICIAL_OWNER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SIGNIFICANT_RESPONSIBILITY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PageInfo\",\n          \"description\": \"The page information for a connection.\",\n          \"fields\": [\n            {\n              \"name\": \"hasNextPage\",\n              \"description\": \"Whether or not there is a next page available.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"hasPreviousPage\",\n              \"description\": \"Always false; backwards pagination is not supported. Present to comply with Relay specifications.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"startCursor\",\n              \"description\": \"The cursor for the first item in the connection page.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"endCursor\",\n              \"description\": \"The cursor for the last item in the connection page.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PairInStoreReaderInput\",\n          \"description\": \"Input fields for pairing an in store reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"userCode\",\n              \"description\": \"Code displayed on Reader during pairing.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"Inputs for Reader.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"InStoreReaderSetupInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ParentAuthorization\",\n          \"description\": \"An original authorization's relationship to all its partial capture transactions.\",\n          \"fields\": [\n            {\n              \"name\": \"childCaptures\",\n              \"description\": \"The captures on a partially captured authorization.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Transaction\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"totalAmountAuthorized\",\n              \"description\": \"The total amount authorized by this transaction. This amount will not change as this transaction is partially captured.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"PartialCaptureDetails\",\n          \"description\": \"A union of all possible relationships of transactions involved in partial captures. If the transaction has been partially captured, this links to all its partial capture children; if the transaction represents a partial capture attempt, this links to the original parent authorization.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"ChildCapture\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"ParentAuthorization\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PartialCaptureTransactionInput\",\n          \"description\": \"Top-level input fields for capturing outstanding funds authorized by a transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"ID of the original authorized transaction to be partially captured.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Input fields for the capture, with details that will define the resulting capture transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PartialCaptureTransactionOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PartialCaptureTransactionOptionsInput\",\n          \"description\": \"Input fields for the capture, with details that will define the resulting capture transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount to capture on the transaction against the parent authorization transaction. Must be greater than 0. You can perform multiple partial capture transactions as long as the cumulative amount of those transactions is less than or equal to the amount authorized by the parent transaction. You can't capture more than the authorized amount unless your industry and processor support settlement adjustment (capturing a certain percentage over the authorized amount); [contact us for assistance](https://help.braintreepayments.com?issue=TransactionProcessingQuestion).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"Discount amount that was included in the total transaction amount. Does not add to the total amount the payment method will be charged. This value can't be negative. Please note that this field is not on PayPal transactions.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"Line items for this transaction. Up to 249 line items may be specified.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"TransactionLineItemInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Additional information about the transaction. On PayPal transactions, this field maps to the PayPal invoice number. PayPal invoice numbers must be unique in your PayPal business account. Maximum 255 characters or 127 for PayPal transactions. If specified, this will update the existing order ID on the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"purchaseOrderNumber\",\n              \"description\": \"A purchase order identification value you associate with this transaction.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shipping\",\n              \"description\": \"Shipping information.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionShippingInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tax\",\n              \"description\": \"Tax information about the transaction.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionTaxInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase. If specified, this will update the existing descriptor on the transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionDescriptorInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PartialCaptureTransactionPayload\",\n          \"description\": \"Top-level output field from partially capturing a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"capture\",\n              \"description\": \"The transaction representing the partial capture.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalAccountDetails\",\n          \"description\": \"Details about a PayPal account.\",\n          \"fields\": [\n            {\n              \"name\": \"billingAgreementId\",\n              \"description\": \"The ID of the billing agreement for this PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address associated with the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"The shipping address associated with the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"The email address associated with the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"phone\",\n              \"description\": \"The primary phone number associated with the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payerId\",\n              \"description\": \"The PayPal ID of the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"The first name on the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"The last name on the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cobrandedCardLabel\",\n              \"description\": \"The label of the co-branded card used as a funding source.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"origin\",\n              \"description\": \"Additional information if the PayPal account was provided from a third-party origin, such as Apple Pay, Google Pay, or another digital wallet.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethodOrigin\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"limitedUseOrderId\",\n              \"description\": \"Limited use PayPal provided Order ID (starts with O-).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalAccountInput\",\n          \"description\": \"Input for identifying a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"payerId\",\n              \"description\": \"The unique PayPal ID of the PayPal account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"ENUM\",\n          \"name\" : \"PayPalBillingAgreementChargePattern\",\n          \"description\" : \"Expected business/pricing model for a billing agreement (Charge Patterns).\",\n          \"fields\" : null,\n          \"inputFields\" : null,\n          \"interfaces\" : null,\n          \"enumValues\" : [\n            {\n              \"name\" : \"DEFERRED\",\n              \"description\" : \"Pay after use, non-recurring post-paid, variable amount, irregular.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"IMMEDIATE\",\n              \"description\" : \"On-demand instant payments - non-recurring, pre-paid, variable amount.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"RECURRING_POSTPAID\",\n              \"description\" : \"Pay on a fixed date based on usage or consumption after the goods/service is delivered.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"RECURRING_PREPAID\",\n              \"description\" : \"Pay upfront fixed or variable amount on a fixed date before the goods/service is delivered.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"THRESHOLD_POSTPAID\",\n              \"description\" : \"Charge payer when the set amount is reached or monthly billing cycle, whichever comes first, after the goods/service is delivered.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"THRESHOLD_PREPAID\",\n              \"description\" : \"Charge payer when the set amount is reached or monthly billing cycle, whichever comes first, before the goods/service is delivered.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            }\n          ],\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"INPUT_OBJECT\",\n          \"name\" : \"PayPalBillingAgreementExperienceProfileInput\",\n          \"description\" : \"Controls the experience in a PayPal billing agreement approval flow.\",\n          \"fields\" : null,\n          \"inputFields\" : [\n            {\n              \"name\" : \"brandName\",\n              \"description\" : \"Merchant brand name to be displayed on the PayPal approval pages.\",\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"String\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"collectShippingAddress\",\n              \"description\" : \"Indicates whether a shipping address will be collected from the customer during the agreement approval flow.\",\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"Boolean\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"landingPageType\",\n              \"description\" : \"Specifies the PayPal page to display when a user lands on the PayPal site to complete the payment.\",\n              \"type\" : {\n                \"kind\" : \"ENUM\",\n                \"name\" : \"PayPalLandingPageType\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"locale\",\n              \"description\" : \"Locale of the PayPal payment approval experience.\",\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"Language\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"shippingAddressEditable\",\n              \"description\" : \"Indicates whether to enable user editing of the shipping address. Only applies when shipping address is provided by merchant.\",\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"Boolean\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"INPUT_OBJECT\",\n          \"name\" : \"PayPalBillingAgreementInput\",\n          \"description\" : \"Input fields for a PayPal account to be vaulted.\",\n          \"fields\" : null,\n          \"inputFields\" : [\n            {\n              \"name\" : \"billingAgreementToken\",\n              \"description\" : \"The Billing Agreement token.\",\n              \"type\" : {\n                \"kind\" : \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalConfiguration\",\n          \"description\": \"Configuration for PayPal.\",\n          \"fields\": [\n            {\n              \"name\": \"displayName\",\n              \"description\": \"The merchant's company name for displaying to customers in the PayPal UI.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"clientId\",\n              \"description\": \"The merchant's PayPal client ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"privacyUrl\",\n              \"description\": \"The merchant's privacy policy URL.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"userAgreementUrl\",\n              \"description\": \"The merchant's user agreement URL.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"assetsUrl\",\n              \"description\": \"A URL pointing to the base path of Braintree's web pages used for various browser switches and popups.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"environment\",\n              \"description\": \"The PayPal environment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PayPalEnvironment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"environmentNoNetwork\",\n              \"description\": \"For internal use only.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is only included for internal testing purposes.\"\n            },\n            {\n              \"name\": \"unvettedMerchant\",\n              \"description\": \"Whether or not the merchant has been vetted.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"braintreeClientId\",\n              \"description\": \"Braintree's PayPal client ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAgreementsEnabled\",\n              \"description\": \"Whether billing agreements are enabled and should be used instead of future payments.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The merchant account being used. This affects the currency code and other options.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The currency code to use.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payeeEmail\",\n              \"description\": \"The email address of the PayPal account that will receive the funds when a transaction is created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"directBaseUrl\",\n              \"description\": \"For internal use only.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field is only included for internal testing purposes.\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalEnvironment\",\n          \"description\": \"The environment being used for PayPal.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CUSTOM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LIVE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OFFLINE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"custom\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"live\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"offline\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalExperienceProfileInput\",\n          \"description\": \"Controls the experience in a PayPal approval flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"brandName\",\n              \"description\": \"Merchant brand name to be displayed on the PayPal approval pages.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"collectShippingAddress\",\n              \"description\": \"Indicates whether a shipping address will be collected from the customer during the agreement approval flow.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"landingPageType\",\n              \"description\": \"Specifies the PayPal page to display when a user lands on the PayPal site to complete the payment.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PayPalLandingPageType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locale\",\n              \"description\": \"Locale of the PayPal payment approval experience.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Language\",\n                \"ofType\": null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"shippingAddressEditable\",\n              \"description\" : \"Indicates whether to enable user editing of the shipping address. Only applies when shipping address is provided by merchant.\",\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"Boolean\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"userAction\",\n              \"description\" : \"Presents the customer with either the Continue or Pay Now (COMMIT) checkout flow. Default is Continue flow if the field is not provided.\",\n              \"type\" : {\n                \"kind\" : \"ENUM\",\n                \"name\" : \"PayPalUserAction\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalFinancingCreditProductIdentifier\",\n          \"description\": \"Possible identifiers for credit products provided via PayPal.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CREDIT_CARD_INSTALLMENTS_BR\",\n              \"description\": \"Brazil Credit Card Installments.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_INSTALLMENTS_MX\",\n              \"description\": \"Mexico Credit Card Installments.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_US\",\n              \"description\": \"United States Credit Card.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYPAL_CREDIT_DE\",\n              \"description\": \"Germany PayPal Credit.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYPAL_CREDIT_UK\",\n              \"description\": \"United Kingdom PayPal Credit.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYPAL_CREDIT_US\",\n              \"description\": \"United States PayPal Credit.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_LATER_FR\",\n              \"description\": \"France Pay Later.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_LATER_GB\",\n              \"description\": \"Great Britain Pay Later.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_LATER_US\",\n              \"description\": \"United States Pay Later.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_UPON_INVOICE_DE\",\n              \"description\": \"Germany Pay Upon Invoice.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalFinancingOption\",\n          \"description\": \"PayPal financing options available for a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"creditProductIdentifier\",\n              \"description\": \"The credit product identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PayPalFinancingCreditProductIdentifier\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"qualifyingFinancingOptions\",\n              \"description\": \"Financing options the transaction qualifies for.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"PayPalQualifyingFinancingOption\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalFinancingOptionCreditType\",\n          \"description\": \"PayPal Financing option credit type.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"INSTALLMENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NO_INTEREST\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_UPON_INVOICE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SAME_AS_CASH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalFinancingOptionsInput\",\n          \"description\": \"Input fields for requesting information about PayPal financing options.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing multi-use PayPal payment method to request financing options for.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The transaction currency and total amount to finance.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"MonetaryAmountInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The financing country code.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CountryCodeAlpha2\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalFinancingOptionsPayload\",\n          \"description\": \"PayPal financing options response payload.\",\n          \"fields\": [\n            {\n              \"name\": \"financingOptions\",\n              \"description\": \"PayPal financing options.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"PayPalFinancingOption\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalIntent\",\n          \"description\": \"The intent for PayPal payments.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AUTHORIZE\",\n              \"description\": \"Merchant will authorize the payment, but the funds will be captured separately.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ORDER\",\n              \"description\": \"Merchant will create a PayPal Order. This validates the transaction without an authorization (i.e. without holding funds). Useful for authorizing and capturing funds up to 90 days after the order has been placed.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SALE\",\n              \"description\": \"Merchant will authorize and captures funds simultaneously.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalLandingPageType\",\n          \"description\": \"The type of landing page to display on the PayPal site for user checkout.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BILLING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEFAULT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOGIN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalLineItemInput\",\n          \"description\": \"Line items for a PayPal payment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"Item name. Maximum 127 characters.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"quantity\",\n              \"description\": \"Number of units of the item purchased. This value can't be negative or zero.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Int\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"unitAmount\",\n              \"description\": \"Per-unit price of the item. Can include up to 2 decimal places. This value can't be negative or zero.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"Indicates whether the line item is a debit (sale) or credit (refund or discount) to the customer.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"TransactionLineItemType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Item description. Maximum 127 characters.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"productCode\",\n              \"description\": \"Product or UPC code for the item. Maximum 127 characters.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"unitTaxAmount\",\n              \"description\": \"Per-unit tax price of the item. Can include up to 2 decimal places. This value can't be negative or zero.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"url\",\n              \"description\": \"The URL to product information.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"URL\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalLocalPaymentOriginDetails\",\n          \"description\": \"Additional information about the local payment method specific to PayPal.\",\n          \"fields\": [\n            {\n              \"name\": \"captureId\",\n              \"description\": \"If funds for the transaction have settled, the PayPal ID for the capture of funds.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customField\",\n              \"description\": \"A string of field/value pairs passed directly to PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"The identification value of the payment within PayPal's API.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionFee\",\n              \"description\": \"The fee charged by PayPal for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalLocalPaymentRefundDetails\",\n          \"description\": \"PayPal local payment specific refund details.\",\n          \"fields\": [\n            {\n              \"name\": \"refundId\",\n              \"description\": \"The PayPal refund ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundedFee\",\n              \"description\": \"Refunded transaction fee charged by PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalOneTimePaymentInput\",\n          \"description\": \"Input fields for a PayPal account for a One-Time payment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"payerId\",\n              \"description\": \"The PayPal payer ID.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"The PayPal payment ID. This ID is prefixed with \\\"PAYID-\\\".\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentToken\",\n              \"description\": \"The PayPal payment token, also known as an Express Checkout token. This token is prefixed with \\\"EC-\\\".\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalPayeeOptionsInput\",\n          \"description\": \"Input fields for a PayPal account receiving transaction funds.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"email\",\n              \"description\": \"The email address associated with the payee PayPal account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"INPUT_OBJECT\",\n          \"name\" : \"PayPalProductAttributesInput\",\n          \"description\" : \"Product attributes input for PayPal billing agreement.\",\n          \"fields\" : null,\n          \"inputFields\" : [\n            {\n              \"name\" : \"paypalBillingAgreementChargePattern\",\n              \"description\" : \"Expected business/pricing model for a billing agreement (Charge Patterns).\",\n              \"type\" : {\n                \"kind\" : \"ENUM\",\n                \"name\" : \"PayPalBillingAgreementChargePattern\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"OBJECT\",\n          \"name\" : \"PayPalQualifyingFinancingOption\",\n          \"description\" : \"PayPal qualifying financing options for a product.\",\n          \"fields\" : [\n            {\n              \"name\" : \"apr\",\n              \"description\" : \"APR percentage.\",\n              \"args\" : [],\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"nominalRate\",\n              \"description\": \"Nominal rate percentage.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"term\",\n              \"description\": \"Total number of payments over which to finance the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"intervalDuration\",\n              \"description\": \"The duration between each interval or payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Duration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"The country or region for the financing option.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CountryCodeAlpha2\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"creditType\",\n              \"description\": \"Credit type.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PayPalFinancingOptionCreditType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"minimumAmount\",\n              \"description\": \"The minimum qualifying amount for a transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"monthlyInterestRate\",\n              \"description\": \"The monthly interest rate for this financing option.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"periodicPayment\",\n              \"description\": \"The amount for transaction periodic payments.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"monthlyPayment\",\n              \"description\": \"The amount for transaction monthly payments.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"The discount amount on the transaction for this financing option.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discountPercentage\",\n              \"description\": \"The discount percentage for this financing option.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"totalInterest\",\n              \"description\": \"The total interest cost for this financing option.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"totalCost\",\n              \"description\": \"The total amount for the transaction, including interest.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paypalSubsidized\",\n              \"description\": \"Indicates whether the financing option's credit fee is funded by PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalRefundDetails\",\n          \"description\": \"PayPal-specific refund details.\",\n          \"fields\": [\n            {\n              \"name\": \"refundId\",\n              \"description\": \"The PayPal refund ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundedFee\",\n              \"description\": \"Refunded transaction fee charged by PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"The description of this refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reason\",\n              \"description\": \"The reason this refund was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalRetailAppUsedForScanning\",\n          \"description\": \"The app used to scan an in-store QR code.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VENMO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PayPalShippingOptionInput\",\n          \"description\": \"A shipping option for a PayPal One-Time payment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The cost for this shipping option.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"MonetaryAmountInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID that identifies a shipping option.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"The shipping option description. Localize this description to the payer's locale. For example, `Free Shipping`, `USPS Priority Shipping`, `Expédition prioritaire USPS`, or `USPS yōuxiān fā huò`.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"selected\",\n              \"description\": \"Indicates which shipping option is selected by default when the payer views the shipping options within the PayPal checkout experience. Only one shipping option can be selected at a time.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"The method by which the payer wants to receive their items.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"PayPalShippingOptionType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PayPalShippingOptionType\",\n          \"description\": \"The method by which the payer wants to receive their items.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"PICKUP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHIPPING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalTransactionDetails\",\n          \"description\": \"PayPal-specific details on a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"authorizationId\",\n              \"description\": \"If the transaction was successfully authorized, the PayPal ID for the authorization.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"captureId\",\n              \"description\": \"If funds for the transaction have settled, the PayPal ID for the capture of funds.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customField\",\n              \"description\": \"A string of field/value pairs passed directly to PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payer\",\n              \"description\": \"Details about the payer or owner of the PayPal account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalAccountDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payee\",\n              \"description\": \"Details about the PayPal account that received the funds.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalAccountDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payerStatus\",\n              \"description\": \"Whether or not the PayPal account has been verified by PayPal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"The identification value of the payment within PayPal's API.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundId\",\n              \"description\": \"If the transaction is a refund, the PayPal refund ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This field will never be populated as it only appears on refunds. Use `details.paypalId` on a refund instead.\"\n            },\n            {\n              \"name\": \"sellerProtectionStatus\",\n              \"description\": \"Whether or not the transaction qualifies for PayPal Seller Protection.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"taxId\",\n              \"description\": \"Payer's tax ID. Only returned for payments from Brazilian accounts.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"taxIdType\",\n              \"description\": \"Payer's tax ID type. Only returned for payments from Brazilian accounts. Allowed values BR_CPF or BR_CNPJ.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionFee\",\n              \"description\": \"The fee charged by PayPal for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionFeeAmount\",\n              \"description\": \"The fee charged by PayPal for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `transactionFee.value` instead.\"\n            },\n            {\n              \"name\": \"transactionFeeCurrencyIsoCode\",\n              \"description\": \"The currency code for the currency of the PayPal transaction fee.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `transactionFee.currencyCode` instead.\"\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Description of the transaction that is displayed to customers in PayPal email receipts.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"origin\",\n              \"description\": \"Additional information if the credit card was provided from a third-party origin, such as Google Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethodOrigin\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"selectedFinancingOption\",\n              \"description\": \"Buyer selected financing option at the time of creating a transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"SelectedPayPalFinancingOptionDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"appUsedForScanning\",\n              \"description\": \"The application used by the payer to scan the QR code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PayPalRetailAppUsedForScanning\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PayPalTransactionPayload\",\n          \"description\": \"Top-level output field from creating a PayPal transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"The transaction representing the charge on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAgreementWithPurchasePaymentMethod\",\n              \"description\": \"If the paymentMethodId passed to this mutation was a single-use PayPal payment method created with the [Billing Agreement with Purchase flow](https://developers.braintreepayments.com/guides/paypal/checkout-with-paypal/javascript/v3#checkout-using-paypal-billing-agreement-with-purchase-flow), then this field will be populated with a multi-use PayPal payment method created alongside the transaction. Otherwise, this will be null.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\" : null\n              },\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            }\n          ],\n          \"inputFields\" : null,\n          \"interfaces\" : [],\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"ENUM\",\n          \"name\" : \"PayPalUserAction\",\n          \"description\" : \"PayPal User action type.\",\n          \"fields\" : null,\n          \"inputFields\" : null,\n          \"interfaces\" : null,\n          \"enumValues\" : [\n            {\n              \"name\" : \"COMMIT\",\n              \"description\" : null,\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"CONTINUE\",\n              \"description\" : null,\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            }\n          ],\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"INTERFACE\",\n          \"name\" : \"Payment\",\n          \"description\" : \"A merchant-initiated movement of money between the merchant and a customer, by way of a payment method. Payments can represent money moving either from a customer to the merchant by charging a payment method (a Transaction), or from the merchant back to a customer by refunding a previous transaction (a Refund).\",\n          \"fields\" : [\n            {\n              \"name\" : \"id\",\n              \"description\" : \"Unique identifier.\",\n              \"args\" : [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the payment was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount charged or credited to the payment method. Note that in the case of a Transaction, this amount will represent the amount moving from the customer to the merchant, and in the case of a Refund, will represent the amount moving from the merchant back to the customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The order ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The current status of this payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"statusHistory\",\n              \"description\": \"The records of all statuses this payment has passed through, with additional information on why each status occurred. Returned in reverse chronological order, with the most recent event first in the list.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INTERFACE\",\n                    \"name\": \"PaymentStatusEvent\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The ID of the merchant account that processed this payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"How the payment was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshot\",\n              \"description\": \"Snapshot of payment method details used to create the payment, preserved at the time the transaction was created. This will always be present.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PaymentMethodSnapshot\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Refund\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Transaction\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentConnection\",\n          \"description\": \"A paginated list of transactions and refunds.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of transactions and refunds.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PaymentConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of transactions and refunds contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentConnectionEdge\",\n          \"description\": \"A transaction or refund within a PaymentConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This transaction or refund's location within the PaymentConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The transaction or refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"INTERFACE\",\n                \"name\": \"Payment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INTERFACE\",\n          \"name\": \"PaymentContext\",\n          \"description\": \"Context associated with a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the payment context was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Timestamp\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"updatedAt\",\n              \"description\": \"Date and time when the payment context was updated.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Timestamp\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CustomActionsPaymentContext\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"LocalPaymentContext\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentInitiator\",\n          \"description\": \"The initiator of the payment. Payment can either be merchant-initiated or customer-initiated.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"MOTO\",\n              \"description\": \"Transactions that are initiated by the customer via the merchant by mail or telephone.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING\",\n              \"description\": \"Transactions that are initiated by the merchant for subsequent recurring payments (e.g. subscriptions with a fixed amount on a predefined schedule).\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING_FIRST\",\n              \"description\": \"Transactions initiated by the customer that represent the first in a series of recurring payments or subscription.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNSCHEDULED\",\n              \"description\": \"Transactions that are initiated by the merchant for unscheduled payments that are not recurring on a predefined schedule or amount (e.g. balance top-up).\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentLevelFeeReport\",\n          \"description\": \"The [payment-level fee report (formerly known as the transaction-level fee report)](https://articles.braintreepayments.com/control-panel/reporting/transaction-level-fee-report) provides a breakdown of fees per individual payments (encompassing transactions and refunds).\",\n          \"fields\": [\n            {\n              \"name\": \"url\",\n              \"description\": \"The URL where the generated report is stored. Download the report from this URL.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentMethod\",\n          \"description\": \"Top-level field representing a payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier. May be the same as ID for single-use payment methods.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"usage\",\n              \"description\": \"Whether a payment method may be used only once or multiple times.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentMethodUsage\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the payment method was vaulted.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"details\",\n              \"description\": \"Details about the payment method specific to the type (e.g. credit card, PayPal account).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PaymentMethodDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verifications\",\n              \"description\": \"A paginated list of verifications that have been run against the payment method.\",\n              \"args\": [\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VerificationConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"The customer that the payment method belongs to.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Customer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentMethodConnection\",\n          \"description\": \"A paginated list of payment methods.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of payment methods.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PaymentMethodConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of payment methods contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentMethodConnectionEdge\",\n          \"description\": \"A payment method within a PaymentMethodConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This payment method's location within the PaymentMethodConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentMethodDeletionInitiator\",\n          \"description\": \"Initiator of a payment method delete request.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CUSTOMER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MERCHANT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"PaymentMethodDetails\",\n          \"description\": \"A union of all possible payment method details. PaymentMethodDetails contain information for display purposes, payment method management, and processing.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CustomActionsPaymentMethodDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CreditCardDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"PayPalAccountDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SamsungPayCardDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"VenmoAccountDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"UsBankAccountDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SEPADirectDebitAccountDetails\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentMethodOrigin\",\n          \"description\": \"Information about how the customer provided a payment method, such as via a digital wallet.\",\n          \"fields\": [\n            {\n              \"name\": \"type\",\n              \"description\": \"An enum identifying the origin of the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentMethodOriginType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"details\",\n              \"description\": \"When available, additional details specific to the origin.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PaymentMethodOriginDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"PaymentMethodOriginDetails\",\n          \"description\": \"A union of all possible payment method origin details. PaymentMethodOriginDetails contain additional information specific to the third party the payment method was provided by.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"ApplePayOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"GooglePayOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"MasterpassOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"NetworkTokenOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SamsungPayOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"VisaCheckoutOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"PayPalLocalPaymentOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CardPresentOriginDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"EmvCardOriginDetails\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentMethodOriginType\",\n          \"description\": \"A value identifying the third-party origin from which a customer provided their payment method.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"APPLE_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GOOGLE_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IN_STORE_READER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MASTERPASS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NETWORK_TOKEN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SAMSUNG_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VISA_CHECKOUT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"PaymentMethodSnapshot\",\n          \"description\": \"A union of all possible payment method details as they were used in a transaction or verification. PaymentMethodSnapshot preserves values used to create a given transaction or verify a payment method at that moment in time.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CustomActionsPaymentMethodDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CreditCardDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"PayPalTransactionDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"VenmoAccountDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"UsBankAccountDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"LocalPaymentDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CreditCardTransactionDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SEPADirectDebitTransactionDetails\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentMethodSnapshotSearchType\",\n          \"description\": \"A value identifying the type of payment method used for a transaction. For certain payment methods such as credit cards, this value also encodes the origin from which a customer provided that payment method.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ALIPAY_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BANCONTACT_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BLIK_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BOLETOBANCARIO_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_VIA_APPLE_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_VIA_GOOGLE_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_VIA_MASTERPASS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_VIA_NETWORK_TOKEN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_VIA_SAMSUNG_PAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CREDIT_CARD_VIA_VISA_CHECKOUT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"EPS_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GIROPAY_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GRABPAY_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IDEAL_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOCAL_PAYMENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MULTIBANCO_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MYBANK_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OXXO_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"P24_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYU_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAY_UPON_INVOICE_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SATISPAY_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SEPA_DIRECT_DEBIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SEPA_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SOFORT_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SWISH_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRUSTLY_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"US_BANK_ACCOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VENMO_ACCOUNT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VERKKOPANKKI_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VIPPS_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WECHAT_PAY_VIA_PAYPAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentMethodUsage\",\n          \"description\": \"Possible usages for payment methods.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"MULTI_USE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SINGLE_USE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PaymentMethodVerificationOptionsInput\",\n          \"description\": \"Input fields that specify options for verifying the vaulted payment method. Only applicable for payment method types that suport verification.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account to use when verifying the payment method. The verification will use the default merchant account if this field is left blank.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"skip\",\n              \"description\": \"Whether to opt out of verifying the payment method. Defaults to `false` for payment methods that support verification. Clients should only pass `true` in the uncommon scenario that the payment method has been verified externally to Braintree.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PaymentNetworkResponse\",\n          \"description\": \"The network response.  When present, this field can provide additional detail about why an authorization or verification was declined, but the processorResponse should be considered the source of truth.\",\n          \"fields\": [\n            {\n              \"name\": \"code\",\n              \"description\": \"The network response code for [authorizations](https://developers.braintreepayments.com/reference/response/transaction/#network-response-codes) or [verifications](https://developers.braintreepayments.com/reference/response/credit-card-verification#network-response-codes).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"message\",\n              \"description\": \"The network response text.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentReaderInputMode\",\n          \"description\": \"The input mode used on the payment reader to facilitate an in-store transaction.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CONTACT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CONTACTLESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MAGSTRIPE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MAGSTRIPE_FALLBACK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MANUAL_KEY_ENTRY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VAULT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PaymentSearchInput\",\n          \"description\": \"Input fields for searching for any type of Payment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find payments with an ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"Find payments by their type. Use this field to search for payments by the direction of money movement.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentTypeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Find payments with a given status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentStatusInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"statusTransition\",\n              \"description\": \"Find payments based on the time at which they transitioned to a given status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentStatusTransitionInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Find payments based on the time they were created.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Find payments for a given amount or currency.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"MonetaryAmountSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Find payments with a given orderId.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Find payments processed through a merchant account ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Find payments with a given customer.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentCustomerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disbursementDate\",\n              \"description\": \"Find payments by their disbursement date. Only use this search criteria if you have an eligible merchant account. Note that payments can only be disbursed after they reach the SETTLED status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"Find payments created with a given source.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentSourceInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"settlementBatchId\",\n              \"description\": \"Find payments by the batch ID under which the payment was submitted for settlement.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"Find payments based on information about the payment method used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentPaymentMethodInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"facilitatorOAuthApplicationClientId\",\n              \"description\": \"Find payments created by a third party via the Grant API using a given OAuth application client ID.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"userId\",\n              \"description\": \"Find payments with a user ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"storeId\",\n              \"description\": \"Find payments by the ID of the store that the transaction was processed in.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentSearchType\",\n          \"description\": \"The type of a Payment, based primarily on implementing type.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"DETACHED_REFUND\",\n              \"description\": \"Only use this field if you have processed [detached credits](https://articles.braintreepayments.com/control-panel/transactions/refunds-voids-credits#detached-credits). The payment is a Refund, and represents a refund of a transaction not processed through your Braintree account.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REFUND\",\n              \"description\": \"The payment is a Refund, and represents a refund of a transaction present in this Braintree account. Unless you have processed any [detached credits](https://articles.braintreepayments.com/control-panel/transactions/refunds-voids-credits#detached-credits), this type encompasses all refunds.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRANSACTION\",\n              \"description\": \"The payment is a Transaction.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentSource\",\n          \"description\": \"The origin of a request that created or changed a transaction or refund.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"API\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CONTROL_PANEL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYMENT_READER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNKNOWN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"PaymentStatus\",\n          \"description\": \"The status of the payment, indicating its success or failure, and where it is in its [lifecycle](https://articles.braintreepayments.com/get-started/transaction-lifecycle). For further details on why any given status occurred, consult the corresponding `PaymentStatusEvent` in the payment's `statusHistory`.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AUTHORIZATION_EXPIRED\",\n              \"description\": \"The transaction spent too much time in the `AUTHORIZED` status and was marked as expired. Expiration [time frames](https://developers.braintreepayments.com/reference/general/statuses#authorization-expired) differ by card type, transaction type, and, in some cases, merchant category.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHORIZED\",\n              \"description\": \"The processor authorized the transaction, putting your customer's funds on hold. Your customer may see a pending charge on his or her account. However, before the customer is actually charged and before you receive the funds, you must use the `captureTransaction` mutation. If you do not want to capture the transaction, you should use the `reverseTransaction` mutation to avoid a misuse of authorization fee.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHORIZING\",\n              \"description\": \"If a payment remains in a status of `AUTHORIZING`, [contact us for assistance](https://help.braintreepayments.com?issue=TransactionProcessingQuestion).\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FAILED\",\n              \"description\": \"An error occurred when sending the payment to the downstream processor. See the payment's `statusHistory` for the exact error.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GATEWAY_REJECTED\",\n              \"description\": \"The transaction was [rejected](https://articles.braintreepayments.com/control-panel/transactions/gateway-rejections) based on one or more settings or rules in your Braintree gateway. See the transaction's `statusHistory` to determine which resulted in the decline.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROCESSOR_DECLINED\",\n              \"description\": \"The processor declined the transaction while attempting to authorize it. See the transaction's `statusHistory` to determine what reason the processor gave for the decline.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SETTLED\",\n              \"description\": \"The payment has been settled. For transactions, this means your customer has been charged and the process of disbursing the funds to your bank account has begun. For refunds, it means that the process of disbursing funds back to the customer has begun.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SETTLEMENT_CONFIRMED\",\n              \"description\": \"The transaction was captured partially and will not be submitted to processor for settling. Its child transaction(s) has been successfully captured and will be included in the next settlement batch.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SETTLEMENT_DECLINED\",\n              \"description\": \"The processor declined the payment while attempting to capture it. See the payment's `statusHistory` to determine why it wasn't settled. This status is rare, and only certain types of transactions can be affected.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SETTLEMENT_PENDING\",\n              \"description\": \"The transaction has not yet fully settled. This status is rare, and will generally resolve to a status of `SETTLED`. Only certain types of transactions can be affected.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SETTLING\",\n              \"description\": \"The payment is in the process of being settled. This is a transitory state, and will resolve to a status of `SETTLED`.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUBMITTED_FOR_SETTLEMENT\",\n              \"description\": \"The payment has been successfully captured, and will be included in the next settlement batch, at which time it will become settled.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VOIDED\",\n              \"description\": \"The payment has been voided or canceled. For transactions, this means it's no longer authorized, your customer's funds are no longer on hold, and you can't use the `captureTransaction` mutation on this transaction. For refunds, it means the customer will not receive the funds from the refund.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INTERFACE\",\n          \"name\": \"PaymentStatusEvent\",\n          \"description\": \"Status event in the [lifecycle of a payment](https://articles.braintreepayments.com/get-started/transaction-lifecycle).\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"New status of the payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the status event occurred.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The payment amount applicable to the status. For instance, the amount when a transaction is `SUBMITTED_FOR_SETTLEMENT` might be less than the amount for which it was `AUTHORIZED`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"Source that caused the status event to occur.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether this is the final state for the payment. If false, this transaction will pass into another subsequent state.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"AuthorizationExpiredEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"AuthorizedEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"FailedEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"GatewayRejectedEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"ProcessorDeclinedEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SettledEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SettlementConfirmedEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SettlementDeclinedEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SettlementPendingEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SettlingEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SubmittedForSettlementEvent\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"VoidedEvent\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Percentage\",\n          \"description\": \"The percentage, as a fixed-point, signed decimal number. For example, define a 19.99% interest rate as `19.99`.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PerformThreeDSecureLookupInput\",\n          \"description\": \"Top-level fields for performing a 3D Secure Lookup.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account that will be used when charging the payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"dfReferenceId\",\n              \"description\": \"Reference ID used by our MPI provider CardinalCommerce to connect the lookup request to the device data that was previously collected.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of a payment method to perform the lookup on.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount you plan to charge the payment method after the 3D Secure authentication.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionInformation\",\n              \"description\": \"Additional information about the transaction when authenticating through 3D Secure.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecureLookupTransactionInformationInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cardholderInformation\",\n              \"description\": \"Additional information about the cardholder when authenticating through 3D Secure.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecureLookupCardholderInformationInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"requestAuthenticationChallenge\",\n              \"description\": \"When set to true, requests a 3D Secure authentication challenge from the issuer. A challenge will result in the acsUrl field being populated on the response, requiring you to open the challenge on the client side.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"clientInformation\",\n              \"description\": \"Information about the client-side lookup process.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecureLookupClientInformationInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"dataOnlyRequested\",\n              \"description\": \"When set to true, the data-only 3D Secure call will be created. The status of [DATA_ONLY_SUCCESSFUL](https://developers.braintreepayments.com/guides/3d-secure/advanced-options#using-data-only-3d-secure) will be returned as `ThreeDSecureAuthenticationStatus` for a successful response.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cardAdd\",\n              \"description\": \"If set to true, a card-add challenge will be requested from the issuer to confirm adding new card to the merchant's vault. This flag should only be used when adding a card to a merchant’s vault and not for creating transactions.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"PerformThreeDSecureLookupPayload\",\n          \"description\": \"Top-level fields returned when performing a 3D Secure Lookup.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"threeDSecureLookupData\",\n              \"description\": \"Data fields containing information from the MPI provider about the 3D Secure Lookup result.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ThreeDSecureLookupData\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"PhoneInput\",\n          \"description\": \"The phone number in its international [E.164 numbering plan format](https://www.itu.int/rec/T-REC-E.164/en).\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"countryPhoneCode\",\n              \"description\": \"The country calling code (CC), in its canonical international [E.164 numbering\\nplan format](https://www.itu.int/rec/T-REC-E.164/en). The combined length of\\nthe CC and the national number must not be greater than 15 digits. The\\nnational number consists of a national destination code (NDC) and subscriber number (SN).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The phone number, in its canonical international [E.164 numbering plan\\nformat](https://www.itu.int/rec/T-REC-E.164/en). The combined length of the\\ncountry calling code (CC) and the national number must not be greater than 15\\ndigits. The national number consists of a national destination code (NDC) and\\nsubscriber number (SN).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"extensionNumber\",\n              \"description\": \"The extension number.\",\n              \"type\": {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"String\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"ENUM\",\n          \"name\" : \"PreDisputeProgram\",\n          \"description\" : \"The pre-dispute program of the dispute.\",\n          \"fields\" : null,\n          \"inputFields\" : null,\n          \"interfaces\" : null,\n          \"enumValues\" : [\n            {\n              \"name\" : \"NONE\",\n              \"description\" : \"The dispute does not have a pre-dispute program.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"VISA_RDR\",\n              \"description\" : \"The dispute is part of the Visa Rapid Dispute Resolution (RDR) program.\",\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            }\n          ],\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"ENUM\",\n          \"name\" : \"ProcessorDeclineType\",\n          \"description\" : \"Whether the decline is likely to be temporary or persistent. Can be taken into consideration when determining whether to retry a declined charge.\",\n          \"fields\" : null,\n          \"inputFields\" : null,\n          \"interfaces\" : null,\n          \"enumValues\" : [\n            {\n              \"name\": \"HARD\",\n              \"description\": \"Hard declines are the result of an error or issue which can't be resolved immediately; the decline is not temporary and subsequent charges on the same payment method will likely not be successful.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SOFT\",\n              \"description\": \"Soft declines result from a temporary issue and can be retried; subsequent charges on the same payment method may be successful.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ProcessorDeclinedEvent\",\n          \"description\": \"Accompanying information for a processor declined transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction was declined by the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"declineType\",\n              \"description\": \"Whether or not the decline is the result of a temporary issue.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ProcessorDeclineType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response and why they declined the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionAuthorizationProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"networkResponse\",\n              \"description\": \"Fields describing the network response to the authorization request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentNetworkResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"riskDecision\",\n              \"description\": \"Risk decision for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"RiskDecision\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Query\",\n          \"description\": \"The top-level Query type. Queries are used to fetch data.\",\n          \"fields\": [\n            {\n              \"name\": \"ping\",\n              \"description\": \"Returns the literal string 'pong'.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pingInStoreReader\",\n              \"description\": \"Triggers a beep on a connected Reader and returns the Reader information or an error if unable to ping the device.\",\n              \"args\": [\n                {\n                  \"name\": \"readerId\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"SCALAR\",\n                      \"name\": \"ID\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"viewer\",\n              \"description\": \"The currently authenticated viewer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Viewer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"clientConfiguration\",\n              \"description\": \"The client-side environment and payment method configuration.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ClientConfiguration\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"Fetch any object that extends the Node interface using its ID.\",\n              \"args\": [\n                {\n                  \"name\": \"id\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"SCALAR\",\n                      \"name\": \"ID\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"INTERFACE\",\n                \"name\": \"Node\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"idFromLegacyId\",\n              \"description\": \"Get a GraphQL ID from a legacy ID that was returned from an SDK or a legacyId field. Does not verify existence except for payment methods.\",\n              \"args\": [\n                {\n                  \"name\": \"legacyId\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"SCALAR\",\n                      \"name\": \"ID\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"type\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"ENUM\",\n                      \"name\": \"LegacyIdType\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"report\",\n              \"description\": \"A collection of the available reports. Each field on the `Report` type is a different report that can be queried with its own input parameters.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Report\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"search\",\n              \"description\": \"A collection of the available searches. Each field on the `Search` type is a different search that can be queried with its own input parameters.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Search\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paypalFinancingOptions\",\n              \"description\": \"Retrieve PayPal financing options that include payment installment plans.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"PayPalFinancingOptionsInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PayPalFinancingOptionsPayload\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inStoreLocations\",\n              \"description\": \"Retrieve a paginated list of all in-store locations.\",\n              \"args\": [\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreLocationConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ReaderStatus\",\n          \"description\": \"Indicates the status of a Reader.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"OFFLINE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ONLINE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"RecurringType\",\n          \"description\": \"The type of recurring payment a transaction represents.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"FIRST\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUBSEQUENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNSCHEDULED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Refund\",\n          \"description\": \"A refund of a charge on a payment method, representing an attempt to send money from a previous transaction back to the customer.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the refund was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount that will be refunded, which can be less than or equal to the original charge amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The order ID for this refund. For PayPal transactions, the PayPal Invoice ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The current status of this refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"statusHistory\",\n              \"description\": \"The records of all statuses this refund has passed through, with additional information on why each status occurred. Returned in reverse chronological order, with the most recent event first in the list.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INTERFACE\",\n                    \"name\": \"PaymentStatusEvent\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"details\",\n              \"description\": \"Payment method specific details about the refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"RefundPaymentMethodDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The ID of the merchant account that processed this refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"How the refund was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundedTransaction\",\n              \"description\": \"The original transaction that this refunds. If this is not present, then this refund represents a refund of a transaction that does not belong to this Braintree gateway account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshot\",\n              \"description\": \"Snapshot of payment method details that will receive the refund, typically based on the original transaction. This will always be present. Equivalent to `refundedTransaction.paymentMethodSnapshot`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PaymentMethodSnapshot\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"The multi-use payment method that will receive the refund. Only present if a multi-use payment method was used to create the original transaction and it has not been since deleted. The details of this PaymentMethod may have changed since the transaction was created; details used for the transaction can be found in the `paymentMethodSnapshot` field. Equivalent to `refundedTransaction.paymentMethod` (if present).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"The customer that the vaulted payment method (if it exists) belongs to. Equivalent to `refundedTransaction.customer` (if present).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Customer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"Line items for this refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"TransactionLineItem\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs passed when creating the refund. Custom fields are [defined in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#store-and-pass-back-fields). For all refunds except \\\"detached refunds\\\", these will always be null.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"CustomField\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's statement (for instance, credit card or bank statement) for this refund. This will always match the descriptor from the refunded transaction (if present).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionDescriptor\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"disbursementDetails\",\n              \"description\" : \"The disbursement details associated with this refund. This field is only available after the refund is SETTLED and if you have an eligible merchant account.\",\n              \"args\" : [],\n              \"type\" : {\n                \"kind\" : \"OBJECT\",\n                \"name\" : \"DisbursementDetails\",\n                \"ofType\" : null\n              },\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            },\n            {\n              \"name\" : \"paymentInitiatedAt\",\n              \"description\" : \"The refund date and time as reported by the in-store payment terminal.\",\n              \"args\" : [],\n              \"type\" : {\n                \"kind\" : \"SCALAR\",\n                \"name\" : \"Timestamp\",\n                \"ofType\" : null\n              },\n              \"isDeprecated\" : false,\n              \"deprecationReason\" : null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Payment\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RefundConnection\",\n          \"description\": \"A paginated list of refunds.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of refunds.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"RefundConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of refunds contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RefundConnectionEdge\",\n          \"description\": \"A transaction within a RefundConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This refund's location within the RefundConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Refund\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RefundCreditCardInput\",\n          \"description\": \"Top-level input fields for creating a detached refund on a credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of the credit card to be refunded.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"Input fields containing details about the refund.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"DetachedRefundInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RefundCreditCardPayload\",\n          \"description\": \"Top-level output field from creating a detached refund for a credit card.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"The information about the created refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Refund\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RefundInput\",\n          \"description\": \"Specific input fields for describing a refund.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount to refund. Must be less than or equal to the amount of the original transaction. Defaults to the total amount of the original transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The refund's order ID. Defaults to the order ID of the original transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account that will be used when performing the refund.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Description of the refund that is displayed to customers in PayPal email receipts.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"reason\",\n              \"description\": \"Reason of the refund transaction. This field maps to the PayPal refund reason.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"Line items for this refund. Up to 249 line items may be specified.\\n\\nOnly allowed for Custom Actions transactions.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"TransactionLineItemInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"RefundPaymentMethodDetails\",\n          \"description\": \"A union of all possible payment method refund details.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"PayPalRefundDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"PayPalLocalPaymentRefundDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"SEPADirectDebitRefundDetails\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"RefundPolicy\",\n          \"description\": \"Supported refund policies.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"EXCHANGE_ONLY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NO_REFUND_OR_EXCHANGE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REFUND_CARDHOLDER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RefundSearchInput\",\n          \"description\": \"Input fields for searching for refunds.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find refunds with an ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Find refunds with the given status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTransactionStatusInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"statusTransition\",\n              \"description\": \"Find payments based on the time at which they transitioned to a given status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentStatusTransitionInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Find refunds based on the time they were created.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Find refunds with a given amount or currency.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"MonetaryAmountSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Find refunds with a given orderId.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Find refunds processed through a merchant account ID or IDs. In most cases, this will be the merchant account of the original refunded transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Find refunds with a given customer.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentCustomerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disbursementDate\",\n              \"description\": \"Find refunds by their disbursement date. Only use this search criteria if you have an eligible merchant account. Note that refunds can only be disbursed after they reach the SETTLED status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"Find refunds created with a given source.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentSourceInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"settlementBatchId\",\n              \"description\": \"Find refunds by the batch ID under which the refund was submitted for settlement.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"Find refunds based on information about the payment method used for the refund.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentPaymentMethodInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"facilitatorOAuthApplicationClientId\",\n              \"description\": \"Find refunds created by a third party via the Grant API using a given OAuth application client ID.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"userId\",\n              \"description\": \"Find refunds with a user ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"storeId\",\n              \"description\": \"Find refunds by the ID of the store that the transaction was processed in.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RefundTransactionInput\",\n          \"description\": \"Top-level input fields for refunding a transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"The ID of a transaction to be refunded.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"Input fields for the details of the refund.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"RefundInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RefundTransactionPayload\",\n          \"description\": \"Top-level output field from refunding a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"The information about the created refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Refund\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RefundUsBankAccountInput\",\n          \"description\": \"Top-level input fields for creating a detached refund on a US Bank Account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of the US Bank Account to be refunded.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields related to the US bank account being charged.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ChargeUsBankAccountOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"Input fields containing details about the refund.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"DetachedRefundInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RefundUsBankAccountPayload\",\n          \"description\": \"Top-level output field from creating a detached refund for a US Bank Account.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"The information about the created refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Refund\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Report\",\n          \"description\": \"Top-level fields returned for a report query.\",\n          \"fields\": [\n            {\n              \"name\": \"transactionLevelFees\",\n              \"description\": \"Top-level fields returned in the transaction-level fee report query.\",\n              \"args\": [\n                {\n                  \"name\": \"date\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"SCALAR\",\n                      \"name\": \"Date\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"merchantAccountId\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"ID\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionLevelFeeReport\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This report has been renamed `paymentLevelFees`, since it applies to all types in the Payment interface, including transactions and refunds. Use the `paymentLevelFees` field instead, which returns the same report.\"\n            },\n            {\n              \"name\": \"paymentLevelFees\",\n              \"description\": \"Top-level fields returned in the payment-level fee report query.\",\n              \"args\": [\n                {\n                  \"name\": \"date\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"SCALAR\",\n                      \"name\": \"Date\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"merchantAccountId\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"ID\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentLevelFeeReport\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestCancelFromInStoreReaderInput\",\n          \"description\": \"Input fields for requesting a cancel during an in-store charge flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"inStoreContextId\",\n              \"description\": \"Unique ID for the charge flow.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestChargeFromInStoreReaderInput\",\n          \"description\": \"Input fields for beginning the in-store charge flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to request a charge from.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"Information about the requested in-store transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"InStoreTransactionInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestChargeInStoreContext\",\n          \"description\": \"Reference object for an in-store charge request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this charge request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader from which the charge was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the charge was requested. A status of COMPLETE does not indicate a successful payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"The transaction representing the charge on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestConfirmationPromptFromInStoreReaderInput\",\n          \"description\": \"Input fields for requesting a confirmation prompt on an in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to request a confirmation prompt from.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"title\",\n              \"description\": \"Title to be displayed on the in-store reader. 50 character maximum.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"text\",\n              \"description\": \"Text to be displayed on the in-store reader. 65536 character maximum. '\\\\n' line breaks will be respected.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"alignment\",\n              \"description\": \"The way the text is aligned when displayed on an in-store reader. Defaults to CENTER.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ConfirmationPromptAlignment\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cancellationText\",\n              \"description\": \"Text for the cancellation option to be displayed on the in-store reader. 20 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"confirmationText\",\n              \"description\": \"Text for the confirmation option to be displayed on the in-store reader. 20 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestConfirmationPromptInStoreContext\",\n          \"description\": \"Reference object for an in-store reader confirmation prompt.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this confirmation prompt request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader from which the confirmation prompt was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the confirmation prompt was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"confirmed\",\n              \"description\": \"The confirmation response collected by the in-store reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestDisplayInStoreContext\",\n          \"description\": \"Reference object for an in-store display request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this display request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader from which the display was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the display was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestFirmwareUpdateFromInStoreReaderInput\",\n          \"description\": \"Input fields for requesting a firmware update for an in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"The in-store reader to receive the firmware update.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestFirmwareUpdateInStoreContext\",\n          \"description\": \"Reference object for an in-store reader firmware update.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this firmware update request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader for which the firmware update was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the firmware update was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestItemDisplayFromInStoreReaderInput\",\n          \"description\": \"Input fields for beginning the in-store display line items flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to display items on.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"displayItems\",\n              \"description\": \"Items to be displayed on the in-store reader. Up to 249 items may be specified.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"InStoreDisplayItemInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tax\",\n              \"description\": \"The total tax amount for the entire transaction, including all display line items.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The total amount for the entire transaction, including tax.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"The total discount amount for the entire transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestRefundFromInStoreReaderInput\",\n          \"description\": \"Input fields for beginning the in-store refund flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to request a refund from.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"Information about the requested in-store refund.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"InStoreRefundInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestRefundInStoreContext\",\n          \"description\": \"Reference object for an in-store refund request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this refund request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader from which the refund was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the refund was requested. A status of COMPLETE does not indicate a successful payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refund\",\n              \"description\": \"The refund representing the refund on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Refund\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestSignaturePromptFromInStoreReaderInput\",\n          \"description\": \"Input fields for requesting a signature prompt on an in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to request a signature prompt from.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"title\",\n              \"description\": \"Title to be displayed on the in-store reader. 50 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cancellationText\",\n              \"description\": \"Text for the cancellation option to be displayed on the in-store reader. 20 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"confirmationText\",\n              \"description\": \"Text for the confirmation option to be displayed on the in-store reader. 20 character maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestSignaturePromptInStoreContext\",\n          \"description\": \"Reference object for an in-store reader signature prompt.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this signature prompt request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader from which the signature prompt was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the signature prompt was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"signatureData\",\n              \"description\": \"The signature data collected by the in-store reader. Base64 encoded PNG image.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestTextDisplayFromInStoreReaderInput\",\n          \"description\": \"Input fields for beginning the in-store display text flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to request a text display from.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"text\",\n              \"description\": \"Text to be displayed on the in-store reader. 255 character maximum. '\\\\n' line breaks will be respected.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RequestVaultFromInStoreReaderInput\",\n          \"description\": \"Input fields for beginning the in-store charge flow.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"ID of the Reader to request a vault from.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"Input fields that specify options for verifying the payment method before vaulting. Only applicable if the payment method is of a type that supports verification. For supported types, verification is performed by default. If the verification fails, the payment method will not be vaulted.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PaymentMethodVerificationOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"ID of the customer to associate the resulting multi-use payment method with.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RequestVaultInStoreContext\",\n          \"description\": \"Reference object for an in-store vault request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"A unique ID for this vault request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reader\",\n              \"description\": \"The reader from which the vault was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReader\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The status of the context created when the vault was requested.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"InStoreContextStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A payment method that has been stored in a merchant's vault and can be reused.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"The verification that was run on the payment method prior to vaulting.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Verification\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"InStoreContextResult\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ReverseRefundInput\",\n          \"description\": \"Input fields for reversing a refund.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"refundId\",\n              \"description\": \"The ID of the refund to reverse.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ReverseTransactionInput\",\n          \"description\": \"Input fields for reversing a transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"The ID of the transaction to reverse.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ReverseTransactionPayload\",\n          \"description\": \"Top-level output field for reversing a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"reversal\",\n              \"description\": \"A transaction (if the original transaction was voided) or refund (if the original transaction was refunded). A reversal will attempt to void the original transaction if it has not yet settled. If the original transaction has settled, a reversal will create a refund for the full amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"TransactionReversal\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Right\",\n          \"description\": \"A right assigned to a user.\",\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"A human-readable name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"RiskData\",\n          \"description\": \"Data from advanced risk evaluations.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"decision\",\n              \"description\": \"The risk decision on whether the transaction should be permitted.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"RiskDecision\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"decisionReasons\",\n              \"description\": \"The reasons for the decision from the fraud service provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deviceDataCaptured\",\n              \"description\": \"Whether data associated with the customer's device was captured and used in the decision process.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"fraudServiceProvider\",\n              \"description\": \"The fraud service provider used to generate the risk decision.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"FraudServiceProvider\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"liabilityShift\",\n              \"description\": \"Liability Shift information in the event of a chargeback.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"LiabilityShift\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"score\",\n              \"description\": \"The numeric risk score assigned by the fraud service provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"RiskDataInput\",\n          \"description\": \"Input fields for data used by processors for risk analysis.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"customerBrowser\",\n              \"description\": \"The User-Agent header provided by the customer's browser, which gives information about the browser. Maximum 255 characters.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerIp\",\n              \"description\": \"The customer's IP address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"deviceData\",\n              \"description\": \"Customer device information. Required when creating transactions using cards (only if using Advanced Fraud Tools), PayPal (only for one-time Vaulted PayPal transactions), and Venmo payment method types. This value will contain a Fraud Merchant ID as the unique, numeric identifier for a Kount account and a Device Session ID as the unique identifier for a customer device. For PayPal and Venmo transactions, this value will also include a PayPal Correlation ID.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"RiskDecision\",\n          \"description\": \"The risk decision provides further context on how a transaction was scored for risk by Braintree.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"APPROVE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DECLINE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NOT_EVALUATED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REVIEW\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Role\",\n          \"description\": \"Groups of rights assigned to the user.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"A human-readable name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"isAccountAdmin\",\n              \"description\": \"Whether the role grants account admin status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"rights\",\n              \"description\": \"The rights associated with the role.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Right\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SEPADirectDebitAccountDetails\",\n          \"description\": \"Details about a SEPA Direct Debit account.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantOrPartnerCustomerId\",\n              \"description\": \"Merchant or Partner Customer ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"last4\",\n              \"description\": \"Last 4 characters of IBAN number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bankReferenceToken\",\n              \"description\": \"Bank reference token.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"mandateType\",\n              \"description\": \"Mandate type.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"MandateType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SEPADirectDebitRefundDetails\",\n          \"description\": \"Refund-related details for SEPA Direct Debit transactions.\",\n          \"fields\": [\n            {\n              \"name\": \"refundId\",\n              \"description\": \"The SEPA Direct Debit refund ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refundedFee\",\n              \"description\": \"Refunded transaction fee charged by SEPA Direct Debit.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"PayPal V2 OrderId.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SEPADirectDebitTransactionDetails\",\n          \"description\": \"Details about a SEPA Direct Debit account.\",\n          \"fields\": [\n            {\n              \"name\": \"captureId\",\n              \"description\": \"If funds for the transaction have settled, the PayPal ID for the capture of funds.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionFee\",\n              \"description\": \"The fee charged by PayPal for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"PayPal V2 OrderId.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SamsungPayCardDetails\",\n          \"description\": \"Details about a Samsung Pay card.\",\n          \"fields\": [\n            {\n              \"name\": \"brand\",\n              \"description\": \"The display name of the card brand, e.g. \\\"Visa\\\" or \\\"American Express\\\".\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"brandCode\",\n              \"description\": \"A static code identifying the card brand of the FPAN (the customer's actual backing card).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CreditCardBrandCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN will differ from the BIN of the source (customer's actual) card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"binData\",\n              \"description\": \"Information about the card based on its BIN. This BIN will differ from the BIN of the source (customer's actual) card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"BinRecord\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sourceCardLast4\",\n              \"description\": \"The last four digits of the FPAN (the customer's actual backing card).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CreditCardLast4\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SamsungPayCardInput\",\n          \"description\": \"Input fields for a Samsung Pay card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"cryptogram\",\n              \"description\": \"A one-time-use string generated by the token requester to validate the transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"eCommerceIndicator\",\n              \"description\": \"A two-digit string that should be passed along in the authorization message.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ECommerceIndicator\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationMonth\",\n              \"description\": \"A two-digit string representing the expiration month of the DPAN.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Month\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationYear\",\n              \"description\": \"A four-digit string representing the expiration year of the DPAN.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Year\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"number\",\n              \"description\": \"The card number provided by Samsung and used in processing. This is a digitized PAN (DPAN), not the backing card number (FPAN).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CreditCardNumber\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"sourceCardLast4\",\n              \"description\": \"The last four digits of the FPAN (the cardholder's backing card).\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CreditCardLast4\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SamsungPayConfiguration\",\n          \"description\": \"Configuration for Samsung Pay on Android.\",\n          \"fields\": [\n            {\n              \"name\": \"displayName\",\n              \"description\": \"A string used to identify the merchant to the customer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"environment\",\n              \"description\": \"The Samsung Pay environment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"SamsungPayEnvironment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"serviceId\",\n              \"description\": \"The Samsung Pay service ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"samsungAuthorization\",\n              \"description\": \"Authorization to use when tokenizing Samsung Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for Samsung Pay.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"SamsungPayEnvironment\",\n          \"description\": \"The environment being used for Samsung Pay.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"PRODUCTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SANDBOX\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SamsungPayOriginDetails\",\n          \"description\": \"Additional information about the payment method specific to Samsung Pay.\",\n          \"fields\": [\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN may differ from the BIN of the customer's actual card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SandboxSettleTransactionInput\",\n          \"description\": \"Top-level input fields for settling a transaction in the sandbox environment.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"Id of the transaction to force settlement in the sandbox environment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"settlementState\",\n              \"description\": \"The target settlement state for the transaction in the sandbox environment.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"SandboxSettlementState\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"SandboxSettlementState\",\n          \"description\": \"The settlement state when forcing transaction settlement in the sandbox environment.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"SETTLED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SETTLEMENT_DECLINED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ScaExemptionType\",\n          \"description\": \"The type of Strong Customer Authentication Exemption.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"LOW_VALUE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SECURE_CORPORATE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRANSACTION_RISK_ANALYSIS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRUSTED_BENEFICIARY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Search\",\n          \"description\": \"Top-level fields returned for a search query.\",\n          \"fields\": [\n            {\n              \"name\": \"transactions\",\n              \"description\": \"A paginated list of transactions that match the TransactionSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"TransactionSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refunds\",\n              \"description\": \"A paginated list of refunds that match the RefundSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"RefundSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RefundConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"payments\",\n              \"description\": \"A paginated list of all types of Payment that match the PaymentSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"PaymentSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"disputes\",\n              \"description\": \"A paginated list of disputes that match the DisputeSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"DisputeSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DisputeConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verifications\",\n              \"description\": \"A paginated list of verifications that match the VerificationSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"VerificationSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VerificationConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customers\",\n              \"description\": \"A paginated list of customers that match the CustomerSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CustomerSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CustomerConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"businessAccountCreationRequests\",\n              \"description\": \"A paginated list of business account creation requests that match the BusinessAccountCreationRequestSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"BusinessAccountCreationRequestSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"BusinessAccountCreationRequestConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inStoreReaders\",\n              \"description\": \"A paginated list of in-store readers that match the InStoreReaderSearchInput.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"InStoreReaderSearchInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"first\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Int\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                },\n                {\n                  \"name\": \"after\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreReaderConnection\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchChargebackProtectionLevelInput\",\n          \"description\": \"Deprecated: Please use `SearchDisputeProtectionLevelInput` instead.\\n\\nInput fields for searching for a dispute with a given chargeback protection level.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The dispute's chargeback protection level is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ChargebackProtectionLevel\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"Dispute's chargeback protection level is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"ChargebackProtectionLevel\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchCreditCardBrandCodeInput\",\n          \"description\": \"Input fields for searching for payments by credit card brand.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"Credit card brand code is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CreditCardBrandCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"Credit card brand code is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchCreditCardExpirationDateInput\",\n          \"description\": \"Input fields for searching for payments by payment method snapshot credit card expiration date criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"Field is exactly this value.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchCreditCardExpirationMonthYearInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"isNot\",\n              \"description\": \"Field is not this value.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchCreditCardExpirationMonthYearInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchCreditCardExpirationMonthYearInput\",\n          \"description\": \"Input fields for searching for payments by payment method snapshot credit card expiration date criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"expirationMonth\",\n              \"description\": \"The month of the credit card expiration as MM.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationYear\",\n              \"description\": \"The year of the credit card expiration as YYYY.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchCreditCardNumberInput\",\n          \"description\": \"Input fields for searching for payments by payment method snapshot credit card number criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"startsWith\",\n              \"description\": \"Up to the first six digits of the credit card number (the credit card's BIN).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"endsWith\",\n              \"description\": \"Up to four digits of the last four digits of the credit card number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchDateInput\",\n          \"description\": \"Input fields for searching for a date. These ranges are precise to the day.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"greaterThanOrEqualTo\",\n              \"description\": \"Date is greater than or equal to this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lessThanOrEqualTo\",\n              \"description\": \"Date is less than or equal to this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchDisputeProtectionLevelInput\",\n          \"description\": \"Input fields for searching for a dispute with a given protection level.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The dispute's protection level is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeProtectionLevel\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"The dispute's protection level is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"DisputeProtectionLevel\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchDisputeReasonInput\",\n          \"description\": \"Input fields for searching for a dispute with a given reason description.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"in\",\n              \"description\": \"The dispute reason is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"DisputeReason\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchDisputeStatusInput\",\n          \"description\": \"Input fields for searching for a dispute with a given status.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The dispute status is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeStatus\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"The dispute status is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"DisputeStatus\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchDisputeTypeInput\",\n          \"description\": \"Input fields for searching for a dispute with a given type.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The dispute type is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"DisputeType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"The dispute type is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"DisputeType\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentCreditCardDetailsInput\",\n          \"description\": \"Input fields for searching for payments by payment method snapshot credit card details criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"number\",\n              \"description\": \"The credit card number used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchCreditCardNumberInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"expirationDate\",\n              \"description\": \"Find payments based on the expiration date of the credit card used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchCreditCardExpirationDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"uniqueNumberIdentifier\",\n              \"description\": \"The unique identifier of the credit card number used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cardholderName\",\n              \"description\": \"The card holder name of the credit card number used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"brandCode\",\n              \"description\": \"The brand code of the credit card number used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchCreditCardBrandCodeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentCustomerInput\",\n          \"description\": \"Input fields for searching payments by customer.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find payments with a given customer ID.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"firstName\",\n              \"description\": \"Find payments with a given first name.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"Find payments with a given last name.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"company\",\n              \"description\": \"Find payments with a given customer company.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Find payments with a customer email.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentMethodSnapshotTypeInput\",\n          \"description\": \"Input fields for searching transactions by payment method snapshot type.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"This value represents the payment method type used to create a transaction. In the case of credit cards, this value also encode the origin from which a customer provided that payment method.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentMethodSnapshotSearchType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"These values represent the payment method type used to create a transaction. In the case of credit cards, these values also encode the origin from which a customer provided that payment method.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"PaymentMethodSnapshotSearchType\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentPayPalDetailsInput\",\n          \"description\": \"Input fields for searching for payments by payment method snapshot PayPal details criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"email\",\n              \"description\": \"\\\"The email address of the PayPal payer.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentPaymentMethodInput\",\n          \"description\": \"Input fields for searching for payments by payment method criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"The ID of the vaulted payment method used for the payment.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshot\",\n              \"description\": \"The snapshot of the payment method at the time of payment creation.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentPaymentMethodSnapshotInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentPaymentMethodSnapshotInput\",\n          \"description\": \"Input fields for searching for payments by payment method snapshot criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"type\",\n              \"description\": \"Find payments based on the payment instrument type.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentMethodSnapshotTypeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"creditCardDetails\",\n              \"description\": \"Find payments made with credit cards, based on the details of the credit card used for the payment. Passing an object with non-empty, non-null fields will scope your search to *only* credit card payment methods. This overrides the `type` field.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentCreditCardDetailsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"payPalDetails\",\n              \"description\": \"Find payments made with PayPal, based on the PayPal details used for the payment. Passing a value here will scope your search to *only* PayPal payment methods. This overrides the `type` field.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentPayPalDetailsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"sepaDirectDebitDetails\",\n              \"description\": \"Find SEPA payments with SEPA details. Passing a value here will scope your search to *only* SEPA Direct Debit payment methods. This overrides the `type` field.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentSEPADirectDebitDetailsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentSEPADirectDebitDetailsInput\",\n          \"description\": \"Input field for searching for payments by payment method snapshot SEPA Direct Debit details criteria.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"paymentId\",\n              \"description\": \"PayPal V2 OrderId.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentSourceInput\",\n          \"description\": \"Input fields for searching for a transaction or refund created with a given source.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"in\",\n              \"description\": \"The transaction source is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"PaymentSource\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentStatusInput\",\n          \"description\": \"Input fields for searching for a transaction or refund with a given status.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"in\",\n              \"description\": \"The transaction status is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"PaymentStatus\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentStatusTransitionInput\",\n          \"description\": \"Payment status transition times.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"failedAt\",\n              \"description\": \"Find transactions with a given failed at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"settledAt\",\n              \"description\": \"Find transactions with a given settled at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"submittedForSettlementAt\",\n              \"description\": \"Find transactions with a given submitted for settlement time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"voidedAt\",\n              \"description\" : \"Find transactions with a given voided at time.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchTimestampInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"authorizationExpiredAt\",\n              \"description\" : \"Find transactions with a given authorization expired at time.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchTimestampInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"authorizedAt\",\n              \"description\" : \"Find transactions with a given authorized at time.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchTimestampInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"gatewayRejectedAt\",\n              \"description\" : \"Find transactions with a given gateway rejected at time.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchTimestampInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"processorDeclinedAt\",\n              \"description\" : \"Find transactions with a given processor declined at time.\",\n              \"type\" : {\n                \"kind\" : \"INPUT_OBJECT\",\n                \"name\" : \"SearchTimestampInput\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchPaymentTypeInput\",\n          \"description\": \"Input fields for searching for payments by implementing type.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"in\",\n              \"description\": \"The payment is a transaction and/or a refund.\",\n              \"type\" : {\n                \"kind\" : \"LIST\",\n                \"name\" : null,\n                \"ofType\" : {\n                  \"kind\" : \"NON_NULL\",\n                  \"name\" : null,\n                  \"ofType\" : {\n                    \"kind\" : \"ENUM\",\n                    \"name\" : \"PaymentSearchType\",\n                    \"ofType\" : null\n                  }\n                }\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\" : \"INPUT_OBJECT\",\n          \"name\" : \"SearchPreDisputeProgramInput\",\n          \"description\" : \"Input fields for searching for a dispute with a given pre-dispute program.\",\n          \"fields\" : null,\n          \"inputFields\" : [\n            {\n              \"name\" : \"is\",\n              \"description\" : \"The dispute's pre-dispute program is exactly this value.\",\n              \"type\" : {\n                \"kind\" : \"ENUM\",\n                \"name\" : \"PreDisputeProgram\",\n                \"ofType\" : null\n              },\n              \"defaultValue\" : null\n            },\n            {\n              \"name\" : \"in\",\n              \"description\" : \"The dispute's pre-dispute program is one of these values.\",\n              \"type\" : {\n                \"kind\" : \"LIST\",\n                \"name\" : null,\n                \"ofType\" : {\n                  \"kind\" : \"NON_NULL\",\n                  \"name\" : null,\n                  \"ofType\" : {\n                    \"kind\" : \"ENUM\",\n                    \"name\" : \"PreDisputeProgram\",\n                    \"ofType\" : null\n                  }\n                }\n              },\n              \"defaultValue\" : null\n            }\n          ],\n          \"interfaces\" : null,\n          \"enumValues\" : null,\n          \"possibleTypes\" : null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchRangeInput\",\n          \"description\": \"Input fields for searching for a range.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"Field is exactly this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"greaterThanOrEqualTo\",\n              \"description\": \"Field is greater than or equal to this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lessThanOrEqualTo\",\n              \"description\": \"Field is less than or equal to this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchSoftwareVersionInput\",\n          \"description\": \"Input fields for searching for a version number.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"Field is exactly this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"isNot\",\n              \"description\": \"Field is not this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"startsWith\",\n              \"description\": \"Field starts with this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"contains\",\n              \"description\": \"Field contains this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchTextInput\",\n          \"description\": \"Input fields for searching text fields.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"Field is exactly this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"isNot\",\n              \"description\": \"Field is not this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"startsWith\",\n              \"description\": \"Field starts with this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"endsWith\",\n              \"description\": \"Field ends with this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"contains\",\n              \"description\": \"Field contains this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchTimestampInput\",\n          \"description\": \"Input fields for searching by timestamp. These ranges are precise to the minute; the results of searching for an object created between 12/17/2015 17:00 and 12/17/2015 17:00 (i.e., the same minute) will include objects created at 12/17/2015 17:00:59. If no timezone is provided, it will be assumed to be UTC.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"greaterThanOrEqualTo\",\n              \"description\": \"Timestamp is greater than or equal to this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lessThanOrEqualTo\",\n              \"description\": \"Timestamp is less than or equal to this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchTransactionSourceInput\",\n          \"description\": \"Input fields for searching for a transaction created with a given source.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"in\",\n              \"description\": \"The transaction source is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"PaymentSource\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchTransactionStatusInput\",\n          \"description\": \"Input fields for searching for a transaction with a given status.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The transaction status is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"The transaction status is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"PaymentStatus\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchTransactionStatusTransitionInput\",\n          \"description\": \"Transaction status transition times.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"failedAt\",\n              \"description\": \"Find transactions with a given failed at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"settledAt\",\n              \"description\": \"Find transactions with a given settled at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"submittedForSettlementAt\",\n              \"description\": \"Find transactions with a given submitted for settlement time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"voidedAt\",\n              \"description\": \"Find transactions with a given voided at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchValueInput\",\n          \"description\": \"Input fields for searching for specific values.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"Field is exactly this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"Field is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"String\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SearchVerificationStatusInput\",\n          \"description\": \"Input fields for searching for a verification with a given status.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"is\",\n              \"description\": \"The verification status is exactly this value.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"VerificationStatus\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"in\",\n              \"description\": \"The verification status is one of these values.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"VerificationStatus\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SelectedPayPalFinancingOptionDetails\",\n          \"description\": \"Details about a selected financing option by a PayPal buyer.\",\n          \"fields\": [\n            {\n              \"name\": \"term\",\n              \"description\": \"Total number of payments over which to finance the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"monthlyPayment\",\n              \"description\": \"The amount for each monthly payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discountPercentage\",\n              \"description\": \"The percent discount off the total transaction amount due to the selected financing option.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"The amount reduced from the total transaction amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"SelectedPayPalFinancingOptionInput\",\n          \"description\": \"Input fields indicating a selected financing option by a PayPal buyer.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"term\",\n              \"description\": \"Total number of payments over which to finance the transaction.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Int\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"currencyCode\",\n              \"description\": \"The currency code for the monthly payment and discount amount.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CurrencyCodeAlpha\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"monthlyPayment\",\n              \"description\": \"The amount for each monthly payment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountPercentage\",\n              \"description\": \"The percent discount off the total transaction amount due to the selected financing option.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Percentage\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"The amount reduced from the total transaction amount.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SettledEvent\",\n          \"description\": \"Accompanying information for a settled transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction was settled.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount the transaction was settled for, in the same currency as the original authorization (aka the \\\"presentment\\\" currency.) If you have elected to settle the transaction into a bank account with a different currency, this will not reflect that.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionSettlementProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"settlementBatchId\",\n              \"description\": \"The ID of the settlement batch in which the transaction was processed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SettlementConfirmedEvent\",\n          \"description\": \"Accompanying information for a settlement confirmed transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction became settlement confirmed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response to the settlement request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionSettlementProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SettlementDeclinedEvent\",\n          \"description\": \"Accompanying information for a settlement declined transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the processor declined to settle this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response to the settlement request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionSettlementProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SettlementPendingEvent\",\n          \"description\": \"Accompanying information for a settlement pending transaction. This typically only occurs for PayPal transactions.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction became settlement pending.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response to the settlement request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionSettlementProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SettlingEvent\",\n          \"description\": \"Accompanying information for a transaction that is settling. This is typically a transient state during which the transaction is being settled with the processor.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction began settling.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the transaction for this status event. This should match the amount submitted for settlement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"String\",\n          \"description\": \"Built-in String\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"SubmittedForSettlementEvent\",\n          \"description\": \"Accompanying information for a transaction that is submitted for settlement. This status indicates that the transaction is scheduled to be settled.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction was submitted for settlement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount that was submitted for settlement. This can differ from the authorized amount, but by default is the same.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TaxInfoInput\",\n          \"description\": \"Input fields for local payment tax information.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"identifier\",\n              \"description\": \"The payer's tax identifier value.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"The payer's tax identifier type.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"TaxInfoType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"TaxInfoType\",\n          \"description\": \"The type of tax identifier.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BR_CNPJ\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"BR_CPF\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ThreeDSecureAuthentication\",\n          \"description\": \"Information about the 3D Secure authentication for a payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"cavv\",\n              \"description\": \"The cardholder authentication verification value. This value should be appended to the authorization message signifying that the transaction has been successfully authenticated with 3D Secure. This value will be encoded according to the merchant's configuration with CardinalCommerce, with either Base64 or Hex encoding. The decoded value will be of different length and format per card scheme.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"directoryServerTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the card brand directory server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"eciFlag\",\n              \"description\": \"The electronic commerce indicator.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ECommerceIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"liabilityShifted\",\n              \"description\": \"A boolean indicating if the card has received liability shift.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"liabilityShiftPossible\",\n              \"description\": \"A boolean indicating if the card is eligible for liability shift.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cardEnrolled\",\n              \"description\": \"Indicates whether the card is enrolled in a 3D Secure program.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureCardEnrolled\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authenticationStatus\",\n              \"description\": \"The 3D Secure authentication status of the card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"version\",\n              \"description\": \"The version of the 3D Secure protocol used during authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"xId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"threeDSecureServerTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the 3D Secure server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"acsTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the access control server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paresStatus\",\n              \"description\": \"Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 1.0 authentications.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationStatusIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionStatus\",\n              \"description\": \"Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 2.0 authentications.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationStatusIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionStatusReason\",\n              \"description\": \"Indicates the reason for the transaction status. This will be null if status is `SUCCESSFUL_AUTHENTICATION`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationAcsWindowSize\",\n          \"description\": \"An override field that a merchant can pass in to set the challenge window size to display to the end cardholder. The ACS will reply with content that is formatted appropriately to this window size to allow for the best user experience. The sizes are width x height in pixels of the window displayed in the cardholder browser window.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"FULL_PAGE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"W250_H400\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"W390_H400\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"W500_H600\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"W600_H400\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationDeliveryTimeframe\",\n          \"description\": \"Indicates the delivery timeframe if applicable.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ELECTRONIC_DELIVERY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OVERNIGHT_SHIPPING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SAME_DAY_SHIPPING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TWO_OR_MORE_DAY_SHIPPING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecureAuthenticationInput\",\n          \"description\": \"Input fields for passing auxillary 3D Secure information manually, as opposed to tokenized on a single-use payment method ID.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"authenticationId\",\n              \"description\": \"Braintree unique ID of the 3D Secure authentication performed for this transaction. You will only need to use this field if you are charging or authorizing a vaulted payment method ID.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"passThrough\",\n              \"description\": \"Results of a merchant-performed 3D Secure authentication. You will only need to use these fields if you've performed your own integration with a 3D Secure MPI provider (e.g. Cardinal Centinel). Otherwise, Braintree's SDKs handle this for you in our standard 3D Secure integration.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecurePassThroughInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationMerchantProductCode\",\n          \"description\": \"Merchant product code.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ACCOMMODATION_RETAIL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AIRLINE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CAR_RENTAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CASH_DISPENSING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DIGITAL_GOODS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FUEL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GENERAL_RETAIL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LUXURY_RETAIL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OTHER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RESTAURANT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SERVICES\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TRAVEL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationShippingType\",\n          \"description\": \"Indicates the shipping type for the transaction.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"DIGITAL_GOODS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OTHER\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHIP_TO_ADDRESS_ON_FILE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHIP_TO_BILLING_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHIP_TO_OTHER_ADDRESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHIP_TO_STORE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TICKETS_NOT_SHIPPED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationStatus\",\n          \"description\": \"The 3D Secure authentication status of the card.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AUTHENTICATE_ATTEMPT_SUCCESSFUL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_FAILED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_FAILED_ACS_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_FRICTIONLESS_FAILED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_REJECTED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_SIGNATURE_VERIFICATION_FAILED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_SUCCESSFUL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATE_SUCCESSFUL_ISSUER_NOT_PARTICIPATING\",\n              \"description\": null,\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"No longer applicable.\"\n            },\n            {\n              \"name\": \"AUTHENTICATE_UNABLE_TO_AUTHENTICATE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATION_BYPASSED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AUTHENTICATION_UNAVAILABLE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CHALLENGE_REQUIRED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DATA_ONLY_SUCCESSFUL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_BYPASSED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_CARD_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_ENROLLED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_FAILED_ACS_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_NOT_ENROLLED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LOOKUP_SERVER_ERROR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNSUPPORTED_ACCOUNT_TYPE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNSUPPORTED_CARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNSUPPORTED_THREE_D_SECURE_VERSION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationStatusIndicator\",\n          \"description\": \"Indicates the current status of the 3D Secure authentication.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AUTHENTICATION_REJECTED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CHALLENGE_REQUIRED_DECOUPLED_AUTHENTICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CHALLENGE_REQUIRED_FOR_AUTHENTICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FAILED_AUTHENTICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INFORMATIONAL_CHALLENGE_PREFERENCE_ACKNOWLEDGED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUCCESSFUL_ATTEMPTS_TRANSACTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUCCESSFUL_AUTHENTICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNABLE_TO_COMPLETE_AUTHENTICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureAuthenticationTransactionType\",\n          \"description\": \"Indicates the type of transaction for 3D Secure authentication.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ADD_CARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CARDHOLDER_VERIFICATION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INSTALLMENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MAINTAIN_CARD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PAYMENT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RECURRING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureCardEnrolled\",\n          \"description\": \"Indicates whether the card is enrolled in a 3D Secure program.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BYPASS\",\n              \"description\": \"Authentication has been bypassed. This status will be returned if you set up bypass rules with CardinalCommerce, and they are triggered.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ERROR\",\n              \"description\": \"There was an error in determining whether the card is enrolled in a 3D Secure program.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NO\",\n              \"description\": \"The card is not enrolled.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNAVAILABLE\",\n              \"description\": \"The DS (directory server) or ACS (access control server) is not available for authentication at the time of the request.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"YES\",\n              \"description\": \"The card is enrolled.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"ThreeDSecureCavvAlgorithm\",\n          \"description\": \"A 3D Secure CAVV algorithm. Possible Values: 2 - CVV with ATN, 3 - Mastercard SPA algorithm.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ThreeDSecureConfiguration\",\n          \"description\": \"Configuration for 3D Secure.\",\n          \"fields\": [\n            {\n              \"name\": \"cardinalAuthenticationJWT\",\n              \"description\": \"Authentication information for initializing Cardinal's songbird.js library.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ThreeDSecureDetails\",\n          \"description\": \"3D Secure information for the payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"authentication\",\n              \"description\": \"Contains relevant data fields if the payment method has been authenticated using 3D Secure. Only available on 3D Secure authenticated single-use payment methods and 3D Secure paymentMethodSnapshots.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"ThreeDSecureAuthentication\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authenticationInsight\",\n              \"description\": \"Information about the [customer authentication regulation environment](https://developers.braintreepayments.com/guides/3d-secure/migration/javascript/v3#authentication-insight) that applies to the payment method when processed with the provided merchant account. This can be used to determine whether to perform 3D Secure authentication.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AuthenticationInsightInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"AuthenticationInsight\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cavv\",\n              \"description\": \"The cardholder authentication verification value. This value should be appended to the authorization message signifying that the transaction has been successfully authenticated with 3D Secure. This value will be encoded according to the merchant's configuration with CardinalCommerce, with either Base64 or Hex encoding. The decoded value will be of different length and format per card scheme.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.cavv instead.\"\n            },\n            {\n              \"name\": \"directoryServerTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the card brand directory server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.directoryServerTransactionId instead.\"\n            },\n            {\n              \"name\": \"eciFlag\",\n              \"description\": \"The electronic commerce indicator.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ECommerceIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.eciFlag instead.\"\n            },\n            {\n              \"name\": \"liabilityShifted\",\n              \"description\": \"A boolean indicating if the card has received liability shift.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.liabilityShifted instead.\"\n            },\n            {\n              \"name\": \"liabilityShiftPossible\",\n              \"description\": \"A boolean indicating if the card is eligible for liability shift.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.liabilityShiftPossible instead.\"\n            },\n            {\n              \"name\": \"cardEnrolled\",\n              \"description\": \"Indicates whether the card is enrolled in a 3D Secure program.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureCardEnrolled\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.cardEnrolled instead.\"\n            },\n            {\n              \"name\": \"authenticationStatus\",\n              \"description\": \"The 3D Secure authentication status of the card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.authenticationStatus instead.\"\n            },\n            {\n              \"name\": \"version\",\n              \"description\": \"The version of the 3D Secure protocol used during authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.version instead.\"\n            },\n            {\n              \"name\": \"xId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the provider.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.xId instead.\"\n            },\n            {\n              \"name\": \"threeDSecureServerTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the 3D Secure server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.threeDSecureServerTransactionId instead.\"\n            },\n            {\n              \"name\": \"acsTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure interaction with the access control server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.acsTransactionId instead.\"\n            },\n            {\n              \"name\": \"paresStatus\",\n              \"description\": \"Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 1.0 authentications.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationStatusIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.paresStatus instead.\"\n            },\n            {\n              \"name\": \"transactionStatus\",\n              \"description\": \"Indicates the current status of the 3D Secure authentication from the 3D Secure server for 3D Secure 2.0 authentications.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationStatusIndicator\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.transactionStatus instead.\"\n            },\n            {\n              \"name\": \"transactionStatusReason\",\n              \"description\": \"Indicates the reason for the transaction status. This will be null if status is `SUCCESSFUL_AUTHENTICATION`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use ThreeDSecureDetails.authentication.transactionStatusReason instead.\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecureLookupBillingAddressInput\",\n          \"description\": \" The billing address of the cardholder sent with 3D Secure Lookup requests.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"givenName\",\n              \"description\": \"The given (first) name associated with the billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"surname\",\n              \"description\": \"The surname (last name) associated with the billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The billing phone number used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"line1\",\n              \"description\": \"Line 1 of the billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"line2\",\n              \"description\": \"Line 2 of the billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"line3\",\n              \"description\": \"Line 3 of the billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"City or locality of billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or region of billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code of billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code of billing address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecureLookupCardholderInformationInput\",\n          \"description\": \"Additional information about the cardholder when authenticating through 3D Secure.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address of the cardholder.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecureLookupBillingAddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecureLookupClientInformationInput\",\n          \"description\": \"Information about the client side lookup process.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"sdkVersion\",\n              \"description\": \"Version of the Braintree client-side SDK being used.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"requestedThreeDSecureVersion\",\n              \"description\": \"Version of 3D Secure requested when performing the lookup.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"issuerDeviceDataCollectionMillisecondsElapsed\",\n              \"description\": \"Number of milliseconds taken for the issuer to collect device data.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"issuerDeviceDataCollectionResult\",\n              \"description\": \"Whether device data collection by the issuer succeeded.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"threeDSecureServerDeviceDataCollectionMillisecondsElapsed\",\n              \"description\": \"Number of milliseconds taken for the 3D Secure server to collect device data.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"ThreeDSecureLookupData\",\n          \"description\": \"Data fields containing information from the MPI provider about the 3D Secure Lookup result.\",\n          \"fields\": [\n            {\n              \"name\": \"acsUrl\",\n              \"description\": \"The URL to use to issue a challenge to the cardholder for 3D Secure authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authenticationId\",\n              \"description\": \"Braintree unique ID of the 3D Secure authentication performed for this transaction. You will only need to use this field if you are charging or authorizing a vaulted payment method ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"version\",\n              \"description\": \"The version of the 3D Secure protocol used in the authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pareq\",\n              \"description\": \"The \\\"PAReq\\\" or \\\"Payment Authentication Request\\\" is the encoded request message used to initiate authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"md\",\n              \"description\": \"The unique 3D Secure identifier assigned by Braintree to track the 3D Secure call as it progresses.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"termUrl\",\n              \"description\": \"A fully qualified URL that the customer will be redirected to once the authentication completes.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"A unique identifier used by the MPI provider to identify the 3D Secure interaction. The MPI provider provides the framework for determining if a card is enrolled in a 3D Secure program and for facilitating interactions with the issuer.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecureLookupShippingAddressInput\",\n          \"description\": \" The shipping address of the transaction to be sent with 3D Secure Lookup requests.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"givenName\",\n              \"description\": \"The given (first) name associated with the shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"surname\",\n              \"description\": \"The surname (last name) associated with the shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The shipping phone number used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"line1\",\n              \"description\": \"Line 1 of the shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"line2\",\n              \"description\": \"Line 2 of the shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"line3\",\n              \"description\": \"Line 3 of the shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locality\",\n              \"description\": \"City or locality of shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"region\",\n              \"description\": \"State or region of shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"countryCode\",\n              \"description\": \"Country code of shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"postalCode\",\n              \"description\": \"Postal code of shipping address used for verification.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"ThreeDSecureLookupShippingMethod\",\n          \"description\": \"Indicates the shipping method chosen for the transaction in the 3D Secure lookup.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ELECTRONIC_DELIVERY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GROUND\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OVERNIGHT_EXPEDITED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PRIORITY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SAME_DAY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHIP_TO_STORE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecureLookupTransactionInformationInput\",\n          \"description\": \"Additional information about the transaction when authenticating through 3D Secure.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"email\",\n              \"description\": \"The email associated with the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingMethod\",\n              \"description\": \"Indicates the shipping method chosen for the transaction in the 3D Secure lookup.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureLookupShippingMethod\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The phone number associated with the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"The shipping address for the transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecureLookupShippingAddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"workPhoneNumber\",\n              \"description\": \"The work phone number associated with the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionType\",\n              \"description\": \"Indicates the type of transaction.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationTransactionType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"deliveryTimeframe\",\n              \"description\": \"Indicates the delivery timeframe if applicable.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationDeliveryTimeframe\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"deliveryEmail\",\n              \"description\": \"For electronic delivery, email address to which the product was delivered.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingType\",\n              \"description\": \"Indicates shipping type chosen for the transaction.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationShippingType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"productCode\",\n              \"description\": \"Merchant product code.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationMerchantProductCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"reorderIndicator\",\n              \"description\": \"Indicates whether the cardholder is reordering merchandise purchased in a previous order.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"preorderIndicator\",\n              \"description\": \"Indicates whether cardholder is placing an order with a future availability or release date.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"preorderDate\",\n              \"description\": \"Expected date that a pre-ordered purchase will be available.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"giftCardAmount\",\n              \"description\": \"The purchase amount total for prepaid gift cards.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"giftCardCurrencyCode\",\n              \"description\": \"ISO 4217 currency code for the gift card purchased.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"CurrencyCodeAlpha\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"giftCardCount\",\n              \"description\": \"Total count of individual prepaid gift cards purchased.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountCreatedDuringTransaction\",\n              \"description\": \"Indicates whether the cardholder created the account during this transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountCreateDate\",\n              \"description\": \"Date the cardholder opened the account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountChangedDuringTransaction\",\n              \"description\": \"Indicates whether the cardholder changed the account during this transaction. This includes changes to the billing or shipping address, new payment accounts or new users added.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountChangeDate\",\n              \"description\": \"Date the cardholder's account was last changed. This includes changes to the billing or shipping address, new payment accounts or new users added.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountPasswordChangedDuringTransaction\",\n              \"description\": \"Indicates whether the cardholder changed or reset the password on the account during this transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountPasswordChangeDate\",\n              \"description\": \"Date the cardholder changed or reset the password on the account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"firstUseOfShippingAddress\",\n              \"description\": \"Indicates whether this transaction represents the first use of this shipping address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAddressFirstUsageDate\",\n              \"description\": \"Date when the shipping address used for this transaction was first used.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionCountDay\",\n              \"description\": \"Number of transactions (successful or incomplete) for this cardholder account within the last 24 hours.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionCountYear\",\n              \"description\": \"Number of transactions (successful or incomplete) for this cardholder account within the last year.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"addCardAttempts\",\n              \"description\": \"Number of attempts that have been made to add a card to this account in the last 24 hours.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountPurchases\",\n              \"description\": \"Number of purchases with this cardholder account during the previous six months.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"suspiciousActivityObserved\",\n              \"description\": \"Indicates whether the merchant experienced suspicious activity (including previous fraud) on the account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountNameMatchesShippingName\",\n              \"description\": \"Indicates if the cardholder name on the account is identical to the shipping name used for the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodAddedDuringTransaction\",\n              \"description\": \"Indicates whether the payment method was added to the cardholder account during this transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodAddedToAccountDate\",\n              \"description\": \"Date the payment method was added to the cardholder account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"acsWindowSize\",\n              \"description\": \"An override field that a merchant can pass in to set the challenge window size to display to the end cardholder. The ACS will reply with content that is formatted appropriately to this window size to allow for the best user experience. The sizes are width x height in pixels of the window displayed in the cardholder browser window.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ThreeDSecureAuthenticationAcsWindowSize\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"sdkMaxTimeout\",\n              \"description\": \"This field indicates the maximum amount of time for all 3DS 2.0 messages to be communicated between all components (in minutes). Minimum is 05. Defaults to 15.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddressMatchesShippingAddress\",\n              \"description\": \"Indicates whether cardholder billing and shipping addresses match.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountId\",\n              \"description\": \"Additional cardholder account information.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"ipAddress\",\n              \"description\": \"The IP address of the cardholder. Both IPv4 and IPv6 formats are supported.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderDescription\",\n              \"description\": \"Brief Description of items purchased.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"taxAmount\",\n              \"description\": \"Tax amount.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"userAgent\",\n              \"description\": \"The exact content of the HTTP user agent header.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"installment\",\n              \"description\": \"Indicates the maximum number of authorizations for installment payments. An integer value greater than 1 indicating the maximum number of permitted authorizations for installment payments.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"purchaseDate\",\n              \"description\": \"Datetime of original purchase.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"recurringEnd\",\n              \"description\": \"The date after which no further recurring authorizations should be performed.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"recurringFrequency\",\n              \"description\": \"Integer value indicating the minimum number of days between recurring authorizations. A frequency of monthly is indicated by the value 28. Multiple of 28 days will be used to indicate months. Example: 6 months = 168.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Int\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"ThreeDSecurePassThroughInput\",\n          \"description\": \"Results of a merchant-performed 3D Secure authentication.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"eciFlag\",\n              \"description\": \"The value of the electronic commerce indicator (ECI) flag, which indicates the outcome of the 3D Secure authentication.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ECommerceIndicator\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cavv\",\n              \"description\": \"Cardholder authentication verification value or CAVV. The main encrypted message issuers and card networks use to verify authentication has occurred. Mastercard uses an AVV (Authentication Verification Value) message and American Express uses an AEVV (American Express Verification Value) message, each of which should also be passed in the cavv parameter.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"xId\",\n              \"description\": \"Transaction identifier resulting from 3D Secure authentication. Uniquely identifies the transaction and sometimes required in the authorization message. Must be base64-encoded. This field will no longer be used in 3D Secure 2 authentications.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"threeDSecureServerTransactionId\",\n              \"description\": \"3D Secure server transaction identifier resulting from 3D Secure authentication.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"version\",\n              \"description\": \"The version of 3D Secure authentication used for the transaction. Required on Visa and Mastercard authentications.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ThreeDSecureVersion\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"authenticationResponse\",\n              \"description\": \"The 3D Secure authentication response status code.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ThreeDSecureStatusCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"directoryServerResponse\",\n              \"description\": \"The 3D Secure directory server response.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ThreeDSecureStatusCode\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cavvAlgorithm\",\n              \"description\": \"The algorithm used to generate the CAVV value. This is only returned for Mastercard SecureCode transactions (3DS 1.0).\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ThreeDSecureCavvAlgorithm\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"directoryServerTransactionId\",\n              \"description\": \"A unique identifier for the 3D Secure 2 interaction with the card brand directory server. This field must be supplied for Mastercard Identity Check.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"ThreeDSecureStatusCode\",\n          \"description\": \"A raw 3D Secure PARes or VARes response code (e.g. 'Y').\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"ThreeDSecureVersion\",\n          \"description\": \"A 3D Secure authentication version. Must be composed of digits separated by periods (e.g. '1.0.2').\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Timestamp\",\n          \"description\": \"An [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Times) timestamp with microsecond precision, in UTC.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeCreditCardInput\",\n          \"description\": \"Top-level input fields for tokenizing a credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"creditCard\",\n              \"description\": \"Input fields for a credit card.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"CreditCardInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Credit card tokenization options.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TokenizeCreditCardOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeCreditCardOptionsInput\",\n          \"description\": \"Credit card tokenization options.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"validate\",\n              \"description\": \"Whether to run validations on credit card fields. Validations are not run by default.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizeCreditCardPayload\",\n          \"description\": \"Top-level fields returned from a tokenized credit card.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"token\",\n              \"description\": \"A one-time-use reference to tokenized sensitive information.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `paymentMethod.id` instead.\"\n            },\n            {\n              \"name\": \"creditCard\",\n              \"description\": \"Details about the tokenized card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"CreditCardDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `paymentMethod.details` instead.\"\n            },\n            {\n              \"name\": \"singleUseToken\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `paymentMethod` instead.\"\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authenticationInsight\",\n              \"description\": \"Information about the [customer authentication regulation environment](https://developers.braintreepayments.com/guides/3d-secure/migration/javascript/v3#authentication-insight) that applies to the payment method when processed with the provided merchant account. This can be used to determine whether to perform 3D Secure authentication.\",\n              \"args\": [\n                {\n                  \"name\": \"input\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"AuthenticationInsightInput\",\n                      \"ofType\": null\n                    }\n                  },\n                  \"defaultValue\": null\n                }\n              ],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"AuthenticationInsight\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use paymentMethod.details.threeDSecure.authenticationInsight instead.\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeCustomActionsPaymentMethodInput\",\n          \"description\": \"Top-level input fields for tokenizing Custom Actions.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customActionsPaymentMethod\",\n              \"description\": \"Input fields for a Custom Actions payment method.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"CustomActionsPaymentMethodInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizeCustomActionsPaymentMethodPayload\",\n          \"description\": \"Top-level fields returned from tokenizing a CustomActionsPaymentMethod.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeCvvInput\",\n          \"description\": \"Top-level input fields for tokenizing a CVV, otherwise known as CSC or CVC.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"cvv\",\n              \"description\": \"A 3 or 4 digit card verification value assigned to credit cards. The CVV will never be stored, but it can be provided with one-time requests to verify the card.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"CVV\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizeCvvPayload\",\n          \"description\": \"Top-level fields returned from a tokenized CVV.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tokenizedCvv\",\n              \"description\": \"A single-use tokenized CVV.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TokenizedCvv\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"singleUseToken\",\n              \"description\": \"A single-use payment method representing just a CVV.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"This mutation does not create a full PaymentMethod. Use `tokenizedCvv` instead.\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeNetworkTokenInput\",\n          \"description\": \"Top-level input field for tokenizing a network token.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"networkToken\",\n              \"description\": \"Input fields for a network token object.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"NetworkTokenInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizeNetworkTokenPayload\",\n          \"description\": \"Top-level fields returned from a tokenized Network Token.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizePayPalBillingAgreementInput\",\n          \"description\": \"Top-level input fields for tokenizing a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAgreement\",\n              \"description\": \"Input fields for a PayPal Billing Agreement.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"PayPalBillingAgreementInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizePayPalBillingAgreementPayload\",\n          \"description\": \"Top-level fields returned from a tokenized PayPal account.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizePayPalOneTimePaymentInput\",\n          \"description\": \"Top-level input fields for tokenizing a PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Braintree merchant account ID associated with the PayPal account to be used for the One-Time payment tokenization.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paypalOneTimePayment\",\n              \"description\": \"Input fields for a PayPal One-Time Payment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"PayPalOneTimePaymentInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizePayPalOneTimePaymentPayload\",\n          \"description\": \"Top-level fields returned from a tokenized PayPal account.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeSamsungPayCardInput\",\n          \"description\": \"Top-level input field for tokenizing a Samsung Pay card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"samsungPayCard\",\n              \"description\": \"Input fields for a Samsung Pay card.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"SamsungPayCardInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizeSamsungPayCardPayload\",\n          \"description\": \"Top-level fields returned from a tokenized Samsung Pay card.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"singleUseToken\",\n              \"description\": \"A one-time-use reference to tokenized sensitive information.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `paymentMethod` instead.\"\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeUsBankAccountInput\",\n          \"description\": \"Top-level input fields for tokenizing a US bank account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"usBankAccount\",\n              \"description\": \"Input fields for a US bank account object.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"UsBankAccountInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizeUsBankAccountPayload\",\n          \"description\": \"Top-level fields returned from a tokenized US bank account.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A single-use payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TokenizeUsBankLoginInput\",\n          \"description\": \"Top-level input fields for tokenizing a US bank login.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"usBankLogin\",\n              \"description\": \"Input fields for a US bank login.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"UsBankLoginInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TokenizedCvv\",\n          \"description\": \"A single-use, tokenized value representing a CVV (card verification value), otherwise known as CSC or CVC. This cannot be charged or authorized, since it is not a payment method, but it can be used alongside a multi-use credit card payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier for the tokenized CVV.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Transaction\",\n          \"description\": \"A charge on a payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time when the transaction was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshot\",\n              \"description\": \"Snapshot of payment method details used to create the transaction, preserved at the time the transaction was created. This will always be present.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PaymentMethodSnapshot\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"The multi-use payment method associated with the transaction. Only present if a multi-use payment method was used to create the transaction and it has not been deleted. The details of this PaymentMethod may have changed since the transaction was created; details used for the transaction can be found in the `paymentMethodSnapshot` field.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount charged in this transaction. For transactions that are partially captured, this amount will be the cummulative amount captured on this transaction. For transactions that are partially authorized, the amount will be less than the `initialRequestedAuthorizationAmount`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"initialRequestedAuthorizationAmount\",\n              \"description\": \"The initial requested authorization amount for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. Custom fields are [defined in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#store-and-pass-back-fields).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"CustomField\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantId\",\n              \"description\": \"The ID of the merchant that processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The ID of the merchant account that processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantName\",\n              \"description\": \"The display name of the merchant that processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchantAddress\",\n              \"description\": \"The address of the merchant that processed this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The order ID for this transaction. For PayPal transactions, the PayPal Invoice ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"purchaseOrderNumber\",\n              \"description\": \"A purchase order identification value you associate with this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The current status of this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Fields describing the payment processor response.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionAuthorizationProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use relevant events in `statusHistory` instead.\"\n            },\n            {\n              \"name\": \"riskData\",\n              \"description\": \"Risk data evaluated for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RiskData\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on customers' credit card statements for a specific purchase.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionDescriptor\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"statusHistory\",\n              \"description\": \"The records of all statuses this transaction has passed through, with additional information on why each status occurred. Returned in reverse chronological order, with the most recent event first in the list.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INTERFACE\",\n                    \"name\": \"PaymentStatusEvent\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"channel\",\n              \"description\": \"If the transaction request was performed through a shopping cart provider or Braintree partner, this field will have a string identifier for that shopping cart provider or partner. For PayPal transactions, this maps to the PayPal account's bn_code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"How the transaction was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Customer associated with the transaction, if applicable.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Customer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"shipping\",\n              \"description\": \"Shipping information.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionShipping\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"tax\",\n              \"description\": \"Tax information.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionTaxInformation\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"scaExemptionRequested\",\n              \"description\": \"The type of Strong Customer Authentication Exemption that was requested for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ScaExemptionType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"Discount amount that was included in the total transaction amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"Line items for this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"TransactionLineItem\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"refunds\",\n              \"description\": \"The list of refunds issued against this transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Refund\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"partialCaptureDetails\",\n              \"description\": \"For transactions created or captured using the `partialCaptureTransaction` mutation. This field links a given transaction to its original authorization or all its partial captures.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PartialCaptureDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"disputes\",\n              \"description\": \"A collection of disputes associated with the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Dispute\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"facilitatorDetails\",\n              \"description\": \"If the transaction request was performed using payment information from a third party via the Grant API, Shared Vault or Google Pay, these fields will capture information about the third party. These fields are primarily useful for the merchant of record.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"FacilitatorDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"disbursementDetails\",\n              \"description\": \"The disbursement details associated with this transaction. This field is only available after the transaction is SETTLED and if you have an eligible merchant account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"DisbursementDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address associated with the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authorizationAdjustments\",\n              \"description\": \"A collection of AuthorizationAdjustments associated with the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"AuthorizationAdjustment\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"retried\",\n              \"description\": \"Whether or not the transaction was automatically retried by Braintree's internal systems.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"installmentDetails\",\n              \"description\": \"Installment details associated with the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"TransactionInstallmentDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentInitiatedAt\",\n              \"description\": \"The transaction date and time as reported by the in-store payment terminal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Payment\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionAuthorizationAdjustmentProcessorResponse\",\n          \"description\": \"Record of processor response data received in response to authorization adjustment requests.\",\n          \"fields\": [\n            {\n              \"name\": \"legacyCode\",\n              \"description\": \"The [processor response code](https://developers.braintreepayments.com/reference/general/processor-responses/authorization-responses) indicating the result of attempting the adjustment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"message\",\n              \"description\": \"The text explanation of the processor response code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"declineType\",\n              \"description\": \"Whether or not the decline is the result of a temporary issue. Only present if adjustment is declined.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"ProcessorDeclineType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionAuthorizationProcessorResponse\",\n          \"description\": \"Detailed response information from the processor when attempting to authorize a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"legacyCode\",\n              \"description\": \"A code based on the response from the processor, indicating the result of attempting to authorize this transaction. See the [list of possible processor response codes for authorization](https://developers.braintreepayments.com/reference/general/processor-responses/authorization-responses).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"message\",\n              \"description\": \"The text explanation of the processor response legacyCode.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cvvResponse\",\n              \"description\": \"The processing bank's response to the provided CVV.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"avsPostalCodeResponse\",\n              \"description\": \"The processing bank's response to the provided billing postal or zip code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"avsStreetAddressResponse\",\n              \"description\": \"The processing bank's response to the provided billing street address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"authorizationId\",\n              \"description\": \"The processor's unique ID or \\\"code\\\" for the authorization.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"additionalInformation\",\n              \"description\": \"If present, any additional information recieved from the processor. May provide further insight into the `legacyCode`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"retrievalReferenceNumber\",\n              \"description\": \"The processor's reference number for the authorization.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"emvData\",\n              \"description\": \"Response EMV data provided by the processor if this was an EMV transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionConnection\",\n          \"description\": \"A paginated list of transactions.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of transactions.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"TransactionConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of transactions contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionConnectionEdge\",\n          \"description\": \"A transaction within a TransactionConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"This transaction's location within the TransactionConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionCustomerDetailsInput\",\n          \"description\": \"Customer details to be stored on the transaction itself, if the transaction is not associated with a customer. Used for fraud detection purposes.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"email\",\n              \"description\": \"Email address for the customer.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"Phone number for the customer.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionDescriptor\",\n          \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase.\",\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The value in the business name field of a customer's statement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"phone\",\n              \"description\": \"The value in the phone number field of a customer's statement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"url\",\n              \"description\": \"The value in the URL/web address field of a customer's statement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionDescriptorInput\",\n          \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The value in the business name field of a customer's statement.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phone\",\n              \"description\": \"The value in the phone number field of a customer's statement.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"url\",\n              \"description\": \"The value in the URL/web address field of a customer's statement.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionExternalVaultOptionsInput\",\n          \"description\": \"Input for transactions created with credit cards vaulted in an external vault, not the Braintree Vault. Do not use for transactions created from Braintree multi-use payment methods, or from single-use payment methods which will not be stored in an external vault.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The credit card's assocation with an external vault.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"ExternalVaultStatus\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verifyingNetworkTransactionId\",\n              \"description\": \"The network transaction ID of the first _transaction_ after which this payment method was stored in the external vault. If the `status` is `WILL_VAULT`, do not pass this value; the network transaction ID of the resulting transaction can be passed in this field for _subsequent_ transactions. If the `status` is `VAULTED`, but the customer is directly initiating the charge, do not pass this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionInput\",\n          \"description\": \"Input fields for creating a transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"Billing amount of the request. This value must be greater than 0, and must match the currency format of the merchant account. This can only contain numbers and one decimal point (e.g. x.xx). Can't be greater than the maximum allowed by the processor.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Merchant account ID used to process the transaction. Currency is also determined by merchant account ID. If no merchant account ID is specified, we will use your default merchant account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Additional information about the transaction. On PayPal transactions, this field maps to the PayPal invoice number. PayPal invoice numbers must be unique in your PayPal business account. Maximum 255 characters or 127 for PayPal transactions.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"exchangeRateQuoteId\",\n              \"description\": \"ID of exchange rate quote to be used for the transaction.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"purchaseOrderNumber\",\n              \"description\": \"A purchase order identification value you associate with this transaction.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"riskData\",\n              \"description\": \"Customer device information, which is sent directly to supported processors for fraud analysis.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"RiskDataInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"Collection of custom field/value pairs. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"CustomFieldInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"descriptor\",\n              \"description\": \"Fields used to define what will appear on a customer's bank statement for a specific purchase.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionDescriptorInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"recurring\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `paymentInitiator` instead.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"RecurringType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentInitiator\",\n              \"description\": \"The initiator of the payment. Payment can either be merchant-initiated or customer-initiated. If the transaction is an ecommerce transaction initiated by the customer, no value is passed.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentInitiator\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"channel\",\n              \"description\": \"For partners and shopping carts only. If you are a shopping cart provider or other Braintree partner, pass a string identifier for your service. For PayPal transactions, this maps to paypal.bn_code.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"If charging a single-use payment method, optional ID of a customer to associate the transaction with. If vaulting the single-use payment method, this customer will be associated with the resulting multi-use payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shipping\",\n              \"description\": \"Shipping information.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionShippingInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"tax\",\n              \"description\": \"Tax information about the transaction.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionTaxInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"Discount amount that was included in the total transaction amount. Does not add to the total amount the payment method will be charged. This value can't be negative. Please note that this field is not used on PayPal transactions.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lineItems\",\n              \"description\": \"Line items for this transaction. Up to 249 line items may be specified.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"INPUT_OBJECT\",\n                    \"name\": \"TransactionLineItemInput\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"threeDSecurePassThrough\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. This field is specific to credit card payment methods only, and cannot be applied to transactions with other payment method types. If you need to pass this field, please use `authorizeCreditCard` or `chargeCreditCard`. See the `CreditCardTransactionOptionsInput` type for details.\\n\\nResults of a merchant-performed 3D Secure authentication. You will only need to use these fields if you've performed your own integration with a 3D Secure MPI provider (e.g. Cardinal Centinel). Otherwise, Braintree's SDKs handle this for you in our standard 3D Secure integration.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecurePassThroughInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"vaultPaymentMethodAfterTransacting\",\n              \"description\": \"When a single-use payment method is used to create this transaction, it can be automatically stored in the vault after transacting. If this field is left blank, the single-use payment method will not be vaulted.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"VaultPaymentMethodAfterTransactingInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerDetails\",\n              \"description\": \"Customer information to be stored on the transaction and used for fraud protection. Use this if you wish to pass customer information on a transaction without creating an independent stored customer record in the vault.\\n\\nThis parameter can only be used if you do not pass `customerId`, and if you are not using a vaulted/multi-use payment method. In other words, this field is only valid when the transaction will not be associated with an existing customer.\\n\\nIf `vaultPaymentMethodAfterTransacting` is also passed, these values will be used when creating a new customer for the newly-vaulted payment method.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"TransactionCustomerDetailsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionInstallment\",\n          \"description\": \"Transaction Installment information.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Installment ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"projectedDisbursementDate\",\n              \"description\": \"The projected date for the funds associated with this installment to be disbursed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"actualDisbursementDate\",\n              \"description\": \"The date that the funds associated with this installment were actually disbursed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Installment amount.The total transaction amount is split equally into each installment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"adjustments\",\n              \"description\": \"List of adjustments associated with the installment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"TransactionInstallmentAdjustment\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionInstallmentAdjustment\",\n          \"description\": \"Adjustment information.\",\n          \"fields\": [\n            {\n              \"name\": \"projectedDisbursementDate\",\n              \"description\": \"The projected date for the funds associated with the adjustements to be disbursed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"actualDisbursementDate\",\n              \"description\": \"The date that the funds associated with this adjustments were actually disbursed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Date\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Adjustment amount for the installment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": \"Transaction Installment Adjustment type.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"TransactionInstallmentAdjustmentType\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"TransactionInstallmentAdjustmentType\",\n          \"description\": \"Transaction Installment Adjustment type to indicate the reason for the adjustment.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"DISPUTE\",\n              \"description\": \"Dispute.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"REFUND\",\n              \"description\": \"Refund.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionInstallmentDetails\",\n          \"description\": \"Installment details for the transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"count\",\n              \"description\": \"The installment count associated with the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"installments\",\n              \"description\": \"List of installments associated with the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"TransactionInstallment\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionLevelFeeReport\",\n          \"description\": \"The [transaction-level fee report](https://articles.braintreepayments.com/control-panel/reporting/transaction-level-fee-report) provides a breakdown of fees per individual transactions and refunds. This type is no longer in use; see `PaymentLevelFeeReport` instead.\",\n          \"fields\": [\n            {\n              \"name\": \"url\",\n              \"description\": \"The URL where you can access the requested report.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionLineItem\",\n          \"description\": \"Data for individual line items on a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"Item name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"kind\",\n              \"description\": \"Indicates whether the line item is a sale or refund.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"TransactionLineItemType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"quantity\",\n              \"description\": \"Number of units of the item purchased.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"unitAmount\",\n              \"description\": \"Per-unit price of the item.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"totalAmount\",\n              \"description\": \"Total price amount for the line item, i.e. quantity multiplied by unit amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"unitTaxAmount\",\n              \"description\": \"Per-unit tax price of the item.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"taxAmount\",\n              \"description\": \"Tax amount for the line item.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"The discount amount of the line item.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"unitOfMeasure\",\n              \"description\": \"The unit of measure or the unit of measure code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"productCode\",\n              \"description\": \"Product or UPC code for the item.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"commodityCode\",\n              \"description\": \"Code used to classify items purchased and track the total amount spent across various categories of products and services. Different corporate purchasing organizations may use different standards, but the [United Nations Standard Products and Services Code (UNSPSC)](https://www.unspsc.org/) is frequently used.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Item description.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"url\",\n              \"description\": \"The URL to product information.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"itemType\",\n              \"description\": \"The type of the line item, i.e., physical, digital etc.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"imageUrl\",\n              \"description\": \"URL to an image that represents the product. Max 1024 characters.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionLineItemInput\",\n          \"description\": \"Data for individual line items on a transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"Item name. Maximum 35 characters, or 127 characters for PayPal transactions.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"kind\",\n              \"description\": \"Indicates whether the line item is a sale or refund.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"TransactionLineItemType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"quantity\",\n              \"description\": \"Number of units of the item purchased. Can include up to 4 decimal places. This value can't be negative or zero.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"unitAmount\",\n              \"description\": \"Per-unit price of the item. Maximum 4 decimal places, or 2 decimal places for PayPal transactions. This value can't be negative or zero.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"totalAmount\",\n              \"description\": \"Total price amount for the line item: quantity multiplied by unitAmount. Can include up to 2 decimal places.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"unitTaxAmount\",\n              \"description\": \"Per-unit tax price of the item. Can include up to 2 decimal places. This value can't be negative or zero.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"taxAmount\",\n              \"description\": \"Tax amount for the line item. Can include up to 2 decimal places. This value can't be negative.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"discountAmount\",\n              \"description\": \"Amount of discount for the line item. Can include up to 2 decimal places. This value can't be negative. Please note that this field is not used on PayPal transactions.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"unitOfMeasure\",\n              \"description\": \"The unit of measure or the unit of measure code. Maximum 12 characters.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"productCode\",\n              \"description\": \"Product or UPC code for the item. Maximum 12 characters, or 127 characters for PayPal transactions.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"commodityCode\",\n              \"description\": \"Code used to classify items purchased and track the total amount spent across various categories of products and services. Different corporate purchasing organizations may use different standards, but the [United Nations Standard Products and Services Code (UNSPSC)](https://www.unspsc.org/) is frequently used. Maximum 12 characters.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Item description. Maximum 127 characters.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"url\",\n              \"description\": \"A URL to information about the product.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"itemType\",\n              \"description\": \"The type of the line item, i.e., physical, digital etc.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"imageUrl\",\n              \"description\": \"URL to an image that represents the product. Max 1024 characters.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"TransactionLineItemType\",\n          \"description\": \"Indicates whether a transaction line item is a debit (sale) or credit (refund).\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CREDIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DEBIT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionPayload\",\n          \"description\": \"Top-level output field from creating a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"transaction\",\n              \"description\": \"The transaction representing the charge on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Transaction\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"TransactionReversal\",\n          \"description\": \"A union of all possible results of a transaction reversal. If the transaction is settled, a refund will be issued and a Refund object will be returned. Otherwise, the transaction will be voided and a Transaction object will be returned.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Refund\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"Transaction\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionSearchInput\",\n          \"description\": \"Input fields for searching for transactions.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find transactions with an ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Find transactions with a given transaction status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTransactionStatusInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"statusTransition\",\n              \"description\": \"Find transactions based on the given transaction status transition times.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTransactionStatusTransitionInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Find transactions based on the time they were created.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"Find transactions for a given amount or currency.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"MonetaryAmountSearchInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"Find transactions with a given orderId.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Find payments processed through a merchant account ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Find transactions with a given customer.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentCustomerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshotType\",\n              \"description\": \"Find transactions created by charging payment methods of the given type.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentMethodSnapshotTypeInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"disbursementDate\",\n              \"description\": \"Find transactions by their disbursement date. Only use this search criteria if you have an eligible merchant account. Note that transactions can only be disbursed after they reach the SETTLED status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchDateInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"Find transactions created with a given transaction source.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTransactionSourceInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"settlementBatchId\",\n              \"description\": \"Find transactions by the batch ID under which the transaction was submitted for settlement.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTextInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"Find transactions based on information about the payment method used for the transaction.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchPaymentPaymentMethodInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"facilitatorOAuthApplicationClientId\",\n              \"description\": \"Find transactions created by a third party via the Grant API using a given OAuth application client ID.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"userId\",\n              \"description\": \"Find transactions with a user ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"storeId\",\n              \"description\": \"Find transactions by the ID of the store that the transaction was processed in.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionSettlementProcessorResponse\",\n          \"description\": \"Detailed response information from the processor when attempting to settle a transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"legacyCode\",\n              \"description\": \"A code based on the response from the processor, indicating the result of attempting to settle this transaction. See the [list of possible processor response codes for settlement](https://developers.braintreepayments.com/reference/general/processor-responses/settlement-responses).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"message\",\n              \"description\": \"The text explanation of the processor response legacyCode.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cvvResponse\",\n              \"description\": \"The processing bank's response to the provided CVV.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"AVS and CVV checks do not take place when capturing a transaction, only when authorizing. Use the `processorResponse` on an authorization-related `PaymentStatusEvent` instead.\"\n            },\n            {\n              \"name\": \"avsPostalCodeResponse\",\n              \"description\": \"The processing bank's response to the provided billing postal or zip code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"AVS and CVV checks do not take place when capturing a transaction, only when authorizing. Use the `processorResponse` on an authorization-related `PaymentStatusEvent` instead.\"\n            },\n            {\n              \"name\": \"avsStreetAddressResponse\",\n              \"description\": \"The processing bank's response to the provided billing street address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"AVS and CVV checks do not take place when capturing a transaction, only when authorizing. Use the `processorResponse` on an authorization-related `PaymentStatusEvent` instead.\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionShipping\",\n          \"description\": \"Information related to shipping a physical product.\",\n          \"fields\": [\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"Shipping address information.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"shippingAmount\",\n              \"description\": \"The shipping cost of the entire transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"shipsFromPostalCode\",\n              \"description\": \"The postal code of the source shipping location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionShippingInput\",\n          \"description\": \"Information related to shipping a physical product.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"Shipping destination address information.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAmount\",\n              \"description\": \"Shipping cost on the entire transaction.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shipsFromPostalCode\",\n              \"description\": \"The postal code of the source shipping location, in any country's format.\\n\\n*Required for Level 3 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"TransactionTaxInformation\",\n          \"description\": \"Information related to taxes on the transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"taxAmount\",\n              \"description\": \"The amount of tax that was included in the total transaction amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"taxExempt\",\n              \"description\": \"Whether the transaction should be considered eligible for tax exemption.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"TransactionTaxInput\",\n          \"description\": \"Information related to taxes on the transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"taxAmount\",\n              \"description\": \"Amount of tax that was included in the total transaction amount. Does not add to the total amount the payment method will be charged.\\n\\n*Required for Level 2 processing* unless `taxExempt` is `true`.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"taxExempt\",\n              \"description\": \"Whether the transaction should be considered eligible for tax exemption.\\n\\n*Required for Level 2 processing*.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"URL\",\n          \"description\": \"A URL string\\npattern: <code>[a-zA-Z0-9+-.]:\\\\\\\\/\\\\\\\\/([-a-zA-Z0-9@:%._\\\\\\\\+~#=]{2,256}\\\\\\\\.?[a-z]{2,4}\\\\\\\\b([-a-zA-Z0-9@:%_\\\\\\\\+.~#?&//=]*))?</code>\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UnionPayConfiguration\",\n          \"description\": \"Configuration for UnionPay cards.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The Braintree merchant account ID with UnionPay processing enabled.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UpdateCreditCardBillingAddressInput\",\n          \"description\": \"Top-level input fields for updating a multi-use credit card to use a new billing address.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"The multi-use credit card for which the billing address will be updated or added.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The new billing address.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account that will be used when verifying the credit card with the new billing address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"Input fields that specify options for verifying the credit card with the new billing address. By default, a verification will be performed. If the verification fails, the update will not be performed.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardVerificationOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UpdateCreditCardBillingAddressPayload\",\n          \"description\": \"Top-level fields returned when updating a multi-use credit card to a new billing address.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The new billing address. Will be `null` if a failed verification prevented the update.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"The verification that was run on the payment method prior to updating the billing address, if present.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Verification\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UpdateCustomerInput\",\n          \"description\": \"Top-level field for updating a customer.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"ID of the customer to be updated.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Input fields for the updates to be made on the customer.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CustomerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UpdateCustomerPayload\",\n          \"description\": \"Top-level fields returned when updating a customer.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customer\",\n              \"description\": \"Information about the customer that was updated.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Customer\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UpdateInStoreLocationInput\",\n          \"description\": \"Input fields for updating an in-store location.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locationId\",\n              \"description\": \"ID of the location to be updated.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"location\",\n              \"description\": \"Input fields to update an in-store location.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"INPUT_OBJECT\",\n                  \"name\": \"InStoreLocationUpdateInput\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UpdateInStoreLocationPayload\",\n          \"description\": \"Top-level fields returned when creating an in-store location.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"location\",\n              \"description\": \"The in-store location.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"InStoreLocation\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UpdateInStoreReaderInput\",\n          \"description\": \"Input fields for updating an in-store reader.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"readerId\",\n              \"description\": \"The ID of the in-store reader to update.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"The new name for the in-store reader.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"locationId\",\n              \"description\": \"The new location ID for the in-store reader.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UpdateTransactionAmountInput\",\n          \"description\": \"Top-level input fields for a updating a transaction's amount.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"ID of the transaction on which to perform the adjustment.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The new total amount to be authorized on a transaction. This value must be greater than 0, and must match the currency format of the merchant account, and cannot be greater than the maximum allowed by the processor.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Amount\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UpdateTransactionCustomFieldsInput\",\n          \"description\": \"Input for creating or updating custom fields on a transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"transactionId\",\n              \"description\": \"The ID of the transaction to update.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"The list of custom fields to update. You must [set up each custom field in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#creating-a-custom-field) prior to passing it with a request.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"INPUT_OBJECT\",\n                      \"name\": \"CustomFieldInput\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UpdateTransactionCustomFieldsPayload\",\n          \"description\": \"Top-level output field from updating custom fields for a specific transaction.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"customFields\",\n              \"description\": \"A list of all custom fields on the updated transaction. Custom fields are [defined in the Control Panel](https://articles.braintreepayments.com/control-panel/custom-fields#store-and-pass-back-fields).\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"CustomField\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UsBankAccountAchMandate\",\n          \"description\": \"Details about the customer's acceptance of ACH terms.\",\n          \"fields\": [\n            {\n              \"name\": \"acceptanceText\",\n              \"description\": \"The text the customer agreed to when setting up ACH.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"acceptedAt\",\n              \"description\": \"Date and time when the text terms were accepted.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UsBankAccountBillingAddressInput\",\n          \"description\": \"A billing address for a US bank account. This is a subset of the fields required on `AddressInput`.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"streetAddress\",\n              \"description\": \"The street address.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"extendedAddress\",\n              \"description\": \"The extended address information—such as an apartment or suite number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"city\",\n              \"description\": \"The city.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"state\",\n              \"description\": \"The state.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"UsStateCode\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"zipCode\",\n              \"description\": \"The ZIP code.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"UsZipCode\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UsBankAccountBusinessOwnerInput\",\n          \"description\": \"The name of the owner of a business US bank account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"businessName\",\n              \"description\": \"The name of the business that owns the account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UsBankAccountConfiguration\",\n          \"description\": \"Configuration for US bank account processing.\",\n          \"fields\": [\n            {\n              \"name\": \"routeId\",\n              \"description\": \"The route ID used to process a US bank account payment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"plaidPublicKey\",\n              \"description\": \"The public key for Plaid to use to log in to a bank account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UsBankAccountDetails\",\n          \"description\": \"Details about a US bank account.\",\n          \"fields\": [\n            {\n              \"name\": \"accountholderName\",\n              \"description\": \"The name of the accountholder. This is either the business name for a business account, or the owner's full name for an individual account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"The bank account type.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"UsBankAccountType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ownershipType\",\n              \"description\": \"The ownership type of the account, i.e. business or personal.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"UsBankAccountOwnershipType\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bankName\",\n              \"description\": \"The name of the bank at which the account exists.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"last4\",\n              \"description\": \"The last four digits of the bank account number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"routingNumber\",\n              \"description\": \"The routing number of the bank.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verified\",\n              \"description\": \"Whether or not the bank account has been verified and can be transacted on.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"achMandate\",\n              \"description\": \"NACHA-mandated proof of acceptance of ACH terms.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"UsBankAccountAchMandate\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UsBankAccountIndividualOwnerInput\",\n          \"description\": \"The name of the owner of a personal US bank account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"firstName\",\n              \"description\": \"The first name of the accountholder.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"The last name of the accountholder.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UsBankAccountInput\",\n          \"description\": \"Input fields for a US bank account object.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"accountNumber\",\n              \"description\": \"The account number of the bank account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"UsBankAccountNumber\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"routingNumber\",\n              \"description\": \"The routing number of the bank that holds the account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"UsBankRoutingNumber\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"The type of account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"UsBankAccountType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"businessOwner\",\n              \"description\": \"Information about the business that owns the account. This should only be specified for business accounts.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"UsBankAccountBusinessOwnerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"individualOwner\",\n              \"description\": \"Information about the individual that owns the account. This should only be specified for individual accounts.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"UsBankAccountIndividualOwnerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address of the account.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"UsBankAccountBillingAddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"achMandate\",\n              \"description\": \"Language used to prove that you have the customer's explicit permission to debit their bank account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"UsBankAccountNumber\",\n          \"description\": \"An account number containing 1-17 digits.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"UsBankAccountOwnershipType\",\n          \"description\": \"The ownership type of US Bank Account.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"BUSINESS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PERSONAL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"UsBankAccountType\",\n          \"description\": \"The type of US Bank Account.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"CHECKING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SAVINGS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNKNOWN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"UsBankAccountVerificationDetails\",\n          \"description\": \"Information specific to verifications of US bank account payment methods.\",\n          \"fields\": [\n            {\n              \"name\": \"method\",\n              \"description\": \"Type of US bank account verification performed.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"UsBankAccountVerificationMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verificationDeterminedAt\",\n              \"description\": \"Time at which the verification was determined to be successful or not. If successful, at this time the payment method will be marked `verified` and you will be able to charge it.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"UsBankAccountVerificationMethod\",\n          \"description\": \"The type of verification on a US bank account payment method. See our [ACH guide](https://articles.braintreepayments.com/guides/payment-methods/ach#verification-methods).\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"INDEPENDENT_CHECK\",\n              \"description\": \"Verification conducted independently by the merchant, not through Braintree.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MICRO_TRANSFERS\",\n              \"description\": \"Verification by micro-deposits transferred to the bank account, which the customer must then confirm. The most reliable method, but takes additional time.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NETWORK_CHECK\",\n              \"description\": \"Verification via account information. Will complete the verification process immediately, but is not supported by all banks.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TOKENIZED_CHECK\",\n              \"description\": \"Verification at the point of tokenization. Requires integration with a third-party provider. Because this requires a different tokenization flow, this method of verification is only supported for vaulting tokenized US bank account logins, and is not supported when re-verifying a US bank account payment method.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"UsBankLoginInput\",\n          \"description\": \"Input fields for a US bank login object.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"publicToken\",\n              \"description\": \"The public token returned from the bank login.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountId\",\n              \"description\": \"The login provider account ID used for the bank login.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"The type of account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"UsBankAccountType\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"businessOwner\",\n              \"description\": \"Information about the business that owns the account. This should only be specified for business accounts.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"UsBankAccountBusinessOwnerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"individualOwner\",\n              \"description\": \"Information about the individual that owns the account. This should only be specified for individual accounts.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"UsBankAccountIndividualOwnerInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The billing address of the account.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"UsBankAccountBillingAddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"achMandate\",\n              \"description\": \"Language used to prove that you have the customer's explicit permission to debit their bank account.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"UsBankRoutingNumber\",\n          \"description\": \"A routing number containing 8 or 9 digits.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"UsStateCode\",\n          \"description\": \"A two-letter code representing a US state or territory.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"AK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"AZ\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"CT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DC\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GU\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"HI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ID\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IL\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"IN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"KS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"KY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ME\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MO\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MP\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MS\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NC\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ND\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NJ\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NV\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OH\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OK\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PR\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"RI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SC\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SD\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TN\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"TX\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UM\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VT\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WA\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WI\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WV\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"WY\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"UsZipCode\",\n          \"description\": \"A US ZIP code. Supports DDDDD and DDDDD-DDDD formats.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"User\",\n          \"description\": \"Details about the user.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Email address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Current status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"UserStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"Full name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"roles\",\n              \"description\": \"Associated roles.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Role\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"UserStatus\",\n          \"description\": \"The status of the user.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ACTIVE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"DELETED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PASSIVE\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PENDING\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUSPENDED\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultCreditCardExternalVaultOptionsInput\",\n          \"description\": \"Options used to indicate when a credit card is externally vaulted.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"verifyingNetworkTransactionId\",\n              \"description\": \"For use if this credit card is stored in an external vault. The network transaction ID of the first _transaction_ after which this credit card was stored in the external vault.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultCreditCardInput\",\n          \"description\": \"Top-level input field for vaulting a credit card so it can be used multiple times.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing single-use credit card payment method to be vaulted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"Input fields that specify options for verifying the credit card before vaulting. By default, a verification will be performed. If the verification fails, the credit card will not be vaulted.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"VaultCreditCardVerificationOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"externalVault\",\n              \"description\": \"Options used to indicate when a credit card is externally vaulted.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"VaultCreditCardExternalVaultOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"ID of the customer to associate the resulting multi-use payment method with.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"accountType\",\n              \"description\": \"The type of account to be used when verifying a combo card.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"CardAccountType\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"A billing address to associate with the vaulted credit card. If billing address data was included when tokenizing the credit card, it will be *merged* with this input value.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"threeDSecurePassThrough\",\n              \"description\": \"Results of a merchant-performed 3D Secure authentication. You will only need to use these fields if you've performed your own integration with a 3D Secure MPI provider (e.g. Cardinal Centinel). Otherwise, Braintree's SDKs handle this for you in our standard 3D Secure integration.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecurePassThroughInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"riskData\",\n              \"description\": \"Customer device information, which is sent directly to supported processors for fraud analysis.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"RiskDataInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultCreditCardVerificationOptionsInput\",\n          \"description\": \"Input fields that specify options for verifying the vaulted credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account to use when verifying the credit card. The verification will use the default merchant account if this field is left blank.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"skip\",\n              \"description\": \"Whether to opt out of verifying the credit card. Defaults to `false` for credit cards that support verification. Clients should only pass `true` in the uncommon scenario that the credit card has been verified externally to Braintree.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount to use to verify the credit card.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"fraudTools\",\n              \"description\": \"Control which fraud tools will be applied to this transaction. Fraud tools cannot be retroactively applied to a transaction if skipped.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardFraudToolsOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultInStorePaymentMethodAfterTransactingInput\",\n          \"description\": \" Specifies behavior for vaulting a single-use payment method for an in-store transaction.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"when\",\n              \"description\": \"Specifies the criteria which must be met to vault this payment method.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"VaultPaymentMethodCriteria\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"qrcOverride\",\n              \"description\": \"Vaulting behavior override for QR code payments.\",\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"VaultQRCOverride\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultLimitedUsePayPalAccountOptionsInput\",\n          \"description\": \"Input fields that provide information about the resulting PayPal account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"amount\",\n              \"description\": \"The total amount of the order. This will be the limit to how much may be captured on the resulting payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Amount\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customField\",\n              \"description\": \"Variable passed directly to PayPal for your own tracking purposes. Customers do not see this value.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": \"Description of the transaction that is displayed to customers in PayPal email receipts.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"orderId\",\n              \"description\": \"The PayPal invoice number. It must be unique in your PayPal business account and can contain a maximum of 127 characters. If specified, transactions created from the resulting payment method will have this orderId.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"Shipping destination address information.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultPayPalBillingAgreementInput\",\n          \"description\": \"Top-level input fields for importing and vaulting a PayPal Billing Agreement.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAgreementId\",\n              \"description\": \"ID of a PayPal Billing Agreement, that was not created through Braintree, to import and vault.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"Optional ID of the customer to associate the resulting payment method with.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"Optional ID of the merchant account associated with the linked PayPal account to be used to retrieve billing agreement details from PayPal. Only used for merchants with the PayPal multi-account feature enabled in Braintree.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"indirectPayee\",\n              \"description\": \"The merchant (payee) PayPal account associated with the PayPal Billing Agreement being vaulted. Only used when the specified merchant account is specially configured to handle indirect PayPal accounts.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PayPalAccountInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VaultPayPalBillingAgreementPayload\",\n          \"description\": \"Top-level fields returned when importing and vaulting a PayPal Billing Agreement.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"The vaulted payment method containing the imported PayPal Billing Agreement.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultPaymentMethodAfterTransactingInput\",\n          \"description\": \" Specifies behavior for vaulting a single-use payment method after transacting with it.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"when\",\n              \"description\": \"Specifies the criteria which must be met to vault this payment method.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"VaultPaymentMethodCriteria\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"VaultPaymentMethodCriteria\",\n          \"description\": \"Defines criteria for vaulting a single-use payment method after transacting with it.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"ALWAYS\",\n              \"description\": \"Always store the single-use payment method after transacting, regardless of the status of the transaction.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ON_SUCCESSFUL_TRANSACTION\",\n              \"description\": \"Only store the single-use payment method if it was successfully authorized.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultPaymentMethodInput\",\n          \"description\": \"Top-level input field for vaulting a payment method so it can be used multiple times.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing single-use payment method to be vaulted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verificationMerchantAccountId\",\n              \"description\": \"Deprecated: This field is included for supporting legacy clients. Please use `verification.merchantAccountId` instead.\\n\\nID of the merchant account to use when verifying the payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"Input fields that specify options for verifying the payment method before vaulting. Only applicable if the payment method is of a type that supports verification. For supported types, verification is performed by default. If the verification fails, the payment method will not be vaulted. For additional, payment method-specific verification options, please see other verification mutations such as `verifyCreditCard` or `verifyUsBankAccount`.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"PaymentMethodVerificationOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"ID of the customer to associate the resulting multi-use payment method with.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"threeDSecurePassThrough\",\n              \"description\": \"Results of a merchant-performed 3D Secure authentication. You will only need to use these fields if you've performed your own integration with a 3D Secure MPI provider (e.g. Cardinal Centinel). Otherwise, Braintree's SDKs handle this for you in our standard 3D Secure integration.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"ThreeDSecurePassThroughInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"riskData\",\n              \"description\": \"Customer device information, which is sent directly to supported processors for fraud analysis.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"RiskDataInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VaultPaymentMethodPayload\",\n          \"description\": \"Top-level output field from vaulting a payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"A payment method that has been stored in a merchant's vault and can be reused.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"The verification that was run on the payment method prior to vaulting.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Verification\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"VaultQRCOverride\",\n          \"description\": \"The override options for QR code vaulting.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"HIDE_QRC\",\n              \"description\": \"Do not show QR code as a payment option, even if it is enabled.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SHOW_QRC_NO_VAULT\",\n              \"description\": \"If QR codes are enabled, show as a payment option, but do not vault.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VaultUsBankAccountInput\",\n          \"description\": \"Top-level input field for vaulting a bank account so it can be used multiple times.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing single-use payment method to be vaulted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verificationMerchantAccountId\",\n              \"description\": \"ID of the merchant account to use when verifying the payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"customerId\",\n              \"description\": \"ID of the customer to associate the resulting multi-use payment method with.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verificationMethod\",\n              \"description\": \"Type of US bank account verification to perform.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"UsBankAccountVerificationMethod\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VenmoAccountDetails\",\n          \"description\": \"Details about a Venmo Account.\",\n          \"fields\": [\n            {\n              \"name\": \"username\",\n              \"description\": \"The Venmo username, as chosen by the user.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"venmoUserId\",\n              \"description\": \"The Venmo user ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VenmoConfiguration\",\n          \"description\": \"Configuration for Pay with Venmo.\",\n          \"fields\": [\n            {\n              \"name\": \"merchantId\",\n              \"description\": \"The Venmo merchant ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"accessToken\",\n              \"description\": \"Authorization to use when tokenizing a Venmo payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"environment\",\n              \"description\": \"The Venmo environment.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"VenmoEnvironment\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"VenmoEnvironment\",\n          \"description\": \"The environment being used for Venmo.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"PRODUCTION\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SANDBOX\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"production\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"sandbox\",\n              \"description\": null,\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VenmoPayerInfo\",\n          \"description\": \"Information about a payer's Venmo account.\",\n          \"fields\": [\n            {\n              \"name\": \"firstName\",\n              \"description\": \"The payer's first name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"The payer's last name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The payer's phone number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"The payer's email address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"EmailAddress\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"externalId\",\n              \"description\": \"The external ID of the payer's Venmo account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"userName\",\n              \"description\": \"The username of the payer's Venmo account.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The payer's billing address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"The payer's shipping address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Address\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VenmoPayerInfoInput\",\n          \"description\": \"Information about a payer's Venmo account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"firstName\",\n              \"description\": \"The payer's first name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"lastName\",\n              \"description\": \"The payer's last name.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"phoneNumber\",\n              \"description\": \"The payer's phone number.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"The payer's email address.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"EmailAddress\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"externalId\",\n              \"description\": \"The external ID of the payer's Venmo account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"userName\",\n              \"description\": \"The username of the payer's Venmo account.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"billingAddress\",\n              \"description\": \"The payer's billing address.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"shippingAddress\",\n              \"description\": \"The payer's shipping address.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"AddressInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Verification\",\n          \"description\": \"A verification reporting whether the payment method has passed your fraud rules and the issuer has ensured it is associated with a valid account.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"legacyId\",\n              \"description\": \"Legacy unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethodSnapshot\",\n              \"description\": \"Snapshot of payment method details that were verified. This will always be present.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"PaymentMethodSnapshot\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethod\",\n              \"description\": \"The multi-use payment method that was verified, if it was vaulted. The details of this PaymentMethod may have changed since it was verified.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentMethod\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"For a credit card, the amount used when performing the verification.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Depending on the type of payment method being verified, some verifications do not have an amount. On a credit card verification, use `paymentMethodVerificationDetails.amount` instead.\"\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"The merchant account used for the verification.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"The current status of this verification, indicating whether the verification was successful. Braintree recommends only vaulting payment methods that are successfully verified.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"VerificationStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"processorResponse\",\n              \"description\": \"Detailed response information from the processor. Will not be present if the verification was rejected prior to contacting the processor.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"VerificationProcessorResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"networkResponse\",\n              \"description\": \"Fields describing the network response to the verification request.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"PaymentNetworkResponse\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Date and time at which the verification was created.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"gatewayRejectionReason\",\n              \"description\": \"The reason the verification was rejected. This will only be set if status is GATEWAY_REJECTED.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"GatewayRejectionReason\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"riskData\",\n              \"description\": \"Risk data evaluated for this verification.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"RiskData\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"paymentMethodVerificationDetails\",\n              \"description\": \"Details unique to the verification based on payment method type being verified.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"UNION\",\n                \"name\": \"VerificationDetails\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"Node\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VerificationConnection\",\n          \"description\": \"A paginated list of verifications.\",\n          \"fields\": [\n            {\n              \"name\": \"edges\",\n              \"description\": \"A list of verifications.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"VerificationConnectionEdge\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"pageInfo\",\n              \"description\": \"Information about the page of verifications contained in `edges`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"PageInfo\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VerificationConnectionEdge\",\n          \"description\": \"A verification within a VerificationConnection.\",\n          \"fields\": [\n            {\n              \"name\": \"cursor\",\n              \"description\": \"The verification's location within the VerificationConnection. Used for requesting additional pages.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"node\",\n              \"description\": \"The verification.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Verification\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"UNION\",\n          \"name\": \"VerificationDetails\",\n          \"description\": \"A union of all possible verification details specific to the type of payment method being verified.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": [\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"UsBankAccountVerificationDetails\",\n              \"ofType\": null\n            },\n            {\n              \"kind\": \"OBJECT\",\n              \"name\": \"CreditCardVerificationDetails\",\n              \"ofType\": null\n            }\n          ]\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VerificationProcessorResponse\",\n          \"description\": \"Detailed response information from the processor.\",\n          \"fields\": [\n            {\n              \"name\": \"legacyCode\",\n              \"description\": \"The [processor response code](https://developers.braintreepayments.com/reference/general/processor-responses/authorization-responses) indicating the result of attempting the verification.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"message\",\n              \"description\": \"The text explanation of the processor response code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"cvvResponse\",\n              \"description\": \"The processing bank's response to the provided CVV.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"avsPostalCodeResponse\",\n              \"description\": \"The processing bank's response to the provided billing postal or zip code.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"avsStreetAddressResponse\",\n              \"description\": \"The processing bank's response to the provided billing street address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"AvsCvvResponseCode\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"additionalInformation\",\n              \"description\": \"If present, any additional information recieved from the processor. May provide further insight into the `legacyCode`.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VerificationSearchInput\",\n          \"description\": \"Input fields for searching for verifications.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Find verifications with an ID or IDs.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchValueInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Find verifications with a given status.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchVerificationStatusInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"createdAt\",\n              \"description\": \"Find verifications with a given created at time.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"SearchTimestampInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"VerificationStatus\",\n          \"description\": \"The status of the verification, indicating whether the payment method was successfully verified. Braintree recommends only vaulting payment methods with successful verifications.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"FAILED\",\n              \"description\": \"Indicates the verification was unsuccessful because of an issue communicating with the processor.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"GATEWAY_REJECTED\",\n              \"description\": \"Indicates that the verification was unsuccessful because the payment method failed one or more fraud checks. In this case, the `gatewayRejectionReason` will indicate which fraud check failed.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PENDING\",\n              \"description\": \"Indicates that the verification is pending.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"PROCESSOR_DECLINED\",\n              \"description\": \"Indicates that the verification was unsuccessful based on the response from the processor.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VERIFIED\",\n              \"description\": \"Indicates that the verification was successful.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VERIFYING\",\n              \"description\": \"Indicates that the verification is in the process of verifying.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VerifoneVendor\",\n          \"description\": \"Verifone specific in-store reader information.\",\n          \"fields\": [\n            {\n              \"name\": \"model\",\n              \"description\": \"Model name or number of reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"osVersion\",\n              \"description\": \"Current OS version running on the reader.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"serialNumber\",\n              \"description\": \"Vendor-specific device serial number.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VerifyCreditCardInput\",\n          \"description\": \"Top-level input field for verifying a multi-use credit card.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing multi-use payment method to be vaulted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account to use when verifying the credit card.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"options\",\n              \"description\": \"Input fields for verifying a credit card.\",\n              \"type\": {\n                \"kind\": \"INPUT_OBJECT\",\n                \"name\": \"CreditCardVerificationOptionsInput\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VerifyPaymentMethodInput\",\n          \"description\": \"Top-level input field for verifying a multi-use payment method.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing multi-use payment method to be verified.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account to use when verifying the payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VerifyPaymentMethodPayload\",\n          \"description\": \"Top-level output field from verifying a payment method.\",\n          \"fields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"verification\",\n              \"description\": \"The verification that was run on the payment method.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Verification\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"INPUT_OBJECT\",\n          \"name\": \"VerifyUsBankAccountInput\",\n          \"description\": \"Top-level input field for retrying a verification on a bank account.\",\n          \"fields\": null,\n          \"inputFields\": [\n            {\n              \"name\": \"clientMutationId\",\n              \"description\": \"An identifier used to reconcile requests and responses. 255 characters maximum.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"paymentMethodId\",\n              \"description\": \"ID of an existing multi-use payment method to be vaulted.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"ID\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"merchantAccountId\",\n              \"description\": \"ID of the merchant account to use when verifying the payment method.\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"defaultValue\": null\n            },\n            {\n              \"name\": \"verificationMethod\",\n              \"description\": \"Type of US bank account verification to perform.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"UsBankAccountVerificationMethod\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ],\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"Viewer\",\n          \"description\": \"Details about the user and merchant authenticated in this request.\",\n          \"fields\": [\n            {\n              \"name\": \"id\",\n              \"description\": \"Unique identifier.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"ID\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `user` for id instead.\"\n            },\n            {\n              \"name\": \"email\",\n              \"description\": \"Email address.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `user` for email instead.\"\n            },\n            {\n              \"name\": \"status\",\n              \"description\": \"Current status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"UserStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `user` for status instead.\"\n            },\n            {\n              \"name\": \"name\",\n              \"description\": \"Full name.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `user` for name instead.\"\n            },\n            {\n              \"name\": \"roles\",\n              \"description\": \"Associated roles.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Role\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `user` for roles instead.\"\n            },\n            {\n              \"name\": \"user\",\n              \"description\": \"Details about the authenticated user.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"User\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"merchant\",\n              \"description\": \"Details about the authenticated merchant.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"Merchant\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"rights\",\n              \"description\": \"Associated rights based on authentication.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"Right\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VisaCheckoutConfiguration\",\n          \"description\": \"Configuration for Visa Checkout.\",\n          \"fields\": [\n            {\n              \"name\": \"apiKey\",\n              \"description\": \"The Visa Checkout API key.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"encryptionKey\",\n              \"description\": \"The Visa Checkout encryption key.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"externalClientId\",\n              \"description\": \"The Visa Checkout external client ID.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"supportedCardBrands\",\n              \"description\": \"A list of card brands supported by the merchant for Visa Checkout.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"ENUM\",\n                    \"name\": \"CreditCardBrandCode\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VisaCheckoutOriginDetails\",\n          \"description\": \"Additional information about the payment method specific to Visa Checkout.\",\n          \"fields\": [\n            {\n              \"name\": \"callId\",\n              \"description\": \"The Visa assigned identifier for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"bin\",\n              \"description\": \"The first 6 digits of the credit card, known as the Bank Identification Number. This BIN may differ from the BIN of the customer's actual card.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"VoidedEvent\",\n          \"description\": \"Accompanying information for a transaction that has been voided.\",\n          \"fields\": [\n            {\n              \"name\": \"status\",\n              \"description\": \"The new status of the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentStatus\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"timestamp\",\n              \"description\": \"Date and time when the transaction was voided.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Timestamp\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"amount\",\n              \"description\": \"The amount of the voided transaction. This should match the authorization amount.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"MonetaryAmount\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"source\",\n              \"description\": \"The source for the transaction change to the new status.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"ENUM\",\n                \"name\": \"PaymentSource\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"terminal\",\n              \"description\": \"Whether or not this is the final state for the transaction.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [\n            {\n              \"kind\": \"INTERFACE\",\n              \"name\": \"PaymentStatusEvent\",\n              \"ofType\": null\n            }\n          ],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"SCALAR\",\n          \"name\": \"Year\",\n          \"description\": \"A four-digit year.\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"__Directive\",\n          \"description\": null,\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": \"The __Directive type represents a Directive that a server supports.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"isRepeatable\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"locations\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"ENUM\",\n                      \"name\": \"__DirectiveLocation\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"args\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"OBJECT\",\n                      \"name\": \"__InputValue\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"onOperation\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `locations`.\"\n            },\n            {\n              \"name\": \"onFragment\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `locations`.\"\n            },\n            {\n              \"name\": \"onField\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": true,\n              \"deprecationReason\": \"Use `locations`.\"\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"__DirectiveLocation\",\n          \"description\": \"An enum describing valid locations where a directive can be placed\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"QUERY\",\n              \"description\": \"Indicates the directive is valid on queries.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"MUTATION\",\n              \"description\": \"Indicates the directive is valid on mutations.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SUBSCRIPTION\",\n              \"description\": \"Indicates the directive is valid on subscriptions.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FIELD\",\n              \"description\": \"Indicates the directive is valid on fields.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAGMENT_DEFINITION\",\n              \"description\": \"Indicates the directive is valid on fragment definitions.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FRAGMENT_SPREAD\",\n              \"description\": \"Indicates the directive is valid on fragment spreads.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INLINE_FRAGMENT\",\n              \"description\": \"Indicates the directive is valid on inline fragments.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"VARIABLE_DEFINITION\",\n              \"description\": \"Indicates the directive is valid on variable definitions.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SCHEMA\",\n              \"description\": \"Indicates the directive is valid on a schema SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"SCALAR\",\n              \"description\": \"Indicates the directive is valid on a scalar SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OBJECT\",\n              \"description\": \"Indicates the directive is valid on an object SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"FIELD_DEFINITION\",\n              \"description\": \"Indicates the directive is valid on a field SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ARGUMENT_DEFINITION\",\n              \"description\": \"Indicates the directive is valid on a field argument SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INTERFACE\",\n              \"description\": \"Indicates the directive is valid on an interface SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNION\",\n              \"description\": \"Indicates the directive is valid on an union SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ENUM\",\n              \"description\": \"Indicates the directive is valid on an enum SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ENUM_VALUE\",\n              \"description\": \"Indicates the directive is valid on an enum value SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INPUT_OBJECT\",\n              \"description\": \"Indicates the directive is valid on an input object SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INPUT_FIELD_DEFINITION\",\n              \"description\": \"Indicates the directive is valid on an input object field SDL definition.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"__EnumValue\",\n          \"description\": null,\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"isDeprecated\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deprecationReason\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"__Field\",\n          \"description\": null,\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"args\",\n              \"description\": null,\n              \"args\": [\n                {\n                  \"name\": \"includeDeprecated\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Boolean\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": \"false\"\n                }\n              ],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"OBJECT\",\n                      \"name\": \"__InputValue\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"__Type\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"isDeprecated\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deprecationReason\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"__InputValue\",\n          \"description\": null,\n          \"fields\": [\n            {\n              \"name\": \"name\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"type\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"__Type\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"defaultValue\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"isDeprecated\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"Boolean\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"deprecationReason\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"__Schema\",\n          \"description\": \"A GraphQL Introspection defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, the entry points for query, mutation, and subscription operations.\",\n          \"fields\": [\n            {\n              \"name\": \"description\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"types\",\n              \"description\": \"A list of all types supported by this server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"OBJECT\",\n                      \"name\": \"__Type\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"queryType\",\n              \"description\": \"The type that query operations will be rooted at.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"OBJECT\",\n                  \"name\": \"__Type\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"mutationType\",\n              \"description\": \"If this server supports mutation, the type that mutation operations will be rooted at.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"__Type\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"directives\",\n              \"description\": \"'A list of all directives supported by this server.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"LIST\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"NON_NULL\",\n                    \"name\": null,\n                    \"ofType\": {\n                      \"kind\": \"OBJECT\",\n                      \"name\": \"__Directive\",\n                      \"ofType\": null\n                    }\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"subscriptionType\",\n              \"description\": \"'If this server support subscription, the type that subscription operations will be rooted at.\",\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"__Type\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"OBJECT\",\n          \"name\": \"__Type\",\n          \"description\": null,\n          \"fields\": [\n            {\n              \"name\": \"kind\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"ENUM\",\n                  \"name\": \"__TypeKind\",\n                  \"ofType\": null\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"name\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"description\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"fields\",\n              \"description\": null,\n              \"args\": [\n                {\n                  \"name\": \"includeDeprecated\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Boolean\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": \"false\"\n                }\n              ],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"__Field\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"interfaces\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"__Type\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"possibleTypes\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"__Type\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"enumValues\",\n              \"description\": null,\n              \"args\": [\n                {\n                  \"name\": \"includeDeprecated\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Boolean\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": \"false\"\n                }\n              ],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"__EnumValue\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"inputFields\",\n              \"description\": null,\n              \"args\": [\n                {\n                  \"name\": \"includeDeprecated\",\n                  \"description\": null,\n                  \"type\": {\n                    \"kind\": \"SCALAR\",\n                    \"name\": \"Boolean\",\n                    \"ofType\": null\n                  },\n                  \"defaultValue\": \"false\"\n                }\n              ],\n              \"type\": {\n                \"kind\": \"LIST\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"NON_NULL\",\n                  \"name\": null,\n                  \"ofType\": {\n                    \"kind\": \"OBJECT\",\n                    \"name\": \"__InputValue\",\n                    \"ofType\": null\n                  }\n                }\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ofType\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"OBJECT\",\n                \"name\": \"__Type\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"specifiedByUrl\",\n              \"description\": null,\n              \"args\": [],\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"inputFields\": null,\n          \"interfaces\": [],\n          \"enumValues\": null,\n          \"possibleTypes\": null\n        },\n        {\n          \"kind\": \"ENUM\",\n          \"name\": \"__TypeKind\",\n          \"description\": \"An enum describing what kind of type a given __Type is\",\n          \"fields\": null,\n          \"inputFields\": null,\n          \"interfaces\": null,\n          \"enumValues\": [\n            {\n              \"name\": \"SCALAR\",\n              \"description\": \"Indicates this type is a scalar. 'specifiedByUrl' is a valid field\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"OBJECT\",\n              \"description\": \"Indicates this type is an object. `fields` and `interfaces` are valid fields.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INTERFACE\",\n              \"description\": \"Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"UNION\",\n              \"description\": \"Indicates this type is a union. `possibleTypes` is a valid field.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"ENUM\",\n              \"description\": \"Indicates this type is an enum. `enumValues` is a valid field.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"INPUT_OBJECT\",\n              \"description\": \"Indicates this type is an input object. `inputFields` is a valid field.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"LIST\",\n              \"description\": \"Indicates this type is a list. `ofType` is a valid field.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            },\n            {\n              \"name\": \"NON_NULL\",\n              \"description\": \"Indicates this type is a non-null. `ofType` is a valid field.\",\n              \"isDeprecated\": false,\n              \"deprecationReason\": null\n            }\n          ],\n          \"possibleTypes\": null\n        }\n      ],\n      \"directives\": [\n        {\n          \"name\": \"include\",\n          \"description\": \"Directs the executor to include this field or fragment only when the `if` argument is true\",\n          \"locations\": [\n            \"FIELD\",\n            \"FRAGMENT_SPREAD\",\n            \"INLINE_FRAGMENT\"\n          ],\n          \"args\": [\n            {\n              \"name\": \"if\",\n              \"description\": \"Included when true.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ]\n        },\n        {\n          \"name\": \"skip\",\n          \"description\": \"Directs the executor to skip this field or fragment when the `if`'argument is true.\",\n          \"locations\": [\n            \"FIELD\",\n            \"FRAGMENT_SPREAD\",\n            \"INLINE_FRAGMENT\"\n          ],\n          \"args\": [\n            {\n              \"name\": \"if\",\n              \"description\": \"Skipped when true.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"Boolean\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ]\n        },\n        {\n          \"name\": \"deprecated\",\n          \"description\": \"Marks the field, argument, input field or enum value as deprecated\",\n          \"locations\": [\n            \"FIELD_DEFINITION\",\n            \"ARGUMENT_DEFINITION\",\n            \"ENUM_VALUE\",\n            \"INPUT_FIELD_DEFINITION\"\n          ],\n          \"args\": [\n            {\n              \"name\": \"reason\",\n              \"description\": \"The reason for the deprecation\",\n              \"type\": {\n                \"kind\": \"SCALAR\",\n                \"name\": \"String\",\n                \"ofType\": null\n              },\n              \"defaultValue\": \"\\\"No longer supported\\\"\"\n            }\n          ]\n        },\n        {\n          \"name\": \"specifiedBy\",\n          \"description\": \"Exposes a URL that specifies the behaviour of this scalar.\",\n          \"locations\": [\n            \"SCALAR\"\n          ],\n          \"args\": [\n            {\n              \"name\": \"url\",\n              \"description\": \"The URL that specifies the behaviour of this scalar.\",\n              \"type\": {\n                \"kind\": \"NON_NULL\",\n                \"name\": null,\n                \"ofType\": {\n                  \"kind\": \"SCALAR\",\n                  \"name\": \"String\",\n                  \"ofType\": null\n                }\n              },\n              \"defaultValue\": null\n            }\n          ]\n        }\n      ]\n    }\n  },\n  \"extensions\": {\n    \"requestId\" : \"1773a68c-af86-410b-aea2-e03390380697\"\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/signal/i18n/HeaderControlledResourceBundleLookup.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.i18n;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\nimport java.util.ResourceBundle.Control;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nonnull;\n\npublic class HeaderControlledResourceBundleLookup {\n\n  private static final int MAX_LOCALES = 15;\n\n  private final ResourceBundleFactory resourceBundleFactory;\n\n  public HeaderControlledResourceBundleLookup() {\n    this(ResourceBundle::getBundle);\n  }\n\n  @VisibleForTesting\n  public HeaderControlledResourceBundleLookup(\n      @Nonnull final ResourceBundleFactory resourceBundleFactory) {\n    this.resourceBundleFactory = Objects.requireNonNull(resourceBundleFactory);\n  }\n\n  @Nonnull\n  private List<Locale> getAcceptableLocales(final List<Locale> acceptableLanguages) {\n    return acceptableLanguages.stream().limit(MAX_LOCALES).distinct().collect(Collectors.toList());\n  }\n\n  @Nonnull\n  public ResourceBundle getResourceBundle(final String baseName, final List<Locale> acceptableLocales) {\n    final List<Locale> deduplicatedLocales = getAcceptableLocales(acceptableLocales);\n    final Locale desiredLocale = deduplicatedLocales.isEmpty() ? Locale.getDefault() : deduplicatedLocales.get(0);\n    // define a control with a fallback order as specified in the header\n    Control control = new Control() {\n      @Override\n      public List<String> getFormats(final String baseName) {\n        Objects.requireNonNull(baseName);\n        return Control.FORMAT_PROPERTIES;\n      }\n\n      @Override\n      public Locale getFallbackLocale(final String baseName, final Locale locale) {\n        Objects.requireNonNull(baseName);\n        if (locale.equals(Locale.getDefault())) {\n          return null;\n        }\n        final int localeIndex = deduplicatedLocales.indexOf(locale);\n        if (localeIndex < 0 || localeIndex >= deduplicatedLocales.size() - 1) {\n          return Locale.getDefault();\n        }\n        // [0, deduplicatedLocales.size() - 2] is now the possible range for localeIndex\n        return deduplicatedLocales.get(localeIndex + 1);\n      }\n    };\n\n    return resourceBundleFactory.createBundle(baseName, desiredLocale, control);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/signal/i18n/ResourceBundleFactory.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.signal.i18n;\n\nimport java.util.Locale;\nimport java.util.ResourceBundle;\n\npublic interface ResourceBundleFactory {\n  ResourceBundle createBundle(String baseName, Locale locale, ResourceBundle.Control control);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.dropwizard.core.Configuration;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.attachments.TusConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.ApnConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.AppleAppStoreConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.AppleDeviceCheckConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BraintreeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.CallQualitySurveyConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.CdnConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.ClientReleaseConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.DefaultAwsCredentialsFactory;\nimport org.whispersystems.textsecuregcm.configuration.DeviceCheckConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.DirectoryV2Configuration;\nimport org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory;\nimport org.whispersystems.textsecuregcm.configuration.DynamoDbTables;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicGrpcAllowListConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.ExternalRequestFilterConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClientFactory;\nimport org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory;\nimport org.whispersystems.textsecuregcm.configuration.FcmConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.GcpAttachmentsConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.GenericZkConfig;\nimport org.whispersystems.textsecuregcm.configuration.GooglePlayBillingConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.GrpcConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.HlrLookupConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.IdlePrimaryDeviceReminderConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.KeyTransparencyServiceConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.LinkDeviceSecretConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.MaxDeviceConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.MessageByteLimitCardinalityEstimatorConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.MessageCacheConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.OpenTelemetryConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.PagedSingleUseKEMPreKeyStoreConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.RegistrationServiceClientFactory;\nimport org.whispersystems.textsecuregcm.configuration.RemoteConfigConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.ReportMessageConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.RetryConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;\nimport org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.ShortCodeExpanderConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SpamFilterConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.StripeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.TlsKeyStoreConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.TurnConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.UnidentifiedDeliveryConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.VirtualThreadConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.ZkConfig;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\n\n/** @noinspection MismatchedQueryAndUpdateOfCollection, WeakerAccess */\npublic class WhisperServerConfiguration extends Configuration {\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private TlsKeyStoreConfiguration tlsKeyStore;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  AwsCredentialsProviderFactory awsCredentialsProvider = new DefaultAwsCredentialsFactory();\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private StripeConfiguration stripe;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private BraintreeConfiguration braintree;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private GooglePlayBillingConfiguration googlePlayBilling;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private AppleAppStoreConfiguration appleAppStore;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private AppleDeviceCheckConfiguration appleDeviceCheck;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private DeviceCheckConfiguration deviceCheck;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private DynamoDbClientFactory dynamoDbClient;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private DynamoDbTables dynamoDbTables;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private GcpAttachmentsConfiguration gcpAttachments;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private CdnConfiguration cdn;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private Cdn3StorageManagerConfiguration cdn3StorageManager;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private OpenTelemetryConfiguration openTelemetry;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private FaultTolerantRedisClusterFactory cacheCluster;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private FaultTolerantRedisClientFactory pubsub;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private DirectoryV2Configuration directoryV2;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private SecureValueRecoveryConfiguration svr2;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private SecureValueRecoveryConfiguration svrb;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private FaultTolerantRedisClusterFactory pushSchedulerCluster;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private FaultTolerantRedisClusterFactory rateLimitersCluster;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private MessageCacheConfiguration messageCache;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private List<MaxDeviceConfiguration> maxDevices = new LinkedList<>();\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private WebSocketConfiguration webSocket = new WebSocketConfiguration();\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private FcmConfiguration fcm;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private ApnConfiguration apn;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private UnidentifiedDeliveryConfiguration unidentifiedDelivery;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private ShortCodeExpanderConfiguration shortCode;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private SecureStorageServiceConfiguration storageService;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private PaymentsServiceConfiguration paymentsService;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private ZkConfig zkConfig;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private GenericZkConfig callingZkConfig;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private GenericZkConfig backupsZkConfig;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private RemoteConfigConfiguration remoteConfig;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private S3ObjectMonitorFactory dynamicConfig;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private BadgesConfiguration badges;\n\n  @Valid\n  @JsonProperty\n  @NotNull\n  private SubscriptionConfiguration subscription;\n\n  @Valid\n  @JsonProperty\n  @NotNull\n  private OneTimeDonationConfiguration oneTimeDonations;\n\n  @Valid\n  @JsonProperty\n  @NotNull\n  private PagedSingleUseKEMPreKeyStoreConfiguration pagedSingleUseKEMPreKeyStore;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private ReportMessageConfiguration reportMessage = new ReportMessageConfiguration();\n\n  @Valid\n  @JsonProperty\n  private SpamFilterConfiguration spamFilter;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private RegistrationServiceClientFactory registrationService;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private TurnConfiguration turn;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private TusConfiguration tus;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private ClientReleaseConfiguration clientRelease = new ClientReleaseConfiguration(Duration.ofHours(4));\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private MessageByteLimitCardinalityEstimatorConfiguration messageByteLimitCardinalityEstimator = new MessageByteLimitCardinalityEstimatorConfiguration(Duration.ofDays(1));\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private LinkDeviceSecretConfiguration linkDevice;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private VirtualThreadConfiguration virtualThread = new VirtualThreadConfiguration();\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private ExternalRequestFilterConfiguration externalRequestFilter;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private KeyTransparencyServiceConfiguration keyTransparencyService;\n\n  @JsonProperty\n  private boolean logMessageDeliveryLoops;\n\n  @JsonProperty\n  private IdlePrimaryDeviceReminderConfiguration idlePrimaryDeviceReminder =\n      new IdlePrimaryDeviceReminderConfiguration(Duration.ofDays(30));\n\n  @JsonProperty\n  private Map<String, @Valid CircuitBreakerConfiguration> circuitBreakers = Collections.emptyMap();\n\n  @JsonProperty\n  private Map<String, @Valid RetryConfiguration> retries = Collections.emptyMap();\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private HlrLookupConfiguration hlrLookup;\n\n  @JsonProperty\n  @Valid\n  @NotNull\n  private RetryConfiguration generalRedisRetry = new RetryConfiguration();\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private GrpcConfiguration grpc;\n\n  @NotNull\n  @Valid\n  @JsonProperty\n  private DynamicGrpcAllowListConfiguration grpcAllowList = new DynamicGrpcAllowListConfiguration();\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private S3ObjectMonitorFactory asnTable;\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private CallQualitySurveyConfiguration callQualitySurvey;\n\n  public TlsKeyStoreConfiguration getTlsKeyStoreConfiguration() {\n    return tlsKeyStore;\n  }\n\n  public AwsCredentialsProviderFactory getAwsCredentialsConfiguration() {\n    return awsCredentialsProvider;\n  }\n\n  public StripeConfiguration getStripe() {\n    return stripe;\n  }\n\n  public BraintreeConfiguration getBraintree() {\n    return braintree;\n  }\n\n  public GooglePlayBillingConfiguration getGooglePlayBilling() {\n    return googlePlayBilling;\n  }\n\n  public AppleAppStoreConfiguration getAppleAppStore() {\n    return appleAppStore;\n  }\n\n  public AppleDeviceCheckConfiguration getAppleDeviceCheck() {\n    return appleDeviceCheck;\n  }\n\n  public DeviceCheckConfiguration getDeviceCheck() {\n    return deviceCheck;\n  }\n\n  public DynamoDbClientFactory getDynamoDbClientConfiguration() {\n    return dynamoDbClient;\n  }\n\n  public DynamoDbTables getDynamoDbTables() {\n    return dynamoDbTables;\n  }\n\n  public ShortCodeExpanderConfiguration getShortCodeRetrieverConfiguration() {\n    return shortCode;\n  }\n\n  public WebSocketConfiguration getWebSocketConfiguration() {\n    return webSocket;\n  }\n\n  public GcpAttachmentsConfiguration getGcpAttachmentsConfiguration() {\n    return gcpAttachments;\n  }\n\n  public FaultTolerantRedisClusterFactory getCacheClusterConfiguration() {\n    return cacheCluster;\n  }\n\n  public FaultTolerantRedisClientFactory getRedisPubSubConfiguration() {\n    return pubsub;\n  }\n\n  public SecureValueRecoveryConfiguration getSvr2Configuration() {\n    return svr2;\n  }\n\n  public SecureValueRecoveryConfiguration getSvrbConfiguration() {\n    return svrb;\n  }\n\n  public DirectoryV2Configuration getDirectoryV2Configuration() {\n    return directoryV2;\n  }\n\n  public SecureStorageServiceConfiguration getSecureStorageServiceConfiguration() {\n    return storageService;\n  }\n\n  public MessageCacheConfiguration getMessageCacheConfiguration() {\n    return messageCache;\n  }\n\n  public FaultTolerantRedisClusterFactory getPushSchedulerCluster() {\n    return pushSchedulerCluster;\n  }\n\n  public FaultTolerantRedisClusterFactory getRateLimitersCluster() {\n    return rateLimitersCluster;\n  }\n\n  public FcmConfiguration getFcmConfiguration() {\n    return fcm;\n  }\n\n  public ApnConfiguration getApnConfiguration() {\n    return apn;\n  }\n\n  public CdnConfiguration getCdnConfiguration() {\n    return cdn;\n  }\n\n  public Cdn3StorageManagerConfiguration getCdn3StorageManagerConfiguration() {\n    return cdn3StorageManager;\n  }\n\n  public OpenTelemetryConfiguration getOpenTelemetryConfiguration() {\n    return openTelemetry;\n  }\n\n  public UnidentifiedDeliveryConfiguration getDeliveryCertificate() {\n    return unidentifiedDelivery;\n  }\n\n  public Map<String, Integer> getMaxDevices() {\n    Map<String, Integer> results = new HashMap<>();\n\n    for (MaxDeviceConfiguration maxDeviceConfiguration : maxDevices) {\n      results.put(maxDeviceConfiguration.getNumber(),\n                  maxDeviceConfiguration.getCount());\n    }\n\n    return results;\n  }\n\n  public PaymentsServiceConfiguration getPaymentsServiceConfiguration() {\n    return paymentsService;\n  }\n\n  public ZkConfig getZkConfig() {\n    return zkConfig;\n  }\n\n  public GenericZkConfig getCallingZkConfig() {\n    return callingZkConfig;\n  }\n\n  public GenericZkConfig getBackupsZkConfig() {\n    return backupsZkConfig;\n  }\n\n  public RemoteConfigConfiguration getRemoteConfigConfiguration() {\n    return remoteConfig;\n  }\n\n  public S3ObjectMonitorFactory getDynamicConfig() {\n    return dynamicConfig;\n  }\n\n  public BadgesConfiguration getBadges() {\n    return badges;\n  }\n\n  public SubscriptionConfiguration getSubscription() {\n    return subscription;\n  }\n\n  public OneTimeDonationConfiguration getOneTimeDonations() {\n    return oneTimeDonations;\n  }\n\n  public PagedSingleUseKEMPreKeyStoreConfiguration getPagedSingleUseKEMPreKeyStore() {\n    return pagedSingleUseKEMPreKeyStore;\n  }\n\n  public ReportMessageConfiguration getReportMessageConfiguration() {\n    return reportMessage;\n  }\n\n  public SpamFilterConfiguration getSpamFilterConfiguration() {\n    return spamFilter;\n  }\n\n  public RegistrationServiceClientFactory getRegistrationServiceConfiguration() {\n    return registrationService;\n  }\n\n  public TurnConfiguration getTurnConfiguration() {\n    return turn;\n  }\n\n  public TusConfiguration getTus() {\n    return tus;\n  }\n\n  public ClientReleaseConfiguration getClientReleaseConfiguration() {\n    return clientRelease;\n  }\n\n  public MessageByteLimitCardinalityEstimatorConfiguration getMessageByteLimitCardinalityEstimator() {\n    return messageByteLimitCardinalityEstimator;\n  }\n\n  public LinkDeviceSecretConfiguration getLinkDeviceSecretConfiguration() {\n    return linkDevice;\n  }\n\n  public VirtualThreadConfiguration getVirtualThreadConfiguration() {\n    return virtualThread;\n  }\n\n  public ExternalRequestFilterConfiguration getExternalRequestFilterConfiguration() {\n    return externalRequestFilter;\n  }\n\n  public KeyTransparencyServiceConfiguration getKeyTransparencyServiceConfiguration() {\n    return keyTransparencyService;\n  }\n\n  public boolean logMessageDeliveryLoops() {\n    return logMessageDeliveryLoops;\n  }\n\n  public IdlePrimaryDeviceReminderConfiguration idlePrimaryDeviceReminderConfiguration() {\n    return idlePrimaryDeviceReminder;\n  }\n\n  public Map<String, CircuitBreakerConfiguration> getCircuitBreakerConfigurations() {\n    return circuitBreakers;\n  }\n\n  public Map<String, RetryConfiguration> getRetryConfigurations() {\n    return retries;\n  }\n\n  public RetryConfiguration getGeneralRedisRetryConfiguration() {\n    return generalRedisRetry;\n  }\n\n  public GrpcConfiguration getGrpc() {\n    return grpc;\n  }\n\n  public DynamicGrpcAllowListConfiguration getGrpcAllowList() {\n    return grpcAllowList;\n  }\n\n  public S3ObjectMonitorFactory getAsnTableConfiguration() {\n    return asnTable;\n  }\n\n  public CallQualitySurveyConfiguration getCallQualitySurveyConfiguration() {\n    return callQualitySurvey;\n  }\n\n  public HlrLookupConfiguration getHlrLookupConfiguration() {\n    return hlrLookup;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/WhisperServerService.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm;\n\nimport static java.util.Objects.requireNonNull;\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.collect.Lists;\nimport com.webauthn4j.appattest.DeviceCheckManager;\nimport io.dropwizard.auth.AuthDynamicFeature;\nimport io.dropwizard.auth.AuthFilter;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.auth.basic.BasicCredentialAuthFilter;\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport io.dropwizard.configuration.EnvironmentVariableSubstitutor;\nimport io.dropwizard.configuration.SubstitutingSourceProvider;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.server.DefaultServerFactory;\nimport io.dropwizard.core.setup.Bootstrap;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.jetty.HttpsConnectorFactory;\nimport io.dropwizard.lifecycle.setup.LifecycleEnvironment;\nimport io.grpc.ServerBuilder;\nimport io.grpc.ServerInterceptors;\nimport io.grpc.ServerServiceDefinition;\nimport io.grpc.netty.NettyServerBuilder;\nimport io.lettuce.core.metrics.MicrometerCommandLatencyRecorder;\nimport io.lettuce.core.metrics.MicrometerOptions;\nimport io.lettuce.core.resource.ClientResources;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;\nimport io.netty.channel.socket.nio.NioDatagramChannel;\nimport io.netty.channel.socket.nio.NioSocketChannel;\nimport io.netty.resolver.ResolvedAddressTypes;\nimport io.netty.resolver.dns.DnsNameResolver;\nimport io.netty.resolver.dns.DnsNameResolverBuilder;\nimport jakarta.servlet.DispatcherType;\nimport jakarta.servlet.Filter;\nimport jakarta.servlet.ServletRegistration;\nimport java.io.ByteArrayInputStream;\nimport java.net.InetSocketAddress;\nimport java.net.http.HttpClient;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.ServiceLoader;\nimport java.util.Set;\nimport java.util.concurrent.BlockingQueue;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.SynchronousQueue;\nimport java.util.concurrent.ThreadPoolExecutor;\nimport java.util.function.Function;\nimport java.util.stream.Stream;\nimport org.eclipse.jetty.websocket.core.WebSocketExtensionRegistry;\nimport org.eclipse.jetty.websocket.core.server.WebSocketServerComponents;\nimport org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.signal.i18n.HeaderControlledResourceBundleLookup;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProviderImpl;\nimport org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.auth.AccountAuthenticator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.CertificateGenerator;\nimport org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.auth.IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter;\nimport org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;\nimport org.whispersystems.textsecuregcm.auth.grpc.ProhibitAuthenticationInterceptor;\nimport org.whispersystems.textsecuregcm.auth.grpc.RequireAuthenticationInterceptor;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.BackupsDb;\nimport org.whispersystems.textsecuregcm.backup.Cdn3BackupCredentialGenerator;\nimport org.whispersystems.textsecuregcm.backup.Cdn3RemoteStorageManager;\nimport org.whispersystems.textsecuregcm.backup.SecureValueRecoveryBCredentialsGeneratorFactory;\nimport org.whispersystems.textsecuregcm.badges.ConfiguredProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.captcha.CaptchaChecker;\nimport org.whispersystems.textsecuregcm.captcha.CaptchaClient;\nimport org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;\nimport org.whispersystems.textsecuregcm.captcha.ShortCodeExpander;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretStore;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.controllers.AccountControllerV2;\nimport org.whispersystems.textsecuregcm.controllers.ArchiveController;\nimport org.whispersystems.textsecuregcm.controllers.AttachmentControllerV4;\nimport org.whispersystems.textsecuregcm.controllers.CallLinkController;\nimport org.whispersystems.textsecuregcm.controllers.CallQualitySurveyController;\nimport org.whispersystems.textsecuregcm.controllers.CallRoutingControllerV2;\nimport org.whispersystems.textsecuregcm.controllers.CertificateController;\nimport org.whispersystems.textsecuregcm.controllers.ChallengeController;\nimport org.whispersystems.textsecuregcm.controllers.DeviceCheckController;\nimport org.whispersystems.textsecuregcm.controllers.DeviceController;\nimport org.whispersystems.textsecuregcm.controllers.DirectoryV2Controller;\nimport org.whispersystems.textsecuregcm.controllers.DonationController;\nimport org.whispersystems.textsecuregcm.controllers.KeepAliveController;\nimport org.whispersystems.textsecuregcm.controllers.KeyTransparencyController;\nimport org.whispersystems.textsecuregcm.controllers.KeysController;\nimport org.whispersystems.textsecuregcm.controllers.MessageController;\nimport org.whispersystems.textsecuregcm.controllers.OneTimeDonationController;\nimport org.whispersystems.textsecuregcm.controllers.PaymentsController;\nimport org.whispersystems.textsecuregcm.controllers.ProfileController;\nimport org.whispersystems.textsecuregcm.controllers.ProvisioningController;\nimport org.whispersystems.textsecuregcm.controllers.RegistrationController;\nimport org.whispersystems.textsecuregcm.controllers.RemoteConfigController;\nimport org.whispersystems.textsecuregcm.controllers.SecureStorageController;\nimport org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;\nimport org.whispersystems.textsecuregcm.controllers.StickerController;\nimport org.whispersystems.textsecuregcm.controllers.SubscriptionController;\nimport org.whispersystems.textsecuregcm.controllers.VerificationController;\nimport org.whispersystems.textsecuregcm.currency.CoinGeckoClient;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\nimport org.whispersystems.textsecuregcm.currency.FixerClient;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.filters.ExternalRequestFilter;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.filters.RemoteDeprecationFilter;\nimport org.whispersystems.textsecuregcm.filters.RequestStatisticsFilter;\nimport org.whispersystems.textsecuregcm.filters.RestDeprecationFilter;\nimport org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;\nimport org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.AccountsGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.AttachmentsGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.BackupsAnonymousGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.BackupsGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.DevicesGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.GroupSendTokenUtil;\nimport org.whispersystems.textsecuregcm.grpc.GrpcAllowListInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.KeysAnonymousGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.KeysGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.MessagesAnonymousGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.MessagesGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.MetricServerInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.PaymentsGrpcService;\nimport org.whispersystems.textsecuregcm.grpc.RequestAttributesInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.ValidatingInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.net.ManagedGrpcServer;\nimport org.whispersystems.textsecuregcm.grpc.net.ManagedNioEventLoopGroup;\nimport org.whispersystems.textsecuregcm.jetty.JettyHttpConfigurationCustomizer;\nimport org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.limits.NoopMessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.limits.PushChallengeManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;\nimport org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.limits.RedisMessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.mappers.BackupExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.IOExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.InvalidWebsocketAddressExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.ObsoletePhoneNumberFormatExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.metrics.MetricsApplicationEventListener;\nimport org.whispersystems.textsecuregcm.metrics.MetricsHttpChannelListener;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.MicrometerAwsSdkMetricPublisher;\nimport org.whispersystems.textsecuregcm.metrics.ReportedMessageMetricsListener;\nimport org.whispersystems.textsecuregcm.metrics.TlsCertificateExpirationUtil;\nimport org.whispersystems.textsecuregcm.metrics.TrafficSource;\nimport org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;\nimport org.whispersystems.textsecuregcm.push.APNSender;\nimport org.whispersystems.textsecuregcm.push.FcmSender;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.ProvisioningManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.push.ReceiptSender;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.ConnectionEventLogger;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport org.whispersystems.textsecuregcm.s3.PolicySigner;\nimport org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;\nimport org.whispersystems.textsecuregcm.s3.S3MonitoringSupplier;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;\nimport org.whispersystems.textsecuregcm.spam.RegistrationFraudChecker;\nimport org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.spam.SpamFilter;\nimport org.whispersystems.textsecuregcm.storage.AccountLockManager;\nimport org.whispersystems.textsecuregcm.storage.Accounts;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ChangeNumberManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleases;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.storage.MessagesCache;\nimport org.whispersystems.textsecuregcm.storage.MessagesDynamoDb;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;\nimport org.whispersystems.textsecuregcm.storage.PagedSingleUseKEMPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.PersistentTimer;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.Profiles;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;\nimport org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.RemoteConfigs;\nimport org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;\nimport org.whispersystems.textsecuregcm.storage.RepeatedUseECSignedPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.RepeatedUseKEMSignedPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageManager;\nimport org.whispersystems.textsecuregcm.storage.SingleUseECPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.SubscriptionManager;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions;\nimport org.whispersystems.textsecuregcm.storage.VerificationSessionManager;\nimport org.whispersystems.textsecuregcm.storage.VerificationSessions;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckTrustAnchor;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreClient;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;\nimport org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;\nimport org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;\nimport org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;\nimport org.whispersystems.textsecuregcm.subscriptions.StripeManager;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;\nimport org.whispersystems.textsecuregcm.telephony.hlrlookup.HlrLookupCarrierDataProvider;\nimport org.whispersystems.textsecuregcm.util.BufferingInterceptor;\nimport org.whispersystems.textsecuregcm.util.ManagedAwsCrt;\nimport org.whispersystems.textsecuregcm.util.ManagedExecutors;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;\nimport org.whispersystems.textsecuregcm.util.VirtualExecutorServiceProvider;\nimport org.whispersystems.textsecuregcm.util.VirtualThreadPinEventMonitor;\nimport org.whispersystems.textsecuregcm.util.logging.LoggingUnhandledExceptionMapper;\nimport org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;\nimport org.whispersystems.textsecuregcm.websocket.AuthenticatedConnectListener;\nimport org.whispersystems.textsecuregcm.websocket.NoContextTakeoverPerMessageDeflateExtension;\nimport org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener;\nimport org.whispersystems.textsecuregcm.websocket.WebSocketAccountAuthenticator;\nimport org.whispersystems.textsecuregcm.workers.BackupMetricsCommand;\nimport org.whispersystems.textsecuregcm.workers.BackupUsageRecalculationCommand;\nimport org.whispersystems.textsecuregcm.workers.CertificateCommand;\nimport org.whispersystems.textsecuregcm.workers.CheckDynamicConfigurationCommand;\nimport org.whispersystems.textsecuregcm.workers.ClearIssuedReceiptRedemptionsCommand;\nimport org.whispersystems.textsecuregcm.workers.DeleteUserCommand;\nimport org.whispersystems.textsecuregcm.workers.IdleDeviceNotificationSchedulerFactory;\nimport org.whispersystems.textsecuregcm.workers.MessagePersisterServiceCommand;\nimport org.whispersystems.textsecuregcm.workers.NotifyIdleDevicesCommand;\nimport org.whispersystems.textsecuregcm.workers.ProcessScheduledJobsServiceCommand;\nimport org.whispersystems.textsecuregcm.workers.RegenerateSecondaryDynamoDbTableDataCommand;\nimport org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;\nimport org.whispersystems.textsecuregcm.workers.RemoveExpiredBackupsCommand;\nimport org.whispersystems.textsecuregcm.workers.RemoveExpiredLinkedDevicesCommand;\nimport org.whispersystems.textsecuregcm.workers.RemoveExpiredUsernameHoldsCommand;\nimport org.whispersystems.textsecuregcm.workers.RemoveOrphanedPreKeyPagesCommand;\nimport org.whispersystems.textsecuregcm.workers.ScheduledApnPushNotificationSenderServiceCommand;\nimport org.whispersystems.textsecuregcm.workers.ServerVersionCommand;\nimport org.whispersystems.textsecuregcm.workers.SetRequestLoggingEnabledTask;\nimport org.whispersystems.textsecuregcm.workers.SetUserDiscoverabilityCommand;\nimport org.whispersystems.textsecuregcm.workers.UnlinkDeviceCommand;\nimport org.whispersystems.textsecuregcm.workers.UnlinkDevicesWithIdlePrimaryCommand;\nimport org.whispersystems.textsecuregcm.workers.ZkParamsCommand;\nimport org.whispersystems.websocket.WebSocketResourceProviderFactory;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.s3.S3AsyncClient;\n\npublic class WhisperServerService extends Application<WhisperServerConfiguration> {\n\n  private static final Logger log = LoggerFactory.getLogger(WhisperServerService.class);\n\n  public static final String SECRETS_BUNDLE_FILE_NAME_PROPERTY = \"secrets.bundle.filename\";\n\n  @Override\n  public void initialize(final Bootstrap<WhisperServerConfiguration> bootstrap) {\n    // `SecretStore` needs to be initialized before Dropwizard reads the main application config file.\n    final String secretsBundleFileName = requireNonNull(\n        System.getProperty(SECRETS_BUNDLE_FILE_NAME_PROPERTY),\n        \"Application requires property [%s] to be provided\".formatted(SECRETS_BUNDLE_FILE_NAME_PROPERTY));\n    final SecretStore secretStore = SecretStore.fromYamlFileSecretsBundle(secretsBundleFileName);\n    SecretsModule.INSTANCE.setSecretStore(secretStore);\n\n    // Initializing SystemMapper here because parsing of the main application config happens before `run()` method is called.\n    SystemMapper.configureMapper(bootstrap.getObjectMapper());\n\n    // Enable variable substitution with environment variables\n    // https://www.dropwizard.io/en/stable/manual/core.html#environment-variables\n    final EnvironmentVariableSubstitutor substitutor = new EnvironmentVariableSubstitutor(true);\n    final SubstitutingSourceProvider provider =\n        new SubstitutingSourceProvider(bootstrap.getConfigurationSourceProvider(), substitutor);\n    bootstrap.setConfigurationSourceProvider(provider);\n\n    bootstrap.addCommand(new DeleteUserCommand());\n    bootstrap.addCommand(new CertificateCommand());\n    bootstrap.addCommand(new ZkParamsCommand());\n    bootstrap.addCommand(new ServerVersionCommand());\n    bootstrap.addCommand(new CheckDynamicConfigurationCommand());\n    bootstrap.addCommand(new SetUserDiscoverabilityCommand());\n    bootstrap.addCommand(new UnlinkDeviceCommand());\n    bootstrap.addCommand(new ScheduledApnPushNotificationSenderServiceCommand());\n    bootstrap.addCommand(new MessagePersisterServiceCommand());\n    bootstrap.addCommand(new RemoveExpiredAccountsCommand(Clock.systemUTC()));\n    bootstrap.addCommand(new RemoveExpiredUsernameHoldsCommand(Clock.systemUTC()));\n    bootstrap.addCommand(new RemoveExpiredBackupsCommand(Clock.systemUTC()));\n    bootstrap.addCommand(new RemoveOrphanedPreKeyPagesCommand(Clock.systemUTC()));\n    bootstrap.addCommand(new BackupMetricsCommand(Clock.systemUTC()));\n    bootstrap.addCommand(new BackupUsageRecalculationCommand());\n    bootstrap.addCommand(new RemoveExpiredLinkedDevicesCommand());\n    bootstrap.addCommand(new UnlinkDevicesWithIdlePrimaryCommand(Clock.systemUTC()));\n    bootstrap.addCommand(new NotifyIdleDevicesCommand());\n    bootstrap.addCommand(new ClearIssuedReceiptRedemptionsCommand());\n\n    bootstrap.addCommand(new ProcessScheduledJobsServiceCommand(\"process-idle-device-notification-jobs\",\n        \"Processes scheduled jobs to send notifications to idle devices\",\n        new IdleDeviceNotificationSchedulerFactory()));\n\n    bootstrap.addCommand(new RegenerateSecondaryDynamoDbTableDataCommand());\n\n    ServiceLoader.load(SpamFilter.class)\n        .stream()\n        .map(ServiceLoader.Provider::get)\n        .flatMap(spamFilter -> spamFilter.getCommands().stream())\n        .forEach(bootstrap::addCommand);\n  }\n\n  @Override\n  public String getName() {\n    return \"whisper-server\";\n  }\n\n  @Override\n  public void run(WhisperServerConfiguration config, Environment environment) throws Exception {\n    final Clock clock = Clock.systemUTC();\n    final int availableProcessors = Runtime.getRuntime().availableProcessors();\n\n    final AwsCredentialsProvider awsCredentialsProvider = config.getAwsCredentialsConfiguration().build();\n\n    UncaughtExceptionHandler.register();\n\n    config.getCircuitBreakerConfigurations().forEach((name, configuration) ->\n        ResilienceUtil.getCircuitBreakerRegistry().addConfiguration(name, configuration.toCircuitBreakerConfig()));\n\n    config.getRetryConfigurations().forEach((name, configuration) ->\n        ResilienceUtil.getRetryRegistry().addConfiguration(name, configuration.toRetryConfigBuilder().build()));\n\n    ResilienceUtil.setGeneralRedisRetryConfiguration(config.getGeneralRedisRetryConfiguration());\n\n    ScheduledExecutorService dynamicConfigurationExecutor = ScheduledExecutorServiceBuilder.of(environment, \"dynamicConfiguration\")\n        .threads(1).build();\n\n    DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        new DynamicConfigurationManager<>(\n            config.getDynamicConfig().build(awsCredentialsProvider, dynamicConfigurationExecutor), DynamicConfiguration.class);\n    dynamicConfigurationManager.start();\n\n    MetricsUtil.configureRegistries(config, environment, dynamicConfigurationManager);\n    MetricsUtil.configureLogging(config, environment);\n\n    ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);\n\n    if (config.getServerFactory() instanceof DefaultServerFactory defaultServerFactory) {\n      defaultServerFactory.getApplicationConnectors()\n          .forEach(connectorFactory -> {\n            if (connectorFactory instanceof HttpsConnectorFactory h) {\n              h.setKeyStorePassword(config.getTlsKeyStoreConfiguration().password().value());\n\n              TlsCertificateExpirationUtil.configureMetrics(h.getKeyStorePath(), h.getKeyStorePassword(), h.getKeyStoreType(), h.getKeyStoreProvider());\n            }\n          });\n    }\n\n    environment.lifecycle().addEventListener(new JettyHttpConfigurationCustomizer());\n\n    HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup =\n        new HeaderControlledResourceBundleLookup();\n    ConfiguredProfileBadgeConverter profileBadgeConverter = new ConfiguredProfileBadgeConverter(\n        clock, config.getBadges(), headerControlledResourceBundleLookup);\n    BankMandateTranslator bankMandateTranslator = new BankMandateTranslator(headerControlledResourceBundleLookup);\n    PayPalDonationsTranslator payPalDonationsTranslator =\n        new PayPalDonationsTranslator(headerControlledResourceBundleLookup);\n\n    environment.lifecycle().manage(new ManagedAwsCrt());\n\n    final ExecutorService awsSdkMetricsExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"awsSdkMetrics\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n\n    final DynamoDbAsyncClient dynamoDbAsyncClient = config.getDynamoDbClientConfiguration()\n        .buildAsyncClient(awsCredentialsProvider, new MicrometerAwsSdkMetricPublisher(awsSdkMetricsExecutor, \"dynamoDbAsync\"));\n\n    final DynamoDbClient dynamoDbClient = config.getDynamoDbClientConfiguration()\n        .buildSyncClient(awsCredentialsProvider, new MicrometerAwsSdkMetricPublisher(awsSdkMetricsExecutor, \"dynamoDbSync\"));\n\n    final AwsCredentialsProvider cdnCredentialsProvider = config.getCdnConfiguration().credentials().build();\n    final S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()\n        .credentialsProvider(cdnCredentialsProvider)\n        .region(Region.of(config.getCdnConfiguration().region()))\n        .endpointOverride(config.getCdnConfiguration().endpointOverride())\n        .build();\n\n    BlockingQueue<Runnable> messageDeletionQueue = new LinkedBlockingQueue<>();\n    Metrics.gaugeCollectionSize(name(getClass(), \"messageDeletionQueueSize\"), Collections.emptyList(),\n        messageDeletionQueue);\n    ExecutorService messageDeletionAsyncExecutor = ExecutorServiceBuilder.of(environment, \"messageDeletionAsyncExecutor\")\n        .minThreads(2)\n        .maxThreads(2)\n        .allowCoreThreadTimeOut(true)\n        .workQueue(messageDeletionQueue).build();\n\n    Accounts accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        dynamoDbAsyncClient,\n        config.getDynamoDbTables().getAccounts().getTableName(),\n        config.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),\n        config.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),\n        config.getDynamoDbTables().getAccounts().getUsernamesTableName(),\n        config.getDynamoDbTables().getDeletedAccounts().getTableName(),\n        config.getDynamoDbTables().getAccounts().getUsedLinkDeviceTokensTableName());\n    ClientReleases clientReleases = new ClientReleases(dynamoDbAsyncClient,\n        config.getDynamoDbTables().getClientReleases().getTableName());\n    PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbAsyncClient,\n        config.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());\n    Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,\n        config.getDynamoDbTables().getProfiles().getTableName());\n\n    S3AsyncClient asyncKeysS3Client = S3AsyncClient.builder()\n        .credentialsProvider(awsCredentialsProvider)\n        .region(Region.of(config.getPagedSingleUseKEMPreKeyStore().region()))\n        .endpointOverride(config.getPagedSingleUseKEMPreKeyStore().endpointOverride())\n        .build();\n    KeysManager keysManager = new KeysManager(\n        new SingleUseECPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcKeys().getTableName()),\n        new PagedSingleUseKEMPreKeyStore(\n            dynamoDbAsyncClient,\n            asyncKeysS3Client,\n            config.getDynamoDbTables().getPagedKemKeys().getTableName(),\n            config.getPagedSingleUseKEMPreKeyStore().bucket()),\n        new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getEcSignedPreKeys().getTableName()),\n        new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, config.getDynamoDbTables().getKemLastResortKeys().getTableName()));\n    MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,\n        config.getDynamoDbTables().getMessages().getTableName(),\n        config.getDynamoDbTables().getMessages().getExpiration(),\n        messageDeletionAsyncExecutor, experimentEnrollmentManager);\n    RemoteConfigs remoteConfigs = new RemoteConfigs(dynamoDbClient,\n        config.getDynamoDbTables().getRemoteConfig().getTableName());\n    PushChallengeDynamoDb pushChallengeDynamoDb = new PushChallengeDynamoDb(dynamoDbClient,\n        config.getDynamoDbTables().getPushChallenge().getTableName());\n    ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, dynamoDbAsyncClient,\n        config.getDynamoDbTables().getReportMessage().getTableName(),\n        config.getReportMessageConfiguration().getReportTtl());\n    RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(\n        config.getDynamoDbTables().getRegistrationRecovery().getTableName(),\n        config.getDynamoDbTables().getRegistrationRecovery().getExpiration(),\n        dynamoDbAsyncClient,\n        clock);\n\n    final VerificationSessions verificationSessions = new VerificationSessions(dynamoDbAsyncClient,\n        config.getDynamoDbTables().getVerificationSessions().getTableName(), clock);\n\n    final ClientResources sharedClientResources = ClientResources.builder()\n        .commandLatencyRecorder(\n            new MicrometerCommandLatencyRecorder(Metrics.globalRegistry, MicrometerOptions.builder().build()))\n        .build();\n    ConnectionEventLogger.logConnectionEvents(sharedClientResources);\n\n    FaultTolerantRedisClusterClient cacheCluster = config.getCacheClusterConfiguration()\n        .build(\"main_cache\", sharedClientResources.mutate());\n    FaultTolerantRedisClusterClient messagesCluster =\n        config.getMessageCacheConfiguration().getRedisClusterConfiguration()\n            .build(\"messages\", sharedClientResources.mutate());\n    FaultTolerantRedisClusterClient pushSchedulerCluster = config.getPushSchedulerCluster().build(\"push_scheduler\",\n        sharedClientResources.mutate());\n    FaultTolerantRedisClusterClient rateLimitersCluster = config.getRateLimitersCluster().build(\"rate_limiters\",\n        sharedClientResources.mutate());\n\n    FaultTolerantRedisClient pubsubClient =\n        config.getRedisPubSubConfiguration().build(\"pubsub\", sharedClientResources);\n\n    final BlockingQueue<Runnable> receiptSenderQueue = new LinkedBlockingQueue<>();\n    Metrics.gaugeCollectionSize(name(getClass(), \"receiptSenderQueue\"), Collections.emptyList(), receiptSenderQueue);\n    final BlockingQueue<Runnable> fcmSenderQueue = new LinkedBlockingQueue<>();\n    Metrics.gaugeCollectionSize(name(getClass(), \"fcmSenderQueue\"), Collections.emptyList(), fcmSenderQueue);\n    final BlockingQueue<Runnable> messageDeliveryQueue = new LinkedBlockingQueue<>();\n    Metrics.gaugeCollectionSize(MetricsUtil.name(getClass(), \"messageDeliveryQueue\"), Collections.emptyList(),\n        messageDeliveryQueue);\n\n    ScheduledExecutorService recurringJobExecutor = ScheduledExecutorServiceBuilder.of(environment, \"recurringJob\").threads(6).build();\n    ExecutorService apnSenderExecutor = ExecutorServiceBuilder.of(environment, \"apnSender\")\n        .maxThreads(1).minThreads(1).build();\n    ExecutorService fcmSenderExecutor = ExecutorServiceBuilder.of(environment, \"fcmSender\")\n        .maxThreads(32).minThreads(32).workQueue(fcmSenderQueue).build();\n    ExecutorService secureValueRecoveryServiceExecutor = ExecutorServiceBuilder.of(environment, \"secureValueRecoveryService\")\n        .maxThreads(1).minThreads(1).build();\n    ExecutorService storageServiceExecutor = ExecutorServiceBuilder.of(environment, \"storageService\")\n        .maxThreads(1).minThreads(1).build();\n    ExecutorService virtualThreadEventLoggerExecutor = ExecutorServiceBuilder.of(environment, \"virtualThreadEventLogger\")\n        .minThreads(1).maxThreads(1).build();\n    ExecutorService asyncOperationQueueingExecutor = ExecutorServiceBuilder.of(environment, \"asyncOperationQueueing\")\n        .minThreads(1).maxThreads(1).build();\n\n    final ScheduledExecutorService retryExecutor = ScheduledExecutorServiceBuilder.of(environment, \"retry\")\n        .threads(16).build();\n    final ScheduledExecutorService registrationIdentityTokenRefreshExecutor =\n      ScheduledExecutorServiceBuilder.of(environment, \"registrationIdentityTokenRefresh\").threads(1).build();\n\n    Scheduler messageDeliveryScheduler = Schedulers.fromExecutorService(\n        ExecutorServiceBuilder.of(environment, \"messageDelivery\")\n            .minThreads(20)\n            .maxThreads(20)\n            .workQueue(messageDeliveryQueue)\n            .build(),\n        \"messageDelivery\");\n\n    // TODO: generally speaking this is a DynamoDB I/O executor for the accounts table; we should eventually have a general executor for speaking to the accounts table, but most of the server is still synchronous so this isn't widely useful yet\n    ExecutorService batchIdentityCheckExecutor = ExecutorServiceBuilder.of(environment, \"batchIdentityCheck\").minThreads(32).maxThreads(32).build();\n\n    ExecutorService receiptSenderExecutor = ExecutorServiceBuilder.of(environment, \"receiptSender\")\n        .maxThreads(2)\n        .minThreads(2)\n        .workQueue(receiptSenderQueue)\n        .rejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy())\n        .build();\n    ExecutorService registrationCallbackExecutor = ExecutorServiceBuilder.of(environment, \"registration\")\n        .maxThreads(2)\n        .minThreads(2)\n        .build();\n    ExecutorService accountLockExecutor = ExecutorServiceBuilder.of(environment, \"accountLock\")\n        .minThreads(8)\n        .maxThreads(8)\n        .build();\n    // unbounded executor (same as cachedThreadPool)\n    ExecutorService remoteStorageHttpExecutor = ExecutorServiceBuilder.of(environment, \"remoteStorage\")\n        .minThreads(0)\n        .maxThreads(Integer.MAX_VALUE)\n        .workQueue(new SynchronousQueue<>())\n        .keepAliveTime(io.dropwizard.util.Duration.seconds(60L))\n        .build();\n    ExecutorService cloudflareTurnHttpExecutor = ExecutorServiceBuilder.of(environment, \"cloudflareTurn\")\n        .maxThreads(2)\n        .minThreads(2)\n        .build();\n    ExecutorService hlrLookupHttpExecutor = ExecutorServiceBuilder.of(environment, \"hlrLookup\")\n        .maxThreads(2)\n        .minThreads(2)\n        .build();\n\n    ExecutorService subscriptionProcessorExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"subscriptionProcessor\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n    ExecutorService clientEventExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"clientEvent\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n    ExecutorService disconnectionRequestListenerExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"disconnectionRequest\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n    ExecutorService callQualitySurveyPubSubExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"callQualitySurvey\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n\n    ScheduledExecutorService cloudflareTurnRetryExecutor = ScheduledExecutorServiceBuilder.of(environment, \"cloudflareTurnRetry\").threads(1).build();\n    ScheduledExecutorService messagePollExecutor = ScheduledExecutorServiceBuilder.of(environment, \"messagePollExecutor\").threads(1).build();\n    ScheduledExecutorService provisioningWebsocketTimeoutExecutor = ScheduledExecutorServiceBuilder.of(environment, \"provisioningWebsocketTimeout\").threads(1).build();\n    ScheduledExecutorService jmxDumper = ScheduledExecutorServiceBuilder.of(environment, \"jmxDumper\").threads(1).build();\n\n    final ManagedNioEventLoopGroup dnsResolutionEventLoopGroup = new ManagedNioEventLoopGroup();\n    final DnsNameResolver cloudflareDnsResolver = new DnsNameResolverBuilder(dnsResolutionEventLoopGroup.next())\n            .resolvedAddressTypes(ResolvedAddressTypes.IPV6_PREFERRED)\n            .completeOncePreferredResolved(false)\n            .channelType(NioDatagramChannel.class)\n            .socketChannelType(NioSocketChannel.class)\n            .build();\n\n    ExternalServiceCredentialsGenerator directoryV2CredentialsGenerator = DirectoryV2Controller.credentialsGenerator(\n        config.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration());\n    ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator(\n        config.getSecureStorageServiceConfiguration());\n    ExternalServiceCredentialsGenerator paymentsCredentialsGenerator = PaymentsController.credentialsGenerator(\n        config.getPaymentsServiceConfiguration());\n    ExternalServiceCredentialsGenerator svr2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(\n        config.getSvr2Configuration());\n    ExternalServiceCredentialsGenerator svrbCredentialsGenerator =\n        SecureValueRecoveryBCredentialsGeneratorFactory.svrbCredentialsGenerator(config.getSvrbConfiguration());\n\n    final S3MonitoringSupplier<AsnInfoProvider> asnInfoProviderSupplier = new S3MonitoringSupplier<>(\n        recurringJobExecutor,\n        awsCredentialsProvider,\n        config.getAsnTableConfiguration(),\n        AsnInfoProviderImpl::fromTsvGz,\n        AsnInfoProvider.EMPTY,\n        \"AsnManager\");\n\n    RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n        new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);\n    UsernameHashZkProofVerifier usernameHashZkProofVerifier = new UsernameHashZkProofVerifier();\n\n    final CarrierDataProvider carrierDataProvider =\n        new HlrLookupCarrierDataProvider(config.getHlrLookupConfiguration().apiKey().value(),\n            config.getHlrLookupConfiguration().apiSecret().value(),\n            hlrLookupHttpExecutor,\n            config.getHlrLookupConfiguration().circuitBreakerConfigurationName(),\n            config.getHlrLookupConfiguration().retryConfigurationName(),\n            retryExecutor);\n\n    RegistrationServiceClient registrationServiceClient = config.getRegistrationServiceConfiguration()\n        .build(environment, registrationCallbackExecutor, registrationIdentityTokenRefreshExecutor);\n    KeyTransparencyServiceClient keyTransparencyServiceClient = new KeyTransparencyServiceClient(\n        config.getKeyTransparencyServiceConfiguration().host(),\n        config.getKeyTransparencyServiceConfiguration().port(),\n        config.getKeyTransparencyServiceConfiguration().tlsCertificate(),\n        config.getKeyTransparencyServiceConfiguration().clientCertificate(),\n        config.getKeyTransparencyServiceConfiguration().clientPrivateKey().value());\n    SecureValueRecoveryClient secureValueRecovery2Client = new SecureValueRecoveryClient(\n        svr2CredentialsGenerator,\n        secureValueRecoveryServiceExecutor,\n        retryExecutor,\n        config.getSvr2Configuration(),\n        () -> dynamicConfigurationManager.getConfiguration().getSvr2StatusCodesToIgnoreForAccountDeletion());\n    SecureValueRecoveryClient secureValueRecoveryBClient = new SecureValueRecoveryClient(\n        svrbCredentialsGenerator,\n        secureValueRecoveryServiceExecutor,\n        retryExecutor,\n        config.getSvrbConfiguration(),\n        () -> dynamicConfigurationManager.getConfiguration().getSvrbStatusCodesToIgnoreForAccountDeletion());\n    SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator,\n        storageServiceExecutor, retryExecutor, config.getSecureStorageServiceConfiguration());\n    DisconnectionRequestManager disconnectionRequestManager = new DisconnectionRequestManager(pubsubClient,\n        disconnectionRequestListenerExecutor, retryExecutor);\n    ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster, retryExecutor, asyncCdnS3Client,\n        config.getCdnConfiguration().bucket());\n    MessagesCache messagesCache = new MessagesCache(messagesCluster, messageDeliveryScheduler,\n        messageDeletionAsyncExecutor, retryExecutor, clock, experimentEnrollmentManager);\n    ClientReleaseManager clientReleaseManager = new ClientReleaseManager(clientReleases,\n        recurringJobExecutor,\n        config.getClientReleaseConfiguration().refreshInterval(),\n        Clock.systemUTC());\n    ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster,\n        config.getReportMessageConfiguration().getCounterTtl());\n    RedisMessageAvailabilityManager redisMessageAvailabilityManager =\n        new RedisMessageAvailabilityManager(messagesCluster, clientEventExecutor, asyncOperationQueueingExecutor);\n    MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,\n        reportMessageManager, messageDeletionAsyncExecutor, Clock.systemUTC());\n    AccountLockManager accountLockManager = new AccountLockManager(dynamoDbClient,\n        config.getDynamoDbTables().getDeletedAccountsLock().getTableName());\n    AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,\n        pubsubClient, accountLockManager, keysManager, messagesManager, profilesManager,\n        secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,\n        registrationRecoveryPasswordsManager, accountLockExecutor, messagePollExecutor,\n        retryExecutor, clock, config.getLinkDeviceSecretConfiguration().secret().value());\n    RemoteConfigsManager remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);\n    APNSender apnSender = new APNSender(apnSenderExecutor, config.getApnConfiguration());\n    FcmSender fcmSender = new FcmSender(fcmSenderExecutor, config.getFcmConfiguration().credentials().value());\n    PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster,\n        apnSender, fcmSender, accountsManager, 0, 0, retryExecutor);\n    PushNotificationManager pushNotificationManager =\n        new PushNotificationManager(accountsManager, apnSender, fcmSender, pushNotificationScheduler);\n    RateLimiters rateLimiters = RateLimiters.create(dynamicConfigurationManager, rateLimitersCluster, retryExecutor);\n    ProvisioningManager provisioningManager = new ProvisioningManager(pubsubClient);\n    IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager(\n        config.getDynamoDbTables().getIssuedReceipts().getTableName(),\n        config.getDynamoDbTables().getIssuedReceipts().getExpiration(),\n        dynamoDbAsyncClient,\n        config.getDynamoDbTables().getIssuedReceipts().getGenerator(),\n        config.getDynamoDbTables().getIssuedReceipts().getmaxIssuedReceiptsPerPaymentId());\n    OneTimeDonationsManager oneTimeDonationsManager = new OneTimeDonationsManager(\n        config.getDynamoDbTables().getOnetimeDonations().getTableName(), config.getDynamoDbTables().getOnetimeDonations().getExpiration(), dynamoDbAsyncClient);\n    RedeemedReceiptsManager redeemedReceiptsManager = new RedeemedReceiptsManager(clock,\n        config.getDynamoDbTables().getRedeemedReceipts().getTableName(),\n        dynamoDbAsyncClient,\n        config.getDynamoDbTables().getRedeemedReceipts().getExpiration());\n    Subscriptions subscriptions = new Subscriptions(\n        config.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient);\n    MessageDeliveryLoopMonitor messageDeliveryLoopMonitor =\n        config.logMessageDeliveryLoops() ? new RedisMessageDeliveryLoopMonitor(rateLimitersCluster) : new NoopMessageDeliveryLoopMonitor();\n    CallQualitySurveyManager callQualitySurveyManager = new CallQualitySurveyManager(asnInfoProviderSupplier,\n        config.getCallQualitySurveyConfiguration().pubSubPublisher().build(),\n        Clock.systemUTC(),\n        callQualitySurveyPubSubExecutor);\n\n    final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(\n        accountsManager, disconnectionRequestManager, svr2CredentialsGenerator, registrationRecoveryPasswordsManager,\n        pushNotificationManager, rateLimiters);\n\n    final ReportedMessageMetricsListener reportedMessageMetricsListener = new ReportedMessageMetricsListener(\n        accountsManager);\n    reportMessageManager.addListener(reportedMessageMetricsListener);\n\n    final AccountAuthenticator accountAuthenticator = new AccountAuthenticator(accountsManager);\n\n    final MessageSender messageSender = new MessageSender(messagesManager, pushNotificationManager);\n    final ReceiptSender receiptSender = new ReceiptSender(accountsManager, messageSender, receiptSenderExecutor);\n    final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(\n        config.getTurnConfiguration().cloudflare().apiToken().value(),\n        config.getTurnConfiguration().cloudflare().endpoint(),\n        config.getTurnConfiguration().cloudflare().requestedCredentialTtl(),\n        config.getTurnConfiguration().cloudflare().clientCredentialTtl(),\n        config.getTurnConfiguration().cloudflare().urls(),\n        config.getTurnConfiguration().cloudflare().urlsWithIps(),\n        config.getTurnConfiguration().cloudflare().hostname(),\n        config.getTurnConfiguration().cloudflare().numHttpClients(),\n        config.getTurnConfiguration().cloudflare().circuitBreakerConfigurationName(),\n        cloudflareTurnHttpExecutor,\n        config.getTurnConfiguration().cloudflare().retryConfigurationName(),\n        cloudflareTurnRetryExecutor,\n        cloudflareDnsResolver\n        );\n\n    final CardinalityEstimator messageByteLimitCardinalityEstimator = new CardinalityEstimator(\n        rateLimitersCluster,\n        \"message_byte_limit\",\n        config.getMessageByteLimitCardinalityEstimator().period());\n\n    PushChallengeManager pushChallengeManager = new PushChallengeManager(pushNotificationManager,\n        pushChallengeDynamoDb);\n\n    ChangeNumberManager changeNumberManager = new ChangeNumberManager(messageSender, accountsManager, Clock.systemUTC());\n\n    HttpClient currencyClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(Duration.ofSeconds(10)).build();\n    FixerClient fixerClient = config.getPaymentsServiceConfiguration().externalClients()\n        .buildFixerClient(currencyClient);\n    CoinGeckoClient coinGeckoClient = config.getPaymentsServiceConfiguration().externalClients()\n        .buildCoinGeckoClient(currencyClient);\n    CurrencyConversionManager currencyManager = new CurrencyConversionManager(fixerClient, coinGeckoClient,\n        cacheCluster, config.getPaymentsServiceConfiguration().paymentCurrencies(), recurringJobExecutor, Clock.systemUTC());\n    VirtualThreadPinEventMonitor virtualThreadPinEventMonitor = new VirtualThreadPinEventMonitor(\n        virtualThreadEventLoggerExecutor,\n        config.getVirtualThreadConfiguration().pinEventThreshold());\n\n    StripeManager stripeManager = new StripeManager(config.getStripe().apiKey().value(), subscriptionProcessorExecutor,\n        config.getStripe().idempotencyKeyGenerator().value(), config.getStripe().boostDescription(), config.getStripe().supportedCurrenciesByPaymentMethod());\n    BraintreeManager braintreeManager = new BraintreeManager(config.getBraintree().merchantId(),\n        config.getBraintree().publicKey().value(), config.getBraintree().privateKey().value(),\n        config.getBraintree().environment(),\n        config.getBraintree().supportedCurrenciesByPaymentMethod(), config.getBraintree().merchantAccounts(),\n        config.getBraintree().graphqlUrl(), currencyManager, config.getBraintree().pubSubPublisher().build(),\n        config.getBraintree().circuitBreakerConfigurationName(), subscriptionProcessorExecutor);\n    GooglePlayBillingManager googlePlayBillingManager = new GooglePlayBillingManager(\n        new ByteArrayInputStream(config.getGooglePlayBilling().credentialsJson().getBytes(StandardCharsets.UTF_8)),\n        config.getGooglePlayBilling().packageName(),\n        config.getGooglePlayBilling().applicationName(),\n        config.getGooglePlayBilling().productIdToLevel());\n    AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager(\n        new AppleAppStoreClient(\n            config.getAppleAppStore().env(),\n            config.getAppleAppStore().bundleId(),\n            config.getAppleAppStore().appAppleId(),\n            config.getAppleAppStore().issuerId(),\n            config.getAppleAppStore().keyId(),\n            config.getAppleAppStore().encodedKey().value(),\n            config.getAppleAppStore().appleRootCerts(),\n            config.getAppleAppStore().retryConfigurationName()),\n        config.getAppleAppStore().subscriptionGroupId(),\n        config.getAppleAppStore().productIdToLevel());\n\n    environment.lifecycle().manage(asnInfoProviderSupplier);\n\n    environment.lifecycle().manage(dnsResolutionEventLoopGroup);\n    environment.lifecycle().manage(apnSender);\n    environment.lifecycle().manage(pushNotificationScheduler);\n    environment.lifecycle().manage(provisioningManager);\n    environment.lifecycle().manage(disconnectionRequestManager);\n    environment.lifecycle().manage(redisMessageAvailabilityManager);\n    environment.lifecycle().manage(currencyManager);\n    environment.lifecycle().manage(registrationServiceClient);\n    environment.lifecycle().manage(keyTransparencyServiceClient);\n    environment.lifecycle().manage(clientReleaseManager);\n    environment.lifecycle().manage(virtualThreadPinEventMonitor);\n    environment.lifecycle().manage(accountsManager);\n\n    final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(\n        config.getGcpAttachmentsConfiguration().domain(),\n        config.getGcpAttachmentsConfiguration().email(),\n        config.getGcpAttachmentsConfiguration().maxSizeInBytes(),\n        config.getGcpAttachmentsConfiguration().pathPrefix(),\n        config.getGcpAttachmentsConfiguration().rsaSigningKey().value());\n\n    PostPolicyGenerator profileCdnPolicyGenerator = new PostPolicyGenerator(config.getCdnConfiguration().region(),\n        config.getCdnConfiguration().bucket(), config.getCdnConfiguration().credentials().accessKeyId().value());\n    PolicySigner profileCdnPolicySigner = new PolicySigner(\n        config.getCdnConfiguration().credentials().secretAccessKey().value(),\n        config.getCdnConfiguration().region());\n\n    ServerSecretParams zkSecretParams = new ServerSecretParams(config.getZkConfig().serverSecret().value());\n    GenericServerSecretParams callingGenericZkSecretParams = new GenericServerSecretParams(config.getCallingZkConfig().serverSecret().value());\n    GenericServerSecretParams backupsGenericZkSecretParams = new GenericServerSecretParams(config.getBackupsZkConfig().serverSecret().value());\n    ServerZkProfileOperations zkProfileOperations = new ServerZkProfileOperations(zkSecretParams);\n    ServerZkAuthOperations zkAuthOperations = new ServerZkAuthOperations(zkSecretParams);\n    ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);\n\n    TusAttachmentGenerator tusAttachmentGenerator = new TusAttachmentGenerator(config.getTus());\n    Cdn3BackupCredentialGenerator cdn3BackupCredentialGenerator = new Cdn3BackupCredentialGenerator(config.getTus());\n    BackupAuthManager backupAuthManager = new BackupAuthManager(experimentEnrollmentManager, rateLimiters,\n        accountsManager, zkReceiptOperations, redeemedReceiptsManager, backupsGenericZkSecretParams, clock);\n    BackupsDb backupsDb = new BackupsDb(\n        dynamoDbAsyncClient,\n        config.getDynamoDbTables().getBackups().getTableName(),\n        clock);\n    final Cdn3RemoteStorageManager cdn3RemoteStorageManager = new Cdn3RemoteStorageManager(\n        remoteStorageHttpExecutor,\n        retryExecutor,\n        config.getCdn3StorageManagerConfiguration());\n    BackupManager backupManager = new BackupManager(\n        backupsDb,\n        backupsGenericZkSecretParams,\n        rateLimiters,\n        tusAttachmentGenerator,\n        cdn3BackupCredentialGenerator,\n        cdn3RemoteStorageManager,\n        svrbCredentialsGenerator,\n        secureValueRecoveryBClient,\n        clock,\n        dynamicConfigurationManager);\n    final BackupMetrics backupMetrics = new BackupMetrics();\n\n    final AppleDeviceChecks appleDeviceChecks = new AppleDeviceChecks(\n        dynamoDbClient,\n        DeviceCheckManager.createObjectConverter(),\n        config.getDynamoDbTables().getAppleDeviceChecks().getTableName(),\n        config.getDynamoDbTables().getAppleDeviceCheckPublicKeys().getTableName());\n    final DeviceCheckManager deviceCheckManager = new DeviceCheckManager(new AppleDeviceCheckTrustAnchor());\n    deviceCheckManager.getAttestationDataValidator().setProduction(config.getAppleDeviceCheck().production());\n    final AppleDeviceCheckManager appleDeviceCheckManager = new AppleDeviceCheckManager(\n        appleDeviceChecks,\n        cacheCluster,\n        deviceCheckManager,\n        config.getAppleDeviceCheck().teamId(),\n        config.getAppleDeviceCheck().bundleId());\n\n    final List<SpamFilter> spamFilters = ServiceLoader.load(SpamFilter.class)\n        .stream()\n        .map(ServiceLoader.Provider::get)\n        .flatMap(filter -> {\n          try {\n            filter.configure(config.getSpamFilterConfiguration().getEnvironment(), environment.getValidator());\n            return Stream.of(filter);\n          } catch (Exception e) {\n            log.warn(\"Failed to register spam filter: {}\", filter.getClass().getName(), e);\n            return Stream.empty();\n          }\n        })\n        .toList();\n    if (spamFilters.size() > 1) {\n      log.warn(\"Multiple spam report token providers found. Using the first.\");\n    }\n    final Optional<SpamFilter> spamFilter = spamFilters.stream().findFirst();\n    if (spamFilter.isEmpty()) {\n      log.warn(\"No spam filters installed\");\n    }\n    final SpamChecker spamChecker = spamFilter\n        .map(SpamFilter::getSpamChecker)\n        .orElseGet(() -> {\n          log.warn(\"No spam-checkers found; using default (no-op) provider as a default\");\n          return SpamChecker.noop();\n        });\n    final ChallengeConstraintChecker challengeConstraintChecker = spamFilter\n        .map(SpamFilter::getChallengeConstraintChecker)\n        .orElseGet(() -> {\n          log.warn(\"No challenge-constraint-checkers found; using default (no-op) provider as a default\");\n          return ChallengeConstraintChecker.noop();\n        });\n    final RegistrationFraudChecker registrationFraudChecker = spamFilter\n        .map(SpamFilter::getRegistrationFraudChecker)\n        .orElseGet(() -> {\n          log.warn(\"No registration-fraud-checkers found; using default (no-op) provider as a default\");\n          return RegistrationFraudChecker.noop();\n        });\n    final RegistrationRecoveryChecker registrationRecoveryChecker = spamFilter\n        .map(SpamFilter::getRegistrationRecoveryChecker)\n        .orElseGet(() -> {\n          log.warn(\"No registration-recovery-checkers found; using default (no-op) provider as a default\");\n          return RegistrationRecoveryChecker.noop();\n        });\n    final Function<String, CaptchaClient> captchaClientSupplier = spamFilter\n        .map(SpamFilter::getCaptchaClientSupplier)\n        .orElseGet(() -> {\n          log.warn(\"No captcha clients found; using default (no-op) client as default\");\n          return ignored -> CaptchaClient.noop();\n        });\n\n    spamFilter.map(SpamFilter::getReportedMessageListener).ifPresent(reportMessageManager::addListener);\n    spamFilter.map(SpamFilter::getMessageDeliveryListener).ifPresent(messageSender::addMessageDeliveryListener);\n\n    final HttpClient shortCodeRetrieverHttpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)\n        .connectTimeout(Duration.ofSeconds(10)).build();\n    final ShortCodeExpander shortCodeRetriever = new ShortCodeExpander(shortCodeRetrieverHttpClient, config.getShortCodeRetrieverConfiguration().baseUrl());\n    final CaptchaChecker captchaChecker = new CaptchaChecker(shortCodeRetriever, captchaClientSupplier);\n\n    final RegistrationCaptchaManager registrationCaptchaManager = new RegistrationCaptchaManager(captchaChecker);\n\n    final RateLimitChallengeManager rateLimitChallengeManager = new RateLimitChallengeManager(pushChallengeManager,\n        captchaChecker, rateLimiters, spamFilter.map(SpamFilter::getRateLimitChallengeListener).stream().toList());\n\n    spamFilter.ifPresent(filter -> {\n      environment.lifecycle().manage(filter);\n      log.info(\"Registered spam filter: {}\", filter.getClass().getName());\n    });\n\n    final RemoteDeprecationFilter remoteDeprecationFilter = new RemoteDeprecationFilter(dynamicConfigurationManager);\n    final MetricServerInterceptor metricServerInterceptor = new MetricServerInterceptor(Metrics.globalRegistry, clientReleaseManager);\n\n    final ErrorMappingInterceptor errorMappingInterceptor = new ErrorMappingInterceptor();\n    final ErrorConformanceInterceptor errorConformanceInterceptor = new ErrorConformanceInterceptor();\n    final GrpcAllowListInterceptor grpcAllowListInterceptor = new GrpcAllowListInterceptor(dynamicConfigurationManager);\n    final RequestAttributesInterceptor requestAttributesInterceptor = new RequestAttributesInterceptor();\n\n    final ValidatingInterceptor validatingInterceptor = new ValidatingInterceptor();\n\n    final ExternalRequestFilter grpcExternalRequestFilter = new ExternalRequestFilter(\n        config.getExternalRequestFilterConfiguration().permittedInternalRanges(),\n        config.getExternalRequestFilterConfiguration().grpcMethods());\n    final RequireAuthenticationInterceptor requireAuthenticationInterceptor = new RequireAuthenticationInterceptor(accountAuthenticator);\n    final ProhibitAuthenticationInterceptor prohibitAuthenticationInterceptor = new ProhibitAuthenticationInterceptor();\n    final GroupSendTokenUtil groupSendTokenUtil = new GroupSendTokenUtil(zkSecretParams, Clock.systemUTC());\n\n    final List<ServerServiceDefinition> authenticatedServices = Stream.of(\n            new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager),\n            ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters),\n            new KeysGrpcService(accountsManager, keysManager, rateLimiters),\n            new MessagesGrpcService(accountsManager, rateLimiters, messageSender, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),\n            new BackupsGrpcService(accountsManager, backupAuthManager, backupMetrics),\n            new DevicesGrpcService(accountsManager),\n            new AttachmentsGrpcService(experimentEnrollmentManager, rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator))\n        .map(bindableService -> ServerInterceptors.intercept(bindableService,\n            // Note: interceptors run in the reverse order they are added; the remote deprecation filter\n            // depends on the user-agent context so it has to come first here!\n            validatingInterceptor,\n            errorMappingInterceptor,\n            errorConformanceInterceptor,\n            grpcAllowListInterceptor,\n            remoteDeprecationFilter,\n            metricServerInterceptor,\n            requestAttributesInterceptor,\n            requireAuthenticationInterceptor))\n        .toList();\n    final List<ServerServiceDefinition> unauthenticatedServices = Stream.of(\n            new AccountsAnonymousGrpcService(accountsManager, rateLimiters),\n            new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters),\n            new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()),\n            new PaymentsGrpcService(currencyManager),\n            new MessagesAnonymousGrpcService(accountsManager, rateLimiters, messageSender, groupSendTokenUtil, messageByteLimitCardinalityEstimator, spamChecker, Clock.systemUTC()),\n            new BackupsAnonymousGrpcService(backupManager, backupMetrics),\n            ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))\n        .map(bindableService -> ServerInterceptors.intercept(bindableService,\n            // Note: interceptors run in the reverse order they are added; the remote deprecation filter\n            // depends on the user-agent context so it has to come first here!\n            grpcExternalRequestFilter,\n            validatingInterceptor,\n            errorMappingInterceptor,\n            errorConformanceInterceptor,\n            grpcAllowListInterceptor,\n            remoteDeprecationFilter,\n            metricServerInterceptor,\n            requestAttributesInterceptor,\n            prohibitAuthenticationInterceptor))\n        .toList();\n\n    final ServerBuilder<?> serverBuilder =\n        NettyServerBuilder.forAddress(new InetSocketAddress(config.getGrpc().bindAddress(), config.getGrpc().port()));\n    authenticatedServices.forEach(serverBuilder::addService);\n    unauthenticatedServices.forEach(serverBuilder::addService);\n    final ManagedGrpcServer exposedGrpcServer = new ManagedGrpcServer(serverBuilder.build());\n\n    environment.lifecycle().manage(exposedGrpcServer);\n\n    final List<Filter> filters = new ArrayList<>();\n    filters.add(remoteDeprecationFilter);\n    filters.add(new RemoteAddressFilter());\n    filters.add(new TimestampResponseFilter());\n\n    for (Filter filter : filters) {\n      environment.servlets()\n          .addFilter(filter.getClass().getSimpleName(), filter)\n          .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, \"/*\");\n    }\n\n    if (!config.getExternalRequestFilterConfiguration().paths().isEmpty()) {\n      environment.servlets().addFilter(ExternalRequestFilter.class.getSimpleName(),\n              new ExternalRequestFilter(config.getExternalRequestFilterConfiguration().permittedInternalRanges(),\n                  config.getExternalRequestFilterConfiguration().grpcMethods()))\n          .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true,\n              config.getExternalRequestFilterConfiguration().paths().toArray(new String[]{}));\n    }\n\n    final AuthFilter<BasicCredentials, AuthenticatedDevice> accountAuthFilter =\n        new BasicCredentialAuthFilter.Builder<AuthenticatedDevice>()\n            .setAuthenticator(accountAuthenticator)\n            .buildAuthFilter();\n\n    final String websocketServletPath = \"/v1/websocket/\";\n    final String provisioningWebsocketServletPath = \"/v1/websocket/provisioning/\";\n\n    final MetricsHttpChannelListener metricsHttpChannelListener = new MetricsHttpChannelListener(clientReleaseManager,\n        Set.of(websocketServletPath, provisioningWebsocketServletPath, \"/health-check\"));\n    metricsHttpChannelListener.configure(environment);\n    final MessageMetrics messageMetrics = new MessageMetrics();\n\n    // BufferingInterceptor is needed on the base environment but not the WebSocketEnvironment,\n    // because we handle serialization of http responses on the websocket on our own and can\n    // compute content lengths without it\n    environment.jersey().register(new BufferingInterceptor());\n    environment.jersey().register(new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager));\n\n    environment.jersey().register(new VirtualExecutorServiceProvider(\n        \"managed-async-virtual-thread\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor()));\n    environment.jersey().register(new RateLimitByIpFilter(rateLimiters));\n    environment.jersey().register(new RequestStatisticsFilter(TrafficSource.HTTP));\n    environment.jersey().register(MultiRecipientMessageProvider.class);\n    environment.jersey().register(new AuthDynamicFeature(accountAuthFilter));\n    environment.jersey().register(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class));\n    environment.jersey().register(new TimestampResponseFilter());\n\n    ///\n    WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment = new WebSocketEnvironment<>(environment,\n        config.getWebSocketConfiguration(), Duration.ofMillis(90000));\n    webSocketEnvironment.jersey().register(new VirtualExecutorServiceProvider(\n        \"managed-async-websocket-virtual-thread\",\n        config.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor()));\n    webSocketEnvironment.setAuthenticator(new WebSocketAccountAuthenticator(accountAuthenticator));\n    webSocketEnvironment.setAuthenticatedWebSocketUpgradeFilter(new IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(\n        config.idlePrimaryDeviceReminderConfiguration().minIdleDuration(), Clock.systemUTC()));\n    webSocketEnvironment.setConnectListener(\n        new AuthenticatedConnectListener(accountsManager, receiptSender, messagesManager, messageMetrics, pushNotificationManager,\n            pushNotificationScheduler, disconnectionRequestManager,\n            messageDeliveryScheduler, asnInfoProviderSupplier, clientReleaseManager, messageDeliveryLoopMonitor, experimentEnrollmentManager\n        ));\n    webSocketEnvironment.jersey().register(new RateLimitByIpFilter(rateLimiters));\n    webSocketEnvironment.jersey().register(new RequestStatisticsFilter(TrafficSource.WEBSOCKET));\n    webSocketEnvironment.jersey().register(MultiRecipientMessageProvider.class);\n    webSocketEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET, clientReleaseManager));\n    webSocketEnvironment.jersey().register(new KeepAliveController(redisMessageAvailabilityManager));\n    webSocketEnvironment.jersey().register(new TimestampResponseFilter());\n\n    final PersistentTimer persistentTimer = new PersistentTimer(rateLimitersCluster, clock);\n\n    final PhoneVerificationTokenManager phoneVerificationTokenManager = new PhoneVerificationTokenManager(\n        phoneNumberIdentifiers, registrationServiceClient, registrationRecoveryPasswordsManager, registrationRecoveryChecker);\n    final List<Object> commonControllers = Lists.newArrayList(\n        new AccountController(accountsManager, rateLimiters, registrationRecoveryPasswordsManager,\n            usernameHashZkProofVerifier),\n        new AccountControllerV2(accountsManager, changeNumberManager, phoneVerificationTokenManager,\n            registrationLockVerificationManager, rateLimiters),\n        new AttachmentControllerV4(rateLimiters, gcsAttachmentGenerator, tusAttachmentGenerator,\n            experimentEnrollmentManager),\n        new ArchiveController(accountsManager, backupAuthManager, backupManager, backupMetrics),\n        new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager),\n        new CallLinkController(rateLimiters, callingGenericZkSecretParams),\n        new CallQualitySurveyController(callQualitySurveyManager),\n        new CertificateController(accountsManager, new CertificateGenerator(config.getDeliveryCertificate().certificate(),\n            config.getDeliveryCertificate().ecPrivateKey(), config.getDeliveryCertificate().expiresDays(), config.getDeliveryCertificate().embedSigner()),\n            zkAuthOperations, callingGenericZkSecretParams, clock),\n        new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker),\n        new DeviceController(accountsManager, rateLimiters, persistentTimer, config.getMaxDevices()),\n        new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,\n            config.getDeviceCheck().backupRedemptionLevel(),\n            config.getDeviceCheck().backupRedemptionDuration()),\n        new DirectoryV2Controller(directoryV2CredentialsGenerator),\n        new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager, config.getBadges(),\n            ReceiptCredentialPresentation::new),\n        new KeysController(rateLimiters, keysManager, accountsManager, zkSecretParams, Clock.systemUTC()),\n        new KeyTransparencyController(keyTransparencyServiceClient),\n        new MessageController(rateLimiters, messageByteLimitCardinalityEstimator, messageSender,\n            accountsManager, messagesManager, phoneNumberIdentifiers, pushNotificationManager, pushNotificationScheduler,\n            reportMessageManager, messageDeliveryScheduler, clientReleaseManager,\n            zkSecretParams, spamChecker, messageMetrics, messageDeliveryLoopMonitor,\n            Clock.systemUTC()),\n        new PaymentsController(currencyManager, paymentsCredentialsGenerator),\n        new ProfileController(clock, rateLimiters, accountsManager, profilesManager, dynamicConfigurationManager,\n            profileBadgeConverter, config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner,\n            zkSecretParams, zkProfileOperations, batchIdentityCheckExecutor),\n        new ProvisioningController(rateLimiters, provisioningManager),\n        new RegistrationController(accountsManager, phoneVerificationTokenManager, registrationLockVerificationManager,\n            rateLimiters),\n        new RemoteConfigController(remoteConfigsManager, config.getRemoteConfigConfiguration().globalConfig(), clock),\n        new SecureStorageController(storageCredentialsGenerator),\n        new SecureValueRecovery2Controller(svr2CredentialsGenerator, accountsManager),\n        new StickerController(rateLimiters, config.getCdnConfiguration().credentials().accessKeyId().value(),\n            config.getCdnConfiguration().credentials().secretAccessKey().value(), config.getCdnConfiguration().region(),\n            config.getCdnConfiguration().bucket()),\n        new VerificationController(registrationServiceClient, new VerificationSessionManager(verificationSessions),\n            pushNotificationManager, registrationCaptchaManager, registrationRecoveryPasswordsManager,\n            phoneNumberIdentifiers, rateLimiters, accountsManager, carrierDataProvider, registrationFraudChecker,\n            dynamicConfigurationManager, clock)\n    );\n    if (config.getSubscription() != null && config.getOneTimeDonations() != null) {\n      SubscriptionManager subscriptionManager = new SubscriptionManager(subscriptions,\n          List.of(stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager),\n          zkReceiptOperations, issuedReceiptsManager);\n      commonControllers.add(new SubscriptionController(clock, config.getSubscription(), config.getOneTimeDonations(),\n          subscriptionManager, stripeManager, braintreeManager, googlePlayBillingManager, appleAppStoreManager,\n          profileBadgeConverter, bankMandateTranslator, dynamicConfigurationManager));\n      commonControllers.add(new OneTimeDonationController(clock, config.getOneTimeDonations(), stripeManager, braintreeManager,\n          payPalDonationsTranslator, zkReceiptOperations, issuedReceiptsManager, oneTimeDonationsManager));\n    }\n\n    for (Object controller : commonControllers) {\n      environment.jersey().register(controller);\n      webSocketEnvironment.jersey().register(controller);\n    }\n\n    WebSocketEnvironment<AuthenticatedDevice> provisioningEnvironment = new WebSocketEnvironment<>(environment,\n        webSocketEnvironment.getRequestLog(), Duration.ofMillis(60000));\n    provisioningEnvironment.setConnectListener(new ProvisioningConnectListener(provisioningManager, asnInfoProviderSupplier, clientReleaseManager, provisioningWebsocketTimeoutExecutor, Duration.ofSeconds(90)));\n    provisioningEnvironment.jersey().register(new MetricsApplicationEventListener(TrafficSource.WEBSOCKET, clientReleaseManager));\n    provisioningEnvironment.jersey().register(new KeepAliveController(redisMessageAvailabilityManager));\n    provisioningEnvironment.jersey().register(new TimestampResponseFilter());\n\n    registerExceptionMappers(environment, webSocketEnvironment, provisioningEnvironment);\n\n    environment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);\n    webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);\n    provisioningEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);\n\n    JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), (context, container) -> {\n      final WebSocketExtensionRegistry extensionRegistry = WebSocketServerComponents\n          .getWebSocketComponents(environment.getApplicationContext().getServletContext())\n          .getExtensionRegistry();\n      if (config.getWebSocketConfiguration().isDisablePerMessageDeflate()) {\n        extensionRegistry.unregister(\"permessage-deflate\");\n      } else if (config.getWebSocketConfiguration().isDisableCrossMessageOutgoingCompression()) {\n        extensionRegistry.unregister(\"permessage-deflate\");\n        extensionRegistry.register(\"permessage-deflate\", NoContextTakeoverPerMessageDeflateExtension.class);\n      }\n    });\n\n    WebSocketResourceProviderFactory<AuthenticatedDevice> webSocketServlet = new WebSocketResourceProviderFactory<>(\n        webSocketEnvironment, AuthenticatedDevice.class, config.getWebSocketConfiguration(),\n        RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n    WebSocketResourceProviderFactory<AuthenticatedDevice> provisioningServlet = new WebSocketResourceProviderFactory<>(\n        provisioningEnvironment, AuthenticatedDevice.class, config.getWebSocketConfiguration(),\n        RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n    ServletRegistration.Dynamic websocket = environment.servlets().addServlet(\"WebSocket\", webSocketServlet);\n    ServletRegistration.Dynamic provisioning = environment.servlets().addServlet(\"Provisioning\", provisioningServlet);\n\n    websocket.addMapping(websocketServletPath);\n    websocket.setAsyncSupported(true);\n\n    provisioning.addMapping(provisioningWebsocketServletPath);\n    provisioning.setAsyncSupported(true);\n\n    environment.admin().addTask(new SetRequestLoggingEnabledTask());\n\n  }\n\n  private void registerExceptionMappers(Environment environment,\n      WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment,\n      WebSocketEnvironment<AuthenticatedDevice> provisioningEnvironment) {\n\n    List.of(\n        new LoggingUnhandledExceptionMapper(),\n        new CompletionExceptionMapper(),\n        new GrpcStatusRuntimeExceptionMapper(),\n        new IOExceptionMapper(),\n        new RateLimitExceededExceptionMapper(),\n        new InvalidWebsocketAddressExceptionMapper(),\n        new DeviceLimitExceededExceptionMapper(),\n        new ServerRejectedExceptionMapper(),\n        new ImpossiblePhoneNumberExceptionMapper(),\n        new NonNormalizedPhoneNumberExceptionMapper(),\n        new ObsoletePhoneNumberFormatExceptionMapper(),\n        new RegistrationServiceSenderExceptionMapper(),\n        new SubscriptionExceptionMapper(),\n        new BackupExceptionMapper(),\n        new JsonMappingExceptionMapper()\n    ).forEach(exceptionMapper -> {\n      environment.jersey().register(exceptionMapper);\n      webSocketEnvironment.jersey().register(exceptionMapper);\n      provisioningEnvironment.jersey().register(exceptionMapper);\n    });\n  }\n\n  public static class ExecutorServiceBuilder extends io.dropwizard.lifecycle.setup.ExecutorServiceBuilder {\n    private final String baseName;\n\n    public ExecutorServiceBuilder(final LifecycleEnvironment environment, final String baseName) {\n      super(environment, name(WhisperServerService.class, baseName) + \"-%d\");\n      this.baseName = baseName;\n    }\n\n    @Override\n    public ExecutorService build() {\n      return ExecutorServiceMetrics.monitor(Metrics.globalRegistry, super.build(), baseName, MetricsUtil.PREFIX);\n    }\n\n    public static ExecutorServiceBuilder of(final Environment environment, final String name) {\n      return new ExecutorServiceBuilder(environment.lifecycle(), name);\n    }\n  }\n\n  public static class ScheduledExecutorServiceBuilder extends io.dropwizard.lifecycle.setup.ScheduledExecutorServiceBuilder {\n    private final String baseName;\n\n    public ScheduledExecutorServiceBuilder(final LifecycleEnvironment environment, final String baseName) {\n      super(environment, name(WhisperServerService.class, baseName) + \"-%d\", false);\n      this.baseName = baseName;\n    }\n\n    @Override\n    public ScheduledExecutorService build() {\n      return ExecutorServiceMetrics.monitor(Metrics.globalRegistry, super.build(), baseName, MetricsUtil.PREFIX);\n    }\n\n    public static ScheduledExecutorServiceBuilder of(final Environment environment, final String name) {\n      return new ScheduledExecutorServiceBuilder(environment.lifecycle(), name);\n    }\n  }\n\n  public static void main(String[] args) throws Exception {\n    new WhisperServerService().run(args);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/asn/AsnInfo.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.asn;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport javax.annotation.Nonnull;\n\npublic record AsnInfo(long asn, @Nonnull String regionCode) {\n\n  public AsnInfo {\n    requireNonNull(regionCode, \"regionCode must not be null\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/asn/AsnInfoProvider.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.asn;\n\nimport java.util.Optional;\nimport javax.annotation.Nonnull;\n\npublic interface AsnInfoProvider {\n\n  /// Gets ASN information for an IP address.\n  ///\n  /// @param ipString a string representation of an IP address\n  ///\n  /// @return ASN information for the given IP address or empty if no ASN information was found for the given IP address\n  Optional<AsnInfo> lookup(@Nonnull String ipString);\n\n  AsnInfoProvider EMPTY = _ -> Optional.empty();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/asn/AsnInfoProviderImpl.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.asn;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.InputStreamReader;\nimport java.lang.invoke.MethodHandles;\nimport java.math.BigInteger;\nimport java.net.Inet4Address;\nimport java.net.Inet6Address;\nimport java.net.InetAddress;\nimport java.nio.ByteBuffer;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.NavigableMap;\nimport java.util.Optional;\nimport java.util.TreeMap;\nimport java.util.zip.GZIPInputStream;\nimport javax.annotation.Nonnull;\nimport org.apache.commons.csv.CSVFormat;\nimport org.apache.commons.csv.CSVParser;\nimport org.apache.commons.csv.CSVRecord;\nimport org.apache.commons.lang3.Validate;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * {@code AsnInfoProvider} implementation that supports both IPv4 and IPv6.\n */\npublic class AsnInfoProviderImpl implements AsnInfoProvider {\n\n  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());\n\n  @Nonnull\n  private final NavigableMap<Long, AsnRange<Long>> asnBlocksByFirstIpv4;\n\n  @Nonnull\n  private final NavigableMap<BigInteger, AsnRange<BigInteger>> asnBlocksByFirstIpv6;\n\n\n  /**\n   * Creates an instance of {@code AsnInfoProviderImpl} using data from <a href=\"https://iptoasn.com/\">iptoasn.com</a>.\n   * @param tsvGzInputStream gzip input stream representing the data.\n   */\n  @Nonnull\n  public static AsnInfoProviderImpl fromTsvGz(@Nonnull final InputStream tsvGzInputStream) {\n    try (final GZIPInputStream inputStream = new GZIPInputStream(tsvGzInputStream)) {\n      return fromTsv(inputStream);\n    } catch (final IOException e) {\n      log.error(\"failed to ungzip the input stream\", e);\n      throw new RuntimeException(e);\n    }\n  }\n\n  /**\n   * Creates an instance of {@code AsnInfoProviderImpl} using data from <a href=\"https://iptoasn.com/\">iptoasn.com</a>.\n   * @param tsvInputStream input stream representing the data.\n   */\n  @Nonnull\n  public static AsnInfoProviderImpl fromTsv(@Nonnull final InputStream tsvInputStream) {\n    try (final InputStreamReader tsvReader = new InputStreamReader(tsvInputStream)) {\n      final NavigableMap<Long, AsnRange<Long>> ip4asns = new TreeMap<>();\n      final NavigableMap<BigInteger, AsnRange<BigInteger>> ip6asns = new TreeMap<>();\n      final Map<Long, AsnInfo> asnInfoCache = new HashMap<>();\n\n      try (final CSVParser csvParser = CSVFormat.TDF.parse(tsvReader)) {\n        for (final CSVRecord record : csvParser) {\n          // format:\n          // range_start_ip_string range_end_ip_string AS_number country_code AS_description\n          final InetAddress startIp = InetAddress.getByName(record.get(0));\n          final InetAddress endIp = InetAddress.getByName(record.get(1));\n          final long asn = Long.parseLong(record.get(2));\n          final String regionCode = record.get(3);\n          // country code should be the same for any ASN, so we're caching AsnInfo objects\n          // not to have multiple instances with the same values\n          final AsnInfo asnInfo = asnInfoCache.computeIfAbsent(asn, k -> new AsnInfo(asn, regionCode));\n          if (!regionCode.equals(asnInfo.regionCode())) {\n            log.warn(\"ASN {} mapped to country codes {} and {}\", asn, regionCode, asnInfo.regionCode());\n          }\n\n          // IPv4\n          if (startIp instanceof Inet4Address) {\n            final AsnRange<Long> asnRange = new AsnRange<>(\n                ip4BytesToLong((Inet4Address) startIp),\n                ip4BytesToLong((Inet4Address) endIp),\n                asnInfo\n            );\n            ip4asns.put(asnRange.from(), asnRange);\n          }\n\n          // IPv6\n          if (startIp instanceof Inet6Address) {\n            final AsnRange<BigInteger> asnRange = new AsnRange<>(\n                ip6BytesToBigInteger((Inet6Address) startIp),\n                ip6BytesToBigInteger((Inet6Address) endIp),\n                asnInfo\n            );\n            ip6asns.put(asnRange.from(), asnRange);\n          }\n        }\n      }\n      return new AsnInfoProviderImpl(ip4asns, ip6asns);\n    } catch (final Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public AsnInfoProviderImpl(\n      @Nonnull final NavigableMap<Long, AsnRange<Long>> asnBlocksByFirstIpv4,\n      @Nonnull final NavigableMap<BigInteger, AsnRange<BigInteger>> asnBlocksByFirstIpv6) {\n    this.asnBlocksByFirstIpv4 = requireNonNull(asnBlocksByFirstIpv4);\n    this.asnBlocksByFirstIpv6 = requireNonNull(asnBlocksByFirstIpv6);\n  }\n\n  @Nonnull\n  @Override\n  public Optional<AsnInfo> lookup(@Nonnull final String ipString) {\n    try {\n      final InetAddress address = InetAddress.getByName(ipString);\n      if (address instanceof Inet4Address ip4) {\n        final Long key = ip4BytesToLong(ip4);\n        return lookupInMap(asnBlocksByFirstIpv4, key);\n      }\n      if (address instanceof Inet6Address ip6) {\n        final BigInteger key = ip6BytesToBigInteger(ip6);\n        return lookupInMap(asnBlocksByFirstIpv6, key);\n      }\n      // safety net, should never happen\n      log.warn(\"Unknown InetAddress implementation: {}\", address.getClass().getName());\n    } catch (final Exception e) {\n      log.error(\"Could not resolve ASN for IP string {}\", ipString);\n    }\n    return Optional.empty();\n  }\n\n  @VisibleForTesting\n  protected static long ip4BytesToLong(@Nonnull final Inet4Address address) {\n    final byte[] arr = address.getAddress();\n    Validate.isTrue(arr.length == 4);\n    return Integer.toUnsignedLong(ByteBuffer.wrap(arr).getInt());\n  }\n\n  @VisibleForTesting\n  protected static BigInteger ip6BytesToBigInteger(@Nonnull final Inet6Address address) {\n    final byte[] arr = address.getAddress();\n    Validate.isTrue(arr.length == 16);\n    return new BigInteger(1, arr);\n  }\n\n  @Nonnull\n  private static <T extends Comparable<T>> Optional<AsnInfo> lookupInMap(\n      @Nonnull final NavigableMap<T, AsnRange<T>> map,\n      @Nonnull final T key) {\n    return Optional.ofNullable(map.floorEntry(key))\n        .filter(e -> e.getValue().contains(key) && e.getValue().asnInfo().asn() != 0)\n        .map(e -> e.getValue().asnInfo());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/asn/AsnRange.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.asn;\n\nimport static java.util.Objects.requireNonNull;\n\nimport javax.annotation.Nonnull;\nimport org.apache.commons.lang3.Validate;\n\npublic record AsnRange<T extends Comparable<T>>(@Nonnull T from,\n                                                @Nonnull T to,\n                                                @Nonnull AsnInfo asnInfo) {\n  public AsnRange {\n    requireNonNull(from);\n    requireNonNull(to);\n    requireNonNull(asnInfo);\n    Validate.isTrue(from.compareTo(to) <= 0);\n  }\n\n  boolean contains(@Nonnull final T element) {\n    requireNonNull(element);\n    return from.compareTo(element) <= 0\n        && element.compareTo(to) <= 0;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/attachments/AttachmentGenerator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.attachments;\nimport java.util.Map;\n\npublic interface AttachmentGenerator {\n\n  record Descriptor(Map<String, String> headers, String signedUploadLocation) {}\n\n  Descriptor generateAttachment(final String key);\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/attachments/AttachmentUtil.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.attachments;\n\nimport java.security.SecureRandom;\nimport java.util.Base64;\n\npublic class AttachmentUtil {\n  public static final String CDN3_EXPERIMENT_NAME = \"cdn3\";\n\n  private AttachmentUtil() {}\n\n  public static String generateAttachmentKey(final SecureRandom secureRandom) {\n    final byte[] bytes = new byte[15];\n    secureRandom.nextBytes(bytes);\n    return Base64.getUrlEncoder().encodeToString(bytes);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/attachments/GcsAttachmentGenerator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.attachments;\n\nimport org.whispersystems.textsecuregcm.gcp.CanonicalRequest;\nimport org.whispersystems.textsecuregcm.gcp.CanonicalRequestGenerator;\nimport org.whispersystems.textsecuregcm.gcp.CanonicalRequestSigner;\nimport javax.annotation.Nonnull;\nimport java.io.IOException;\nimport java.security.InvalidKeyException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.ZoneOffset;\nimport java.time.ZonedDateTime;\nimport java.util.Map;\n\npublic class GcsAttachmentGenerator implements AttachmentGenerator {\n  @Nonnull\n  private final CanonicalRequestGenerator canonicalRequestGenerator;\n\n  @Nonnull\n  private final CanonicalRequestSigner canonicalRequestSigner;\n\n  public GcsAttachmentGenerator(@Nonnull String domain, @Nonnull String email,\n      int maxSizeInBytes, @Nonnull String pathPrefix, @Nonnull String rsaSigningKey)\n      throws IOException, InvalidKeyException, InvalidKeySpecException {\n    this.canonicalRequestGenerator = new CanonicalRequestGenerator(domain, email, maxSizeInBytes, pathPrefix);\n    this.canonicalRequestSigner = new CanonicalRequestSigner(rsaSigningKey);\n  }\n\n  @Override\n  public Descriptor generateAttachment(final String key) {\n    final ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);\n    final CanonicalRequest canonicalRequest = canonicalRequestGenerator.createFor(key, now);\n    return new Descriptor(getHeaderMap(canonicalRequest), getSignedUploadLocation(canonicalRequest));\n  }\n\n  private String getSignedUploadLocation(@Nonnull CanonicalRequest canonicalRequest) {\n    return \"https://\" + canonicalRequest.getDomain() + canonicalRequest.getResourcePath()\n        + '?' + canonicalRequest.getCanonicalQuery()\n        + \"&X-Goog-Signature=\" + canonicalRequestSigner.sign(canonicalRequest);\n  }\n\n  private static Map<String, String> getHeaderMap(@Nonnull CanonicalRequest canonicalRequest) {\n    return Map.of(\n        \"host\", canonicalRequest.getDomain(),\n        \"x-goog-content-length-range\", \"1,\" + canonicalRequest.getMaxSizeInBytes(),\n        \"x-goog-resumable\", \"start\");\n  }\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusAttachmentGenerator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.attachments;\n\nimport org.apache.http.HttpHeaders;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.util.Base64;\nimport java.util.Map;\n\npublic class TusAttachmentGenerator implements AttachmentGenerator {\n\n  private static final String ATTACHMENTS = \"attachments\";\n\n  final ExternalServiceCredentialsGenerator credentialsGenerator;\n  final String tusUri;\n\n  public TusAttachmentGenerator(final TusConfiguration cfg) {\n    this.tusUri = cfg.uploadUri();\n    this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg);\n  }\n\n  private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock, final TusConfiguration cfg) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .prependUsername(false)\n        .withClock(clock)\n        .build();\n  }\n\n  @Override\n  public Descriptor generateAttachment(final String key) {\n    final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(ATTACHMENTS + \"/\" + key);\n    final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));\n    final Map<String, String> headers = Map.of(\n        HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials),\n        \"Upload-Metadata\", String.format(\"filename %s\", b64Key)\n    );\n    return new Descriptor(headers, tusUri + \"/\" +  ATTACHMENTS);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/attachments/TusConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.attachments;\n\nimport jakarta.validation.constraints.NotEmpty;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic record TusConfiguration(\n  @ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,\n  @NotEmpty String uploadUri\n){}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/AccountAuthenticator.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.auth.Authenticator;\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic class AccountAuthenticator implements Authenticator<BasicCredentials, AuthenticatedDevice> {\n\n  private static final String AUTHENTICATION_COUNTER_NAME = name(AccountAuthenticator.class, \"authentication\");\n  private static final String AUTHENTICATION_SUCCEEDED_TAG_NAME = \"succeeded\";\n  private static final String AUTHENTICATION_FAILURE_REASON_TAG_NAME = \"reason\";\n\n  private static final String DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME = name(AccountAuthenticator.class, \"daysSinceLastSeen\");\n  private static final String IS_PRIMARY_DEVICE_TAG = \"isPrimary\";\n\n  private static final Counter OLD_TOKEN_VERSION_COUNTER =\n      Metrics.counter(name(AccountAuthenticator.class, \"oldTokenVersionCounter\"));\n\n  @VisibleForTesting\n  static final char DEVICE_ID_SEPARATOR = '.';\n\n  private final AccountsManager accountsManager;\n  private final Clock clock;\n\n  public AccountAuthenticator(AccountsManager accountsManager) {\n    this(accountsManager, Clock.systemUTC());\n  }\n\n  @VisibleForTesting\n  public AccountAuthenticator(AccountsManager accountsManager, Clock clock) {\n    this.accountsManager = accountsManager;\n    this.clock = clock;\n  }\n\n  static Pair<String, Byte> getIdentifierAndDeviceId(final String basicUsername) {\n    final String identifier;\n    final byte deviceId;\n\n    final int deviceIdSeparatorIndex = basicUsername.indexOf(DEVICE_ID_SEPARATOR);\n\n    if (deviceIdSeparatorIndex == -1) {\n      identifier = basicUsername;\n      deviceId = Device.PRIMARY_ID;\n    } else {\n      identifier = basicUsername.substring(0, deviceIdSeparatorIndex);\n      deviceId = Byte.parseByte(basicUsername.substring(deviceIdSeparatorIndex + 1));\n    }\n\n    return new Pair<>(identifier, deviceId);\n  }\n\n  @Override\n  public Optional<AuthenticatedDevice> authenticate(BasicCredentials basicCredentials) {\n    boolean succeeded = false;\n    String failureReason = null;\n\n    try {\n      final UUID accountUuid;\n      final byte deviceId;\n      {\n        final Pair<String, Byte> identifierAndDeviceId = getIdentifierAndDeviceId(basicCredentials.getUsername());\n\n        accountUuid = UUID.fromString(identifierAndDeviceId.first());\n        deviceId = identifierAndDeviceId.second();\n      }\n\n      Optional<Account> account = accountsManager.getByAccountIdentifier(accountUuid);\n\n      if (account.isEmpty()) {\n        failureReason = \"noSuchAccount\";\n        return Optional.empty();\n      }\n\n      Optional<Device> device = account.get().getDevice(deviceId);\n\n      if (device.isEmpty()) {\n        failureReason = \"noSuchDevice\";\n        return Optional.empty();\n      }\n\n      SaltedTokenHash deviceSaltedTokenHash = device.get().getAuthTokenHash();\n      if (deviceSaltedTokenHash.verify(basicCredentials.getPassword())) {\n        succeeded = true;\n        Account authenticatedAccount = updateLastSeen(account.get(), device.get());\n        if (deviceSaltedTokenHash.getVersion() != SaltedTokenHash.CURRENT_VERSION) {\n          OLD_TOKEN_VERSION_COUNTER.increment();\n          authenticatedAccount = accountsManager.updateDeviceAuthentication(\n              authenticatedAccount,\n              device.get(),\n              SaltedTokenHash.generateFor(basicCredentials.getPassword()));  // new credentials have current version\n        }\n        return Optional.of(new AuthenticatedDevice(authenticatedAccount.getIdentifier(IdentityType.ACI),\n            device.get().getId(),\n            Instant.ofEpochMilli(authenticatedAccount.getPrimaryDevice().getLastSeen())));\n      } else {\n        failureReason = \"incorrectPassword\";\n        return Optional.empty();\n      }\n    } catch (IllegalArgumentException | InvalidAuthorizationHeaderException iae) {\n      failureReason = \"invalidHeader\";\n      return Optional.empty();\n    } finally {\n      Tags tags = Tags.of(\n          AUTHENTICATION_SUCCEEDED_TAG_NAME, String.valueOf(succeeded));\n\n      if (StringUtils.isNotBlank(failureReason)) {\n        tags = tags.and(AUTHENTICATION_FAILURE_REASON_TAG_NAME, failureReason);\n      }\n\n      Metrics.counter(AUTHENTICATION_COUNTER_NAME, tags).increment();\n    }\n  }\n\n  @VisibleForTesting\n  public Account updateLastSeen(Account account, Device device) {\n    // compute a non-negative integer between 0 and 86400.\n    long n = Util.ensureNonNegativeLong(account.getUuid().getLeastSignificantBits());\n    final long lastSeenOffsetSeconds = n % ChronoUnit.DAYS.getDuration().toSeconds();\n\n    // produce a truncated timestamp which is either today at UTC midnight\n    // or yesterday at UTC midnight, based on per-user randomized offset used.\n    final long todayInMillisWithOffset = Util.todayInMillisGivenOffsetFromNow(clock,\n        Duration.ofSeconds(lastSeenOffsetSeconds).negated());\n\n    // only update the device's last seen time when it falls behind the truncated timestamp.\n    // this ensures a few things:\n    //   (1) each account will only update last-seen at most once per day\n    //   (2) these updates will occur throughout the day rather than all occurring at UTC midnight.\n    if (device.getLastSeen() < todayInMillisWithOffset) {\n      Metrics.summary(DAYS_SINCE_LAST_SEEN_DISTRIBUTION_NAME, IS_PRIMARY_DEVICE_TAG, String.valueOf(device.isPrimary()))\n          .record(Duration.ofMillis(todayInMillisWithOffset - device.getLastSeen()).toDays());\n\n      return accountsManager.updateDeviceLastSeen(account, device, Util.todayInMillis(clock));\n    }\n\n    return account;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/Anonymous.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Response;\nimport java.util.Base64;\n\npublic class Anonymous {\n\n  private final byte[] unidentifiedSenderAccessKey;\n\n  public Anonymous(String header) {\n    try {\n      this.unidentifiedSenderAccessKey = Base64.getDecoder().decode(header);\n    } catch (IllegalArgumentException e) {\n      throw new WebApplicationException(e, Response.Status.UNAUTHORIZED);\n    }\n  }\n\n  public byte[] getAccessKey() {\n    return unidentifiedSenderAccessKey;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthenticatedBackupUser.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport javax.annotation.Nullable;\n\npublic record AuthenticatedBackupUser(\n    byte[] backupId,\n    BackupCredentialType credentialType,\n    BackupLevel backupLevel,\n    String backupDir,\n    String mediaDir,\n    @Nullable UserAgent userAgent) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/AuthenticatedDevice.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.security.Principal;\nimport java.time.Instant;\nimport java.util.UUID;\nimport javax.security.auth.Subject;\n\npublic record AuthenticatedDevice(UUID accountIdentifier, byte deviceId, Instant primaryDeviceLastSeen)\n    implements Principal {\n\n  @Override\n  public String getName() {\n    return null;\n  }\n\n  @Override\n  public boolean implies(final Subject subject) {\n    return false;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeader.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.util.Base64;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.util.Pair;\n\npublic class BasicAuthorizationHeader {\n\n  private final String username;\n  private final byte deviceId;\n  private final String password;\n\n  private BasicAuthorizationHeader(final String username, final byte deviceId, final String password) {\n    this.username = username;\n    this.deviceId = deviceId;\n    this.password = password;\n  }\n\n  public static BasicAuthorizationHeader fromString(final String header) throws InvalidAuthorizationHeaderException {\n    try {\n      if (StringUtils.isBlank(header)) {\n        throw new InvalidAuthorizationHeaderException(\"Blank header\");\n      }\n\n      final int spaceIndex = header.indexOf(' ');\n\n      if (spaceIndex == -1) {\n        throw new InvalidAuthorizationHeaderException(\"Invalid authorization header: \" + header);\n      }\n\n      final String authorizationType = header.substring(0, spaceIndex);\n\n      if (!\"Basic\".equals(authorizationType)) {\n        throw new InvalidAuthorizationHeaderException(\"Unsupported authorization method: \" + authorizationType);\n      }\n\n      final String credentials;\n\n      try {\n        credentials = new String(Base64.getDecoder().decode(header.substring(spaceIndex + 1)));\n      } catch (final IndexOutOfBoundsException e) {\n        throw new InvalidAuthorizationHeaderException(\"Missing credentials\");\n      }\n\n      if (StringUtils.isEmpty(credentials)) {\n        throw new InvalidAuthorizationHeaderException(\"Bad decoded value: \" + credentials);\n      }\n\n      final int credentialSeparatorIndex = credentials.indexOf(':');\n\n      if (credentialSeparatorIndex == -1) {\n        throw new InvalidAuthorizationHeaderException(\"Badly-formatted credentials: \" + credentials);\n      }\n\n      final String usernameComponent = credentials.substring(0, credentialSeparatorIndex);\n\n      final String username;\n      final byte deviceId;\n      {\n        final Pair<String, Byte> identifierAndDeviceId =\n            AccountAuthenticator.getIdentifierAndDeviceId(usernameComponent);\n\n        username = identifierAndDeviceId.first();\n        deviceId = identifierAndDeviceId.second();\n      }\n\n      final String password = credentials.substring(credentialSeparatorIndex + 1);\n\n      if (StringUtils.isAnyBlank(username, password)) {\n        throw new InvalidAuthorizationHeaderException(\"Username or password were blank\");\n      }\n\n      return new BasicAuthorizationHeader(username, deviceId, password);\n    } catch (final IllegalArgumentException | IndexOutOfBoundsException e) {\n      throw new InvalidAuthorizationHeaderException(e);\n    }\n  }\n\n  public String getUsername() {\n    return username;\n  }\n\n  public long getDeviceId() {\n    return deviceId;\n  }\n\n  public String getPassword() {\n    return password;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/CertificateGenerator.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport java.util.concurrent.TimeUnit;\nimport org.signal.libsignal.protocol.ecc.ECPrivateKey;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class CertificateGenerator {\n\n  private final ECPrivateKey privateKey;\n  private final int expiresDays;\n  private final boolean embedSigner;\n  private final ServerCertificate serverCertificate;\n  private final int serverCertificateId;\n\n  public CertificateGenerator(byte[] serverCertificate, ECPrivateKey privateKey, int expiresDays, boolean embedSigner)\n      throws InvalidProtocolBufferException {\n    this.privateKey = privateKey;\n    this.expiresDays = expiresDays;\n    this.embedSigner = embedSigner;\n    this.serverCertificate = ServerCertificate.parseFrom(serverCertificate);\n    this.serverCertificateId = ServerCertificate.Certificate\n        .parseFrom(this.serverCertificate.getCertificate())\n        .getId();\n  }\n\n  public byte[] createFor(final Account account, final byte deviceId, boolean includeE164) {\n    SenderCertificate.Certificate.Builder builder = SenderCertificate.Certificate.newBuilder()\n        .setSenderDevice(Math.toIntExact(deviceId))\n        .setExpires(System.currentTimeMillis() + TimeUnit.DAYS.toMillis(expiresDays))\n        .setIdentityKey(ByteString.copyFrom(account.getIdentityKey(IdentityType.ACI).serialize()))\n        .setSenderUuid(UUIDUtil.toByteString(account.getUuid()));\n\n    if (includeE164) {\n      builder.setSenderE164(account.getNumber());\n    }\n\n    if (embedSigner) {\n      builder.setSignerCertificate(serverCertificate);\n    } else {\n      builder.setSignerId(serverCertificateId);\n    }\n\n    byte[] certificate = builder.build().toByteArray();\n    byte[] signature;\n    signature = privateKey.calculateSignature(certificate);\n\n    return SenderCertificate.newBuilder()\n        .setCertificate(ByteString.copyFrom(certificate))\n        .setSignature(ByteString.copyFrom(signature))\n        .build()\n        .toByteArray();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/ChangesLinkedDevices.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * Indicates that an endpoint may change the \"enabled\" state of one or more devices associated with an account, and that\n * any websockets associated with the account may need to be refreshed after a call to that endpoint.\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface ChangesLinkedDevices {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/ChangesPhoneNumber.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n/**\n * Indicates that an endpoint changes the phone number and PNI keys associated with an account, and that\n * any websockets associated with the account may need to be refreshed after a call to that endpoint.\n */\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface ChangesPhoneNumber {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport io.netty.resolver.dns.DnsNameResolver;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.net.Inet6Address;\nimport java.net.URI;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ScheduledExecutorService;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class CloudflareTurnCredentialsManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(CloudflareTurnCredentialsManager.class);\n\n  private final List<String> cloudflareTurnUrls;\n  private final List<String> cloudflareTurnUrlsWithIps;\n  private final String cloudflareTurnHostname;\n  private final HttpRequest getCredentialsRequest;\n\n  private final FaultTolerantHttpClient cloudflareTurnClient;\n  private final DnsNameResolver dnsNameResolver;\n\n  private final Duration clientCredentialTtl;\n\n  private record CredentialRequest(long ttl) {}\n\n  private record CloudflareTurnResponse(IceServer iceServers) {\n\n    private record IceServer(\n        String username,\n        String credential,\n        List<String> urls) {\n    }\n  }\n\n  public CloudflareTurnCredentialsManager(final String cloudflareTurnApiToken,\n      final String cloudflareTurnEndpoint,\n      final Duration requestedCredentialTtl,\n      final Duration clientCredentialTtl,\n      final List<String> cloudflareTurnUrls,\n      final List<String> cloudflareTurnUrlsWithIps,\n      final String cloudflareTurnHostname,\n      final int cloudflareTurnNumHttpClients,\n      @Nullable final String circuitBreakerConfigurationName,\n      final ExecutorService executor,\n      @Nullable final String retryConfigurationName,\n      final ScheduledExecutorService retryExecutor,\n      final DnsNameResolver dnsNameResolver) {\n\n    this.cloudflareTurnClient = FaultTolerantHttpClient.newBuilder(\"cloudflare-turn\", executor)\n        .withCircuitBreaker(circuitBreakerConfigurationName)\n        .withRetry(retryConfigurationName, retryExecutor)\n        .withNumClients(cloudflareTurnNumHttpClients)\n        .build();\n    this.cloudflareTurnUrls = cloudflareTurnUrls;\n    this.cloudflareTurnUrlsWithIps = cloudflareTurnUrlsWithIps;\n    this.cloudflareTurnHostname = cloudflareTurnHostname;\n    this.dnsNameResolver = dnsNameResolver;\n\n    final String credentialsRequestBody;\n\n    try {\n      credentialsRequestBody =\n          SystemMapper.jsonMapper().writeValueAsString(new CredentialRequest(requestedCredentialTtl.toSeconds()));\n    } catch (final JsonProcessingException e) {\n      throw new IllegalArgumentException(e);\n    }\n\n    // We repeat the same request to Cloudflare every time, so we can construct it once and re-use it\n    this.getCredentialsRequest = HttpRequest.newBuilder()\n        .uri(URI.create(cloudflareTurnEndpoint))\n        .header(\"Content-Type\", \"application/json\")\n        .header(\"Authorization\", String.format(\"Bearer %s\", cloudflareTurnApiToken))\n        .POST(HttpRequest.BodyPublishers.ofString(credentialsRequestBody))\n        .build();\n\n    this.clientCredentialTtl = clientCredentialTtl;\n  }\n\n  public TurnToken retrieveFromCloudflare() throws IOException {\n    final List<String> cloudflareTurnComposedUrls;\n    try {\n      cloudflareTurnComposedUrls = dnsNameResolver.resolveAll(cloudflareTurnHostname).get().stream()\n          .map(i -> switch (i) {\n            case Inet6Address i6 -> \"[\" + i6.getHostAddress() + \"]\";\n            default -> i.getHostAddress();\n          })\n          .flatMap(i -> cloudflareTurnUrlsWithIps.stream().map(u -> u.formatted(i)))\n          .toList();\n    } catch (Exception e) {\n      throw new IOException(e);\n    }\n\n    final HttpResponse<String> response;\n    try {\n      response = cloudflareTurnClient.sendAsync(getCredentialsRequest, HttpResponse.BodyHandlers.ofString()).join();\n    } catch (CompletionException e) {\n      logger.warn(\"failed to make http request to Cloudflare Turn: {}\", e.getMessage());\n      throw new IOException(ExceptionUtils.unwrap(e));\n    }\n\n    if (response.statusCode() != Response.Status.CREATED.getStatusCode()) {\n      logger.warn(\"failure request credentials from Cloudflare Turn (code={}): {}\", response.statusCode(), response);\n      throw new IOException(\"Cloudflare Turn http failure : \" + response.statusCode());\n    }\n\n    final CloudflareTurnResponse cloudflareTurnResponse = SystemMapper.jsonMapper()\n        .readValue(response.body(), CloudflareTurnResponse.class);\n\n    return new TurnToken(\n        cloudflareTurnResponse.iceServers().username(),\n        cloudflareTurnResponse.iceServers().credential(),\n        clientCredentialTtl.toSeconds(),\n        cloudflareTurnUrls == null ? Collections.emptyList() : cloudflareTurnUrls,\n        cloudflareTurnComposedUrls,\n        cloudflareTurnHostname\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/CombinedUnidentifiedSenderAccessKeys.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.util.Base64;\n\npublic class CombinedUnidentifiedSenderAccessKeys {\n  private final byte[] combinedUnidentifiedSenderAccessKeys;\n\n  public CombinedUnidentifiedSenderAccessKeys(String header) {\n    try {\n      this.combinedUnidentifiedSenderAccessKeys = Base64.getDecoder().decode(header);\n      if (this.combinedUnidentifiedSenderAccessKeys == null || this.combinedUnidentifiedSenderAccessKeys.length != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {\n        throw new WebApplicationException(\"Invalid combined unidentified sender access keys\", Status.UNAUTHORIZED);\n      }\n    } catch (IllegalArgumentException e) {\n      throw new WebApplicationException(e, Response.Status.UNAUTHORIZED);\n    }\n  }\n\n  public byte[] getAccessKeys() {\n    return combinedUnidentifiedSenderAccessKeys;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/DisconnectionRequestListener.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\n/**\n * A disconnection request listener receives and handles a request to close an authenticated network connection for a\n * specific client.\n */\npublic interface DisconnectionRequestListener {\n\n  /**\n   * Handles a request to close an authenticated network connection for a specific authenticated device. Requests are\n   * dispatched on dedicated threads, and implementations may safely block.\n   */\n  void handleDisconnectionRequest();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/DisconnectionRequestManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.lettuce.core.pubsub.RedisPubSubAdapter;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\n/**\n * A disconnection request manager broadcasts and dispatches requests for servers to close authenticated connections\n * from specific clients.\n *\n * @see DisconnectionRequestListener\n */\npublic class DisconnectionRequestManager extends RedisPubSubAdapter<byte[], byte[]> implements Managed {\n\n  private final FaultTolerantRedisClient pubSubClient;\n  private final Executor listenerEventExecutor;\n  private final ScheduledExecutorService retryExecutor;\n\n  private static final String RETRY_NAME = ResilienceUtil.name(DisconnectionRequestManager.class);\n\n  private static final Duration SUBSCRIBE_RETRY_DELAY = Duration.ofSeconds(5);\n\n  private final Map<AccountIdentifierAndDeviceId, List<DisconnectionRequestListener>> listeners =\n      new ConcurrentHashMap<>();\n\n  @Nullable\n  private FaultTolerantPubSubConnection<byte[], byte[]> pubSubConnection;\n\n  private static final byte[] DISCONNECTION_REQUEST_CHANNEL = \"disconnection_requests\".getBytes(StandardCharsets.UTF_8);\n\n  private static final Counter DISCONNECTION_REQUESTS_SENT_COUNTER =\n      Metrics.counter(MetricsUtil.name(DisconnectionRequestManager.class, \"requestsSent\"));\n\n  private static final Counter DISCONNECTION_REQUESTS_RECEIVED_COUNTER =\n      Metrics.counter(MetricsUtil.name(DisconnectionRequestManager.class, \"requestsReceived\"));\n\n  private static final Logger logger = LoggerFactory.getLogger(DisconnectionRequestManager.class);\n\n  private record AccountIdentifierAndDeviceId(UUID accountIdentifier, byte deviceId) {}\n\n  public DisconnectionRequestManager(final FaultTolerantRedisClient pubSubClient,\n      final Executor listenerEventExecutor,\n      final ScheduledExecutorService retryExecutor) {\n\n    this.pubSubClient = pubSubClient;\n    this.listenerEventExecutor = listenerEventExecutor;\n    this.retryExecutor = retryExecutor;\n  }\n\n  @Override\n  public synchronized void start() {\n    this.pubSubConnection = pubSubClient.createBinaryPubSubConnection();\n    this.pubSubConnection.usePubSubConnection(connection -> {\n      connection.addListener(this);\n\n      boolean subscribed = false;\n\n      // Loop indefinitely until we establish a subscription. We don't want to fail immediately if there's a temporary\n      // Redis connectivity issue, since that would derail the whole startup process and likely lead to unnecessary pod\n      // churn, which might make things worse. If we never establish a connection, readiness probes will eventually fail\n      // and terminate the pods.\n      do {\n        try {\n          connection.sync().subscribe(DISCONNECTION_REQUEST_CHANNEL);\n          subscribed = true;\n        } catch (final RedisCommandTimeoutException e) {\n          try {\n            Thread.sleep(SUBSCRIBE_RETRY_DELAY);\n          } catch (final InterruptedException ex) {\n            throw new RuntimeException(ex);\n          }\n        }\n      } while (!subscribed);\n    });\n  }\n\n  @Override\n  public synchronized void stop() {\n    if (pubSubConnection != null) {\n      pubSubConnection.usePubSubConnection(connection -> {\n        connection.removeListener(this);\n        connection.close();\n      });\n    }\n\n    pubSubConnection = null;\n  }\n\n  /**\n   * Adds a listener for disconnection requests for a specific authenticated device.\n   *\n   * @param accountIdentifier TODO\n   * @param deviceId TODO\n   * @param listener the listener to register\n   */\n  public void addListener(final UUID accountIdentifier, final byte deviceId, final DisconnectionRequestListener listener) {\n    listeners.compute(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), (_, existingListeners) -> {\n      final List<DisconnectionRequestListener> listeners =\n          existingListeners == null ? new ArrayList<>() : existingListeners;\n\n      listeners.add(listener);\n\n      return listeners;\n    });\n  }\n\n  /**\n   * Removes a listener for disconnection requests for a specific authenticated device.\n   *\n   * @param accountIdentifier TODO\n   * @param deviceId TODO\n   * @param listener the listener to remove\n   */\n  public void removeListener(final UUID accountIdentifier, final byte deviceId, final DisconnectionRequestListener listener) {\n    listeners.computeIfPresent(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), (_, existingListeners) -> {\n      existingListeners.remove(listener);\n\n      return existingListeners.isEmpty() ? null : existingListeners;\n    });\n  }\n\n  @VisibleForTesting\n  List<DisconnectionRequestListener> getListeners(final UUID accountIdentifier, final byte deviceId) {\n    return listeners.getOrDefault(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), Collections.emptyList());\n  }\n\n  /**\n   * Broadcasts a request to close all connections associated with the given account identifier to all servers.\n   *\n   * @param account the account for which to close connections\n   *\n   * @return a future that completes when the request has been broadcast\n   */\n  public CompletionStage<Void> requestDisconnection(final Account account) {\n    return requestDisconnection(account.getIdentifier(IdentityType.ACI),\n        account.getDevices().stream().map(Device::getId).toList());\n  }\n\n  /**\n   * Broadcasts a request to close connections associated with the given account identifier and device IDs to all\n   * servers.\n   *\n   * @param accountIdentifier the account for which to close connections\n   * @param deviceIds the device IDs for which to close connections\n   *\n   * @return a future that completes when the request has been broadcast\n   */\n  public CompletionStage<Void> requestDisconnection(final UUID accountIdentifier, final Collection<Byte> deviceIds) {\n    final DisconnectionRequest disconnectionRequest = DisconnectionRequest.newBuilder()\n        .setAccountIdentifier(UUIDUtil.toByteString(accountIdentifier))\n        .addAllDeviceIds(deviceIds.stream().mapToInt(Byte::intValue).boxed().toList())\n        .build();\n\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> pubSubClient.withBinaryConnection(connection ->\n                connection.async().publish(DISCONNECTION_REQUEST_CHANNEL, disconnectionRequest.toByteArray()))\n            .toCompletableFuture())\n        .thenRun(DISCONNECTION_REQUESTS_SENT_COUNTER::increment);\n  }\n\n  @Override\n  public void message(final byte[] channel, final byte[] message) {\n    final UUID accountIdentifier;\n    final List<Byte> deviceIds;\n\n    try {\n      final DisconnectionRequest disconnectionRequest = DisconnectionRequest.parseFrom(message);\n      DISCONNECTION_REQUESTS_RECEIVED_COUNTER.increment();\n\n      accountIdentifier = UUIDUtil.fromByteString(disconnectionRequest.getAccountIdentifier());\n      deviceIds = disconnectionRequest.getDeviceIdsList().stream()\n          .map(deviceIdInt -> {\n            if (deviceIdInt == null || deviceIdInt < Device.PRIMARY_ID || deviceIdInt > Byte.MAX_VALUE) {\n              throw new IllegalArgumentException(\"Invalid device ID: \" + deviceIdInt);\n            }\n\n            return deviceIdInt.byteValue();\n          })\n          .toList();\n    } catch (final InvalidProtocolBufferException e) {\n      logger.error(\"Could not parse disconnection request protobuf\", e);\n      return;\n    } catch (final IllegalArgumentException e) {\n      logger.error(\"Could not parse part of disconnection request\", e);\n      return;\n    }\n\n    deviceIds.forEach(deviceId -> {\n      listeners.getOrDefault(new AccountIdentifierAndDeviceId(accountIdentifier, deviceId), Collections.emptyList())\n          .forEach(listener -> listenerEventExecutor.execute(() -> {\n            try {\n              listener.handleDisconnectionRequest();\n            } catch (final Exception e) {\n              logger.warn(\"Listener failed to handle disconnection request\", e);\n            }\n          }));\n    });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentials.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\n\npublic record ExternalServiceCredentials(String username, String password) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGenerator.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static java.util.Objects.requireNonNull;\nimport static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256ToHexString;\nimport static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256TruncatedToHexString;\nimport static org.whispersystems.textsecuregcm.util.HmacUtils.hmacHexStringsEqual;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.function.Function;\nimport org.apache.commons.lang3.StringUtils;\nimport org.apache.commons.lang3.Validate;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic class ExternalServiceCredentialsGenerator {\n\n  private static final String DELIMITER = \":\";\n\n  private static final int TRUNCATED_SIGNATURE_LENGTH = 10;\n\n  private final byte[] key;\n\n  private final byte[] userDerivationKey;\n\n  private final boolean prependUsername;\n\n  private final boolean truncateSignature;\n\n  private final String usernameTimestampPrefix;\n\n  private final Function<Instant, Instant> usernameTimestampTruncator;\n\n  private final Clock clock;\n\n  private final int derivedUsernameTruncateLength;\n\n\n  public static ExternalServiceCredentialsGenerator.Builder builder(final SecretBytes key) {\n    return builder(key.value());\n  }\n\n  @VisibleForTesting\n  public static ExternalServiceCredentialsGenerator.Builder builder(final byte[] key) {\n    return new Builder(key);\n  }\n\n  private ExternalServiceCredentialsGenerator(\n      final byte[] key,\n      final byte[] userDerivationKey,\n      final boolean prependUsername,\n      final boolean truncateSignature,\n      final int derivedUsernameTruncateLength,\n      final String usernameTimestampPrefix,\n      final Function<Instant, Instant> usernameTimestampTruncator,\n      final Clock clock) {\n    this.key = requireNonNull(key);\n    this.userDerivationKey = requireNonNull(userDerivationKey);\n    this.prependUsername = prependUsername;\n    this.truncateSignature = truncateSignature;\n    this.usernameTimestampPrefix = usernameTimestampPrefix;\n    this.usernameTimestampTruncator = usernameTimestampTruncator;\n    this.clock = requireNonNull(clock);\n    this.derivedUsernameTruncateLength = derivedUsernameTruncateLength;\n\n    if (hasUsernameTimestampPrefix() ^ hasUsernameTimestampTruncator()) {\n      throw new RuntimeException(\"Configured to have only one of (usernameTimestampPrefix, usernameTimestampTruncator)\");\n    }\n  }\n\n  /**\n   * A convenience method for the case of identity in the form of {@link UUID}.\n   * @param uuid identity to generate credentials for\n   * @return an instance of {@link ExternalServiceCredentials}\n   */\n  public ExternalServiceCredentials generateForUuid(final UUID uuid) {\n    return generateFor(uuid.toString());\n  }\n\n  /**\n   * Generates `ExternalServiceCredentials` for the given identity following this generator's configuration.\n   * @param identity identity string to generate credentials for\n   * @return an instance of {@link ExternalServiceCredentials}\n   */\n  public ExternalServiceCredentials generateFor(final String identity) {\n    if (usernameIsTimestamp()) {\n      throw new RuntimeException(\"Configured to use timestamp as username\");\n    }\n\n    return generate(identity);\n  }\n\n  /**\n   * Generates `ExternalServiceCredentials` using a prefix concatenated with a truncated timestamp as the username, following this generator's configuration.\n   * @return an instance of {@link ExternalServiceCredentials}\n   */\n  public ExternalServiceCredentials generateWithTimestampAsUsername() {\n    if (!usernameIsTimestamp()) {\n      throw new RuntimeException(\"Not configured to use timestamp as username\");\n    }\n\n    final String truncatedTimestampSeconds = String.valueOf(usernameTimestampTruncator.apply(clock.instant()).getEpochSecond());\n    return generate(usernameTimestampPrefix + DELIMITER + truncatedTimestampSeconds);\n  }\n\n  private ExternalServiceCredentials generate(final String identity) {\n    final String username = shouldDeriveUsername()\n        ? hmac256TruncatedToHexString(userDerivationKey, identity, derivedUsernameTruncateLength)\n        : identity;\n\n    final long currentTimeSeconds = currentTimeSeconds();\n\n    final String dataToSign = usernameIsTimestamp() ? username : username + DELIMITER + currentTimeSeconds;\n\n    final String signature = truncateSignature\n        ? hmac256TruncatedToHexString(key, dataToSign, TRUNCATED_SIGNATURE_LENGTH)\n        : hmac256ToHexString(key, dataToSign);\n\n    final String token = (prependUsername ? dataToSign : currentTimeSeconds) + DELIMITER + signature;\n\n    return new ExternalServiceCredentials(username, token);\n  }\n\n  /**\n   * In certain cases, identity (as it was passed to `generate` method)\n   * is a part of the signature (`password`, in terms of `ExternalServiceCredentials`) string itself.\n   * For such cases, this method returns the value of the identity string.\n   * @param password `password` part of `ExternalServiceCredentials`\n   * @return non-empty optional with an identity string value, or empty if value can't be extracted.\n   */\n  public Optional<String> identityFromSignature(final String password) {\n    // for some generators, identity in the clear is just not a part of the password\n    if (!prependUsername || shouldDeriveUsername() || StringUtils.isBlank(password)) {\n      return Optional.empty();\n    }\n    // checking for the case of unexpected format\n    if (StringUtils.countMatches(password, DELIMITER) == 2) {\n      if (usernameIsTimestamp()) {\n        final int indexOfSecondDelimiter = password.indexOf(DELIMITER, password.indexOf(DELIMITER) + 1);\n        return Optional.of(password.substring(0, indexOfSecondDelimiter));\n      } else {\n        return Optional.of(password.substring(0, password.indexOf(DELIMITER)));\n      }\n    }\n    return Optional.empty();\n  }\n\n  /**\n   * Given an instance of {@link ExternalServiceCredentials} object, checks that the password\n   * matches the username taking into account this generator's configuration.\n   * @param credentials an instance of {@link ExternalServiceCredentials}\n   * @return An optional with a timestamp (seconds) of when the credentials were generated,\n   *         or an empty optional if the password doesn't match the username for any reason (including malformed data)\n   */\n  public Optional<Long> validateAndGetTimestamp(final ExternalServiceCredentials credentials) {\n    final String[] parts = requireNonNull(credentials).password().split(DELIMITER);\n    final String timestampSeconds;\n    final String actualSignature;\n\n    // making sure password format matches our expectations based on the generator configuration\n    if (parts.length == 3 && prependUsername) {\n      final String username = usernameIsTimestamp() ? parts[0] + DELIMITER + parts[1] : parts[0];\n      // username has to match the one from `credentials`\n      if (!credentials.username().equals(username)) {\n        return Optional.empty();\n      }\n      timestampSeconds = parts[1];\n      actualSignature = parts[2];\n    } else if (parts.length == 2 && !prependUsername) {\n      timestampSeconds = parts[0];\n      actualSignature = parts[1];\n    } else {\n      // unexpected password format\n      return Optional.empty();\n    }\n\n    final String signedData = usernameIsTimestamp() ? credentials.username() : credentials.username() + DELIMITER + timestampSeconds;\n    final String expectedSignature = truncateSignature\n        ? hmac256TruncatedToHexString(key, signedData, TRUNCATED_SIGNATURE_LENGTH)\n        : hmac256ToHexString(key, signedData);\n\n    // if the signature is valid it's safe to parse the `timestampSeconds` string into Long\n    return hmacHexStringsEqual(expectedSignature, actualSignature)\n        ? Optional.of(Long.valueOf(timestampSeconds))\n        : Optional.empty();\n  }\n\n  @VisibleForTesting\n  boolean isCredentialExpired(final long credentialTimestamp, final long maxAgeSeconds) {\n    return currentTimeSeconds() - credentialTimestamp > maxAgeSeconds;\n  }\n\n  private boolean shouldDeriveUsername() {\n    return userDerivationKey.length > 0;\n  }\n\n  private boolean hasUsernameTimestampPrefix() {\n    return usernameTimestampPrefix != null;\n  }\n\n  private boolean hasUsernameTimestampTruncator() {\n    return usernameTimestampTruncator != null;\n  }\n\n  private boolean usernameIsTimestamp() {\n    return hasUsernameTimestampPrefix() && hasUsernameTimestampTruncator();\n  }\n\n  private long currentTimeSeconds() {\n    return clock.instant().getEpochSecond();\n  }\n\n  public static class Builder {\n\n    private final byte[] key;\n\n    private byte[] userDerivationKey = new byte[0];\n\n    private boolean prependUsername = true;\n\n    private boolean truncateSignature = true;\n\n    private int derivedUsernameTruncateLength = 10;\n\n    private String usernameTimestampPrefix = null;\n\n    private Function<Instant, Instant> usernameTimestampTruncator = null;\n\n    private Clock clock = Clock.systemUTC();\n\n\n    private Builder(final byte[] key) {\n      this.key = requireNonNull(key);\n    }\n\n    public Builder withUserDerivationKey(final SecretBytes userDerivationKey) {\n      return withUserDerivationKey(userDerivationKey.value());\n    }\n\n    public Builder withUserDerivationKey(final byte[] userDerivationKey) {\n      Validate.isTrue(requireNonNull(userDerivationKey).length > 0, \"userDerivationKey must not be empty\");\n      this.userDerivationKey = userDerivationKey;\n      return this;\n    }\n\n    public Builder withClock(final Clock clock) {\n      this.clock = requireNonNull(clock);\n      return this;\n    }\n\n    public Builder withDerivedUsernameTruncateLength(int truncateLength) {\n      Validate.inclusiveBetween(10, 32, truncateLength);\n      this.derivedUsernameTruncateLength = truncateLength;\n      return this;\n    }\n\n    public Builder prependUsername(final boolean prependUsername) {\n      this.prependUsername = prependUsername;\n      return this;\n    }\n\n    public Builder truncateSignature(final boolean truncateSignature) {\n      this.truncateSignature = truncateSignature;\n      return this;\n    }\n\n    public Builder withUsernameTimestampTruncatorAndPrefix(final Function<Instant, Instant> truncator, final String prefix) {\n      this.usernameTimestampTruncator = truncator;\n      this.usernameTimestampPrefix = prefix;\n      return this;\n    }\n\n    public ExternalServiceCredentialsGenerator build() {\n      return new ExternalServiceCredentialsGenerator(\n          key, userDerivationKey, prependUsername, truncateSignature, derivedUsernameTruncateLength, usernameTimestampPrefix, usernameTimestampTruncator, clock);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelector.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\n\npublic class ExternalServiceCredentialsSelector {\n\n  private ExternalServiceCredentialsSelector() {}\n\n  public enum CredentialStatus {\n    // The token passes verification, is not expired, and is the most recent token available\n    VALID,\n    // The token was malformed or the signature was invalid\n    FAILED_VERIFICATION,\n    // The credential was correctly signed, but exceeded the maximum credential age\n    EXPIRED,\n    // There was a more recent credential available with the same username\n    REPLACED\n  }\n\n  public record CredentialInfo(String token, ExternalServiceCredentials credentials, long timestamp, CredentialStatus status) {\n    /**\n     * @return a copy of this record that indicates it has been replaced with a more up-to-date token\n     */\n    private CredentialInfo replaced() {\n      return new CredentialInfo(token, credentials, timestamp, CredentialStatus.REPLACED);\n    }\n\n    public boolean valid() {\n      return status == CredentialStatus.VALID;\n    }\n  }\n\n  /**\n   * Validate a list of username:password credentials.\n   * A credential is valid if it passes validation by the provided credentialsGenerator AND it is the most recent\n   * credential in the provided list for a username.\n   *\n   * @param tokens A list of credentials, potentially with different usernames\n   * @param credentialsGenerator To validate these credentials\n   * @param maxAgeSeconds The maximum allowable age of the credential\n   * @return A {@link CredentialInfo} for each provided token\n   */\n  public static List<CredentialInfo> check(\n      final List<String> tokens,\n      final ExternalServiceCredentialsGenerator credentialsGenerator,\n      final long maxAgeSeconds) {\n\n    // the credential for the username with the latest timestamp (so far)\n    final Map<String, CredentialInfo> bestForUsername = new HashMap<>();\n    final List<CredentialInfo> results = new ArrayList<>();\n    for (String token : tokens) {\n      // each token is supposed to be in a \"${username}:${password}\" form,\n      // (note that password part may also contain ':' characters)\n      final String[] parts = token.split(\":\", 2);\n      if (parts.length != 2) {\n        results.add(new CredentialInfo(token,  null, 0L, CredentialStatus.FAILED_VERIFICATION));\n        continue;\n      }\n      final ExternalServiceCredentials credentials = new ExternalServiceCredentials(parts[0], parts[1]);\n      final Optional<Long> maybeTimestamp = credentialsGenerator.validateAndGetTimestamp(credentials);\n      if (maybeTimestamp.isEmpty()) {\n        results.add(new CredentialInfo(token, credentials, 0L, CredentialStatus.FAILED_VERIFICATION));\n        continue;\n      }\n      final long credentialTs = maybeTimestamp.get();\n      if (credentialsGenerator.isCredentialExpired(credentialTs, maxAgeSeconds)) {\n        results.add(new CredentialInfo(token, credentials, credentialTs, CredentialStatus.EXPIRED));\n        continue;\n      }\n\n      // now that we validated signature and token age, we will also find the latest of the tokens\n      // for each username\n      final long timestamp = maybeTimestamp.get();\n      final CredentialInfo best = bestForUsername.get(credentials.username());\n      if (best == null) {\n        bestForUsername.put(credentials.username(), new CredentialInfo(token, credentials, timestamp, CredentialStatus.VALID));\n        continue;\n      }\n      if (best.timestamp() < timestamp) {\n        // we found a better credential for the username\n        bestForUsername.put(credentials.username(), new CredentialInfo(token, credentials, timestamp, CredentialStatus.VALID));\n        // mark the previous best as an invalid credential, since we have a better credential now\n        results.add(best.replaced());\n      } else {\n        // the credential we already had was more recent, this one can be marked invalid\n        results.add(new CredentialInfo(token, null, 0L, CredentialStatus.REPLACED));\n      }\n    }\n\n    // all invalid tokens should be in results, just add the valid ones\n    results.addAll(bestForUsername.values());\n    return results;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/GroupSendTokenHeader.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.util.Base64;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;\n\npublic record GroupSendTokenHeader(GroupSendFullToken token) {\n\n  public static GroupSendTokenHeader valueOf(String header) {\n    try {\n      return new GroupSendTokenHeader(new GroupSendFullToken(Base64.getDecoder().decode(header)));\n    } catch (InvalidInputException | IllegalArgumentException e) {\n      // Base64 throws IllegalArgumentException; GroupSendFullToken ctor throws InvalidInputException\n      throw new WebApplicationException(e, Status.UNAUTHORIZED);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Optional;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;\n\npublic class IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter implements\n    AuthenticatedWebSocketUpgradeFilter<AuthenticatedDevice> {\n\n  private final Duration minIdleDuration;\n  private final Clock clock;\n\n  @VisibleForTesting\n  static final String ALERT_HEADER = \"X-Signal-Alert\";\n\n  @VisibleForTesting\n  static final String IDLE_PRIMARY_DEVICE_ALERT = \"idle-primary-device\";\n\n  private static final Counter IDLE_PRIMARY_WARNING_COUNTER = Metrics.counter(\n      MetricsUtil.name(IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.class, \"idlePrimaryDeviceWarning\"),\n      \"critical\", \"false\");\n\n  public IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(final Duration minIdleDuration, final Clock clock) {\n    this.minIdleDuration = minIdleDuration;\n    this.clock = clock;\n  }\n\n  @Override\n  public void handleAuthentication(final Optional<AuthenticatedDevice> authenticated,\n      final JettyServerUpgradeRequest request,\n      final JettyServerUpgradeResponse response) {\n\n    // No action needed if the connection is unauthenticated (in which case we don't know when we've last seen the\n    // primary device) or if the authenticated device IS the primary device\n    authenticated\n        .filter(authenticatedDevice -> authenticatedDevice.deviceId() != Device.PRIMARY_ID)\n        .ifPresent(authenticatedDevice -> {\n          if (authenticatedDevice.primaryDeviceLastSeen().isBefore(clock.instant().minus(minIdleDuration))) {\n            response.addHeader(ALERT_HEADER, IDLE_PRIMARY_DEVICE_ALERT);\n            IDLE_PRIMARY_WARNING_COUNTER.increment();\n          }\n        });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/InvalidAuthorizationHeaderException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth;\n\n\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Response.Status;\n\npublic class InvalidAuthorizationHeaderException extends WebApplicationException {\n  public InvalidAuthorizationHeaderException(String s) {\n    super(s, Status.UNAUTHORIZED);\n  }\n\n  public InvalidAuthorizationHeaderException(Exception e) {\n    super(e, Status.UNAUTHORIZED);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/OptionalAccess.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport jakarta.ws.rs.NotAuthorizedException;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Response;\nimport java.security.MessageDigest;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic class OptionalAccess {\n\n  public static String ALL_DEVICES_SELECTOR = \"*\";\n\n  public static void verify(Optional<Account> requestAccount,\n      Optional<Anonymous> accessKey,\n      Optional<Account> targetAccount,\n      ServiceIdentifier targetIdentifier,\n      String deviceSelector) {\n\n    try {\n      verify(requestAccount, accessKey, targetAccount, targetIdentifier);\n\n      if (!ALL_DEVICES_SELECTOR.equals(deviceSelector)) {\n        byte deviceId = Byte.parseByte(deviceSelector);\n\n        Optional<Device> targetDevice = targetAccount.get().getDevice(deviceId);\n\n        if (targetDevice.isPresent()) {\n          return;\n        }\n\n        if (requestAccount.isPresent()) {\n          throw new NotFoundException();\n        } else {\n          throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);\n        }\n      }\n    } catch (NumberFormatException e) {\n      throw new WebApplicationException(Response.status(422).build());\n    }\n  }\n\n  public static void verify(Optional<Account> requestAccount,\n      Optional<Anonymous> accessKey,\n      Optional<Account> targetAccount,\n      ServiceIdentifier targetIdentifier) {\n\n    if (requestAccount.isPresent()) {\n      // Authenticated requests are never unauthorized; if the target exists, return OK, otherwise throw not-found.\n      if (targetAccount.isPresent()) {\n        return;\n      } else {\n        throw new NotFoundException();\n      }\n    }\n\n    // Anything past this point can only be authenticated by an access key. Even when the target\n    // has unrestricted unidentified access, callers need to supply a fake access key. Likewise, if\n    // the target account does not exist, we *also* report unauthorized here (*not* not-found,\n    // since that would provide a free exists check).\n    if (accessKey.isEmpty() || targetAccount.isEmpty()) {\n      throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);\n    }\n\n    // Unidentified access is only for ACI identities\n    if (IdentityType.PNI.equals(targetIdentifier.identityType())) {\n      throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);\n    }\n\n    // Unrestricted unidentified access does what it says on the tin: we don't check if the key the\n    // caller provided is right or not.\n    if (targetAccount.get().isUnrestrictedUnidentifiedAccess()) {\n      return;\n    }\n\n    if (!targetAccount.get().isIdentifiedBy(targetIdentifier)) {\n      throw new IllegalArgumentException(\"Target account is not identified by the given identifier\");\n    }\n\n    // At this point, any successful authentication requires a real access key on the target account\n    if (targetAccount.get().getUnidentifiedAccessKey().isEmpty()) {\n      throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);\n    }\n\n    // Otherwise, access is gated by the caller having the unidentified-access key matching the target account.\n    if (MessageDigest.isEqual(accessKey.get().getAccessKey(), targetAccount.get().getUnidentifiedAccessKey().get())) {\n      return;\n    }\n\n    throw new NotAuthorizedException(Response.Status.UNAUTHORIZED);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/PhoneVerificationTokenManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.NotAuthorizedException;\nimport jakarta.ws.rs.ServerErrorException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Response;\nimport java.security.MessageDigest;\nimport java.time.Duration;\nimport java.util.concurrent.CancellationException;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\n\npublic class PhoneVerificationTokenManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(PhoneVerificationTokenManager.class);\n  private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);\n  private static final long VERIFICATION_TIMEOUT_SECONDS = REGISTRATION_RPC_TIMEOUT.plusSeconds(1).getSeconds();\n\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers;\n\n  private final RegistrationServiceClient registrationServiceClient;\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n  private final RegistrationRecoveryChecker registrationRecoveryChecker;\n\n  public PhoneVerificationTokenManager(final PhoneNumberIdentifiers phoneNumberIdentifiers,\n      final RegistrationServiceClient registrationServiceClient,\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n      final RegistrationRecoveryChecker registrationRecoveryChecker) {\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n    this.registrationServiceClient = registrationServiceClient;\n    this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;\n    this.registrationRecoveryChecker = registrationRecoveryChecker;\n  }\n\n  /**\n   * Checks if a {@link PhoneVerificationRequest} has a token that verifies the caller has confirmed access to the e164\n   * number\n   *\n   * @param requestContext the container request context\n   * @param number  the e164 presented for verification\n   * @param request the request with exactly one verification token (RegistrationService sessionId or registration\n   *                recovery password)\n   * @return if verification was successful, returns the verification type\n   * @throws BadRequestException    if the number does not match the sessionId’s number, or the remote service rejects\n   *                                the session ID as invalid\n   * @throws NotAuthorizedException if the session is not verified\n   * @throws ForbiddenException     if the recovery password is not valid\n   * @throws InterruptedException   if verification did not complete before a timeout\n   */\n  public PhoneVerificationRequest.VerificationType verify(final ContainerRequestContext requestContext, final String number, final PhoneVerificationRequest request)\n      throws InterruptedException {\n\n    final PhoneVerificationRequest.VerificationType verificationType = request.verificationType();\n    switch (verificationType) {\n      case SESSION -> verifyBySessionId(number, request.decodeSessionId());\n      case RECOVERY_PASSWORD -> verifyByRecoveryPassword(requestContext, number, request.recoveryPassword());\n    }\n\n    return verificationType;\n  }\n\n  private void verifyBySessionId(final String number, final byte[] sessionId) throws InterruptedException {\n    try {\n      final RegistrationServiceSession session = registrationServiceClient\n          .getSession(sessionId, REGISTRATION_RPC_TIMEOUT)\n          .get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)\n          .orElseThrow(() -> new NotAuthorizedException(\"session not verified\"));\n\n      if (!MessageDigest.isEqual(number.getBytes(), session.number().getBytes())) {\n        throw new BadRequestException(\"number does not match session\");\n      }\n      if (!session.verified()) {\n        throw new NotAuthorizedException(\"session not verified\");\n      }\n    } catch (final ExecutionException e) {\n\n      if (e.getCause() instanceof StatusRuntimeException grpcRuntimeException) {\n        if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {\n          throw new BadRequestException();\n        }\n      }\n\n      logger.error(\"Registration service failure\", e);\n      throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);\n\n    } catch (final CancellationException | TimeoutException e) {\n\n      logger.error(\"Registration service failure\", e);\n      throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);\n    }\n  }\n\n  private void verifyByRecoveryPassword(final ContainerRequestContext requestContext, final String number, final byte[] recoveryPassword)\n      throws InterruptedException {\n    if (!registrationRecoveryChecker.checkRegistrationRecoveryAttempt(requestContext, number)) {\n      throw new ForbiddenException(\"recoveryPassword couldn't be verified\");\n    }\n    try {\n      final boolean verified = registrationRecoveryPasswordsManager.verify(phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join(), recoveryPassword)\n          .get(VERIFICATION_TIMEOUT_SECONDS, TimeUnit.SECONDS);\n      if (!verified) {\n        throw new ForbiddenException(\"recoveryPassword couldn't be verified\");\n      }\n    } catch (final ExecutionException | TimeoutException e) {\n      throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/RedemptionRange.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.LocalDate;\nimport java.time.ZoneOffset;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Iterator;\nimport java.util.Objects;\nimport java.util.stream.Stream;\nimport org.jetbrains.annotations.NotNull;\n\n/// A validated range of days for which a credential may be issued.\npublic class RedemptionRange implements Iterable<Instant> {\n\n  public static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);\n\n  /// The first day for which a credential should be issued\n  private final LocalDate from;\n\n  /// The last day for which a credential should be issued\n  private final LocalDate end;\n\n  private RedemptionRange(final LocalDate from, final LocalDate end) {\n    this.from = from;\n    this.end = end;\n  }\n\n  ///  Construct a {@link RedemptionRange} if the provided day bounds are valid.\n  ///\n  /// The redemption bounds must satisfy:\n  ///   - `redemptionEnd` >= `redemptionStart`\n  ///   - `redemptionStart` and `redemptionEnd` are day-aligned\n  ///   - `redemptionStart` is yesterday or later\n  ///   - `redemptionEnd` is tomorrow + `MAX_REDEMPTION_DURATION` or earlier\n  ///   - The number of days requested is less than `MAX_REDEMPTION_DURATION`\n  ///\n  /// @param clock           Clock to use to get current day\n  /// @param redemptionStart The first day included in the range\n  /// @param redemptionEnd   The last day included in the range\n  /// @return A {@link RedemptionRange} that can be used to iterate each day between `redemptionStart` and\n  ///  `redemptionEnd`\n  /// @throws IllegalArgumentException if the redemption bounds were not valid\n  public static RedemptionRange inclusive(Clock clock, Instant redemptionStart, Instant redemptionEnd)\n      throws IllegalArgumentException {\n    final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);\n    final Instant yesterday = today.minus(Duration.ofDays(1));\n\n    if (redemptionStart.isAfter(redemptionEnd)) {\n      throw new IllegalArgumentException(\"end of range must be after start of range\");\n    }\n\n    if (!redemptionStart.truncatedTo(ChronoUnit.DAYS).equals(redemptionStart)\n        || !redemptionEnd.truncatedTo(ChronoUnit.DAYS).equals(redemptionEnd)) {\n      throw new IllegalArgumentException(\"timestamps must be day aligned\");\n    }\n\n    if (redemptionStart.isBefore(yesterday)) {\n      throw new IllegalArgumentException(\"start of range too far in the past\");\n    }\n\n    if (redemptionEnd.isAfter(today.plus(MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1)))) {\n      throw new IllegalArgumentException(\"end of range too far in the future\");\n    }\n\n    if (redemptionEnd.isAfter(redemptionStart.plus(MAX_REDEMPTION_DURATION))) {\n      throw new IllegalArgumentException(\"redemption window too large\");\n    }\n\n    return new RedemptionRange(\n        LocalDate.ofInstant(redemptionStart, ZoneOffset.UTC),\n        LocalDate.ofInstant(redemptionEnd, ZoneOffset.UTC));\n  }\n\n  @Override\n  public @NotNull Iterator<Instant> iterator() {\n    final Instant fromInstant = from.atStartOfDay(ZoneOffset.UTC).toInstant();\n    final Instant endInstant = end.atStartOfDay(ZoneOffset.UTC).toInstant();\n    return Stream\n        .iterate(fromInstant, redemptionTime -> redemptionTime.plus(Duration.ofDays(1)))\n        .takeWhile(redemptionTime -> !redemptionTime.isAfter(endInstant))\n        .iterator();\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n    RedemptionRange that = (RedemptionRange) o;\n    return Objects.equals(from, that.from) && Objects.equals(end, that.end);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(from, end);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.NotPushRegisteredException;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\n\npublic class RegistrationLockVerificationManager {\n  public enum Flow {\n    REGISTRATION,\n    CHANGE_NUMBER\n  }\n\n  @VisibleForTesting\n  public static final int FAILURE_HTTP_STATUS = 423;\n\n  private static final String EXPIRED_REGISTRATION_LOCK_COUNTER_NAME =\n      name(RegistrationLockVerificationManager.class, \"expiredRegistrationLock\");\n  private static final String REQUIRED_REGISTRATION_LOCK_COUNTER_NAME =\n      name(RegistrationLockVerificationManager.class, \"requiredRegistrationLock\");\n  private static final String CHALLENGED_DEVICE_NOT_PUSH_REGISTERED_COUNTER_NAME =\n      name(RegistrationLockVerificationManager.class, \"challengedDeviceNotPushRegistered\");\n  private static final String ALREADY_LOCKED_TAG_NAME = \"alreadyLocked\";\n  private static final String REGISTRATION_LOCK_VERIFICATION_FLOW_TAG_NAME = \"flow\";\n  private static final String REGISTRATION_LOCK_MATCHES_TAG_NAME = \"registrationLockMatches\";\n  private static final String PHONE_VERIFICATION_TYPE_TAG_NAME = \"phoneVerificationType\";\n\n  private final AccountsManager accounts;\n  private final DisconnectionRequestManager disconnectionRequestManager;\n  private final ExternalServiceCredentialsGenerator svr2CredentialGenerator;\n  private final RateLimiters rateLimiters;\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n  private final PushNotificationManager pushNotificationManager;\n\n  public RegistrationLockVerificationManager(\n      final AccountsManager accounts,\n      final DisconnectionRequestManager disconnectionRequestManager,\n      final ExternalServiceCredentialsGenerator svr2CredentialGenerator,\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n      final PushNotificationManager pushNotificationManager,\n      final RateLimiters rateLimiters) {\n    this.accounts = accounts;\n    this.disconnectionRequestManager = disconnectionRequestManager;\n    this.svr2CredentialGenerator = svr2CredentialGenerator;\n    this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;\n    this.pushNotificationManager = pushNotificationManager;\n    this.rateLimiters = rateLimiters;\n  }\n\n  /**\n   * Verifies the given registration lock credentials against the account’s current registration lock, if any\n   *\n   * @param account\n   * @param clientRegistrationLock\n   * @throws RateLimitExceededException\n   * @throws WebApplicationException\n   */\n  public void verifyRegistrationLock(final Account account, @Nullable final String clientRegistrationLock,\n      final String userAgent,\n      final Flow flow,\n      final PhoneVerificationRequest.VerificationType phoneVerificationType\n  ) throws RateLimitExceededException, WebApplicationException {\n\n    final Tags expiredTags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),\n        Tag.of(REGISTRATION_LOCK_VERIFICATION_FLOW_TAG_NAME, flow.name()),\n        Tag.of(PHONE_VERIFICATION_TYPE_TAG_NAME, phoneVerificationType.name())\n    );\n\n    final StoredRegistrationLock existingRegistrationLock = account.getRegistrationLock();\n\n    switch (existingRegistrationLock.getStatus()) {\n      case EXPIRED:\n        Metrics.counter(EXPIRED_REGISTRATION_LOCK_COUNTER_NAME, expiredTags).increment();\n        return;\n      case ABSENT:\n        return;\n      case REQUIRED:\n        break;\n      default:\n        throw new RuntimeException(\"Unexpected status: \" + existingRegistrationLock.getStatus());\n    }\n\n    if (StringUtils.isNotEmpty(clientRegistrationLock)) {\n      rateLimiters.getPinLimiter().validate(account.getNumber());\n    }\n\n    final String phoneNumber = account.getNumber();\n    final boolean registrationLockMatches = existingRegistrationLock.verify(clientRegistrationLock);\n    final boolean alreadyLocked = account.hasLockedCredentials();\n\n    final Tags additionalTags = expiredTags.and(\n        REGISTRATION_LOCK_MATCHES_TAG_NAME, Boolean.toString(registrationLockMatches),\n        ALREADY_LOCKED_TAG_NAME, Boolean.toString(alreadyLocked)\n    );\n\n    Metrics.counter(REQUIRED_REGISTRATION_LOCK_COUNTER_NAME, additionalTags).increment();\n\n    final DistributionSummary registrationLockIdleDays = DistributionSummary\n        .builder(name(RegistrationLockVerificationManager.class, \"registrationLockIdleDays\"))\n        .tags(additionalTags)\n        .distributionStatisticExpiry(Duration.ofHours(2))\n        .register(Metrics.globalRegistry);\n\n    final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());\n    final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());\n\n    registrationLockIdleDays.record(timeSinceLastSeen.toDays());\n\n    if (!registrationLockMatches) {\n      // At this point, the client verified ownership of the phone number but doesn’t have the reglock PIN.\n      // Freezing the existing account credentials will definitively start the reglock timeout.\n      // Until the timeout, the current reglock can still be supplied,\n      // along with phone number verification, to restore access.\n      final Account updatedAccount;\n      if (!alreadyLocked) {\n        updatedAccount = accounts.update(account, Account::lockAuthTokenHash);\n      } else {\n        updatedAccount = account;\n      }\n\n      // The client often sends an empty registration lock token on the first request\n      // and sends an actual token if the server returns a 423 indicating that one is required.\n      // This logic accounts for that behavior by not deleting the registration recovery password\n      // if the user verified correctly via registration recovery password and sent an empty token.\n      // This allows users to re-register via registration recovery password\n      // instead of always being forced to fall back to SMS verification.\n      if (!phoneVerificationType.equals(PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD) || clientRegistrationLock != null) {\n        registrationRecoveryPasswordsManager.remove(updatedAccount.getIdentifier(IdentityType.PNI)).join();\n      }\n\n      final List<Byte> deviceIds = updatedAccount.getDevices().stream().map(Device::getId).toList();\n      disconnectionRequestManager.requestDisconnection(updatedAccount.getUuid(), deviceIds);\n\n      try {\n        // Send a push notification that prompts the client to attempt login and fail due to locked credentials\n        pushNotificationManager.sendAttemptLoginNotification(updatedAccount, \"failedRegistrationLock\");\n      } catch (final NotPushRegisteredException e) {\n        Metrics.counter(CHALLENGED_DEVICE_NOT_PUSH_REGISTERED_COUNTER_NAME).increment();\n      }\n\n      throw new WebApplicationException(Response.status(FAILURE_HTTP_STATUS)\n          .entity(new RegistrationLockFailure(\n              existingRegistrationLock.getTimeRemaining().toMillis(),\n              svr2FailureCredentials(existingRegistrationLock, updatedAccount)))\n          .build());\n    }\n\n    rateLimiters.getPinLimiter().clear(phoneNumber);\n  }\n\n  private @Nullable ExternalServiceCredentials svr2FailureCredentials(final StoredRegistrationLock existingRegistrationLock, final Account account) {\n    if (!existingRegistrationLock.needsFailureCredentials()) {\n      return null;\n    }\n    return svr2CredentialGenerator.generateForUuid(account.getUuid());\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/SaltedTokenHash.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth;\n\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SecureRandom;\nimport java.util.HexFormat;\nimport org.signal.libsignal.protocol.kdf.HKDF;\n\npublic record SaltedTokenHash(String hash, String salt) {\n\n  public enum Version {\n    V1,\n    V2,\n  }\n\n  public static final Version CURRENT_VERSION = Version.V2;\n\n  private static final String V2_PREFIX = \"2.\";\n\n  private static final byte[] AUTH_TOKEN_HKDF_INFO = \"authtoken\".getBytes(StandardCharsets.UTF_8);\n\n  private static final int SALT_SIZE = 16;\n\n  private static final SecureRandom SECURE_RANDOM = new SecureRandom();\n\n\n  public static SaltedTokenHash generateFor(final String token) {\n    final String salt = generateSalt();\n    final String hash = calculateV2Hash(salt, token);\n    return new SaltedTokenHash(hash, salt);\n  }\n\n  public Version getVersion() {\n    return hash.startsWith(V2_PREFIX) ? Version.V2 : Version.V1;\n  }\n\n  public boolean verify(final String token) {\n    final String theirValue = switch (getVersion()) {\n      case V1 -> calculateV1Hash(salt, token);\n      case V2 -> calculateV2Hash(salt, token);\n    };\n    return MessageDigest.isEqual(\n        theirValue.getBytes(StandardCharsets.UTF_8),\n        hash.getBytes(StandardCharsets.UTF_8));\n  }\n\n  private static String generateSalt() {\n    final byte[] salt = new byte[SALT_SIZE];\n    SECURE_RANDOM.nextBytes(salt);\n    return HexFormat.of().formatHex(salt);\n  }\n\n  private static String calculateV1Hash(final String salt, final String token) {\n    try {\n      return HexFormat.of()\n          .formatHex(MessageDigest.getInstance(\"SHA1\").digest((salt + token).getBytes(StandardCharsets.UTF_8)));\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  private static String calculateV2Hash(final String salt, final String token) {\n    final byte[] secret = HKDF.deriveSecrets(\n        token.getBytes(StandardCharsets.UTF_8),  // key\n        salt.getBytes(StandardCharsets.UTF_8),  // salt\n        AUTH_TOKEN_HKDF_INFO,\n        32);\n    return V2_PREFIX + HexFormat.of().formatHex(secret);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/StoredRegistrationLock.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic class StoredRegistrationLock {\n  public enum Status {\n    REQUIRED,\n    EXPIRED,\n    ABSENT\n  }\n\n  @VisibleForTesting\n  static final Duration REGISTRATION_LOCK_EXPIRATION_DAYS = Duration.ofDays(7);\n\n  private final Optional<String> registrationLock;\n\n  private final Optional<String> registrationLockSalt;\n\n  private final Instant lastSeen;\n\n  /**\n   * @return milliseconds since the last time the account was seen.\n   */\n  private long timeSinceLastSeen() {\n    return System.currentTimeMillis() - lastSeen.toEpochMilli();\n  }\n\n  /**\n   * @return true if the registration lock and salt are both set.\n   */\n  private boolean hasLockAndSalt() {\n    return registrationLock.isPresent() && registrationLockSalt.isPresent();\n  }\n\n  public boolean isPresent() {\n    return hasLockAndSalt();\n  }\n\n  public StoredRegistrationLock(Optional<String> registrationLock, Optional<String> registrationLockSalt, Instant lastSeen) {\n    this.registrationLock     = registrationLock;\n    this.registrationLockSalt = registrationLockSalt;\n    this.lastSeen             = lastSeen;\n  }\n\n  public Status getStatus() {\n    if (!isPresent()) {\n      return Status.ABSENT;\n    }\n    if (getTimeRemaining().toMillis() > 0) {\n      return Status.REQUIRED;\n    }\n    return Status.EXPIRED;\n  }\n\n  public boolean needsFailureCredentials() {\n    return hasLockAndSalt();\n  }\n\n  public Duration getTimeRemaining() {\n    return REGISTRATION_LOCK_EXPIRATION_DAYS.minus(timeSinceLastSeen(), ChronoUnit.MILLIS);\n  }\n\n  public boolean verify(@Nullable String clientRegistrationLock) {\n    if (hasLockAndSalt() && StringUtils.isNotEmpty(clientRegistrationLock)) {\n      SaltedTokenHash credentials = new SaltedTokenHash(registrationLock.get(), registrationLockSalt.get());\n      return credentials.verify(clientRegistrationLock);\n    } else {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/TurnToken.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport java.util.List;\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\n\npublic record TurnToken(\n    String username,\n    String password,\n    @JsonProperty(\"ttl\") long ttlSeconds,\n    @Nonnull List<String> urls,\n    @Nonnull List<String> urlsWithIps,\n    @Nullable String hostname) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksum.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\n\npublic class UnidentifiedAccessChecksum {\n\n  public static byte[] generateFor(byte[] unidentifiedAccessKey) {\n    try {\n      if (unidentifiedAccessKey.length != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {\n        throw new IllegalArgumentException(\"Invalid UAK length: \" + unidentifiedAccessKey.length);\n      }\n\n      Mac mac = Mac.getInstance(\"HmacSHA256\");\n      mac.init(new SecretKeySpec(unidentifiedAccessKey, \"HmacSHA256\"));\n\n      return mac.doFinal(new byte[32]);\n    } catch (NoSuchAlgorithmException | InvalidKeyException e) {\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport java.security.MessageDigest;\nimport java.util.Collection;\nimport java.util.function.Predicate;\nimport java.util.stream.IntStream;\n\npublic class UnidentifiedAccessUtil {\n\n  public static final int UNIDENTIFIED_ACCESS_KEY_LENGTH = 16;\n\n  private UnidentifiedAccessUtil() {\n  }\n\n  /**\n   * Checks whether an action (e.g. sending a message or retrieving pre-keys) may be taken on the target account by an\n   * actor presenting the given unidentified access key.\n   *\n   * @param targetAccount the account on which an actor wishes to take an action\n   * @param unidentifiedAccessKey the unidentified access key presented by the actor\n   *\n   * @return {@code true} if an actor presenting the given unidentified access key has permission to take an action on\n   * the target account or {@code false} otherwise\n   */\n  public static boolean checkUnidentifiedAccess(final Account targetAccount, final byte[] unidentifiedAccessKey) {\n    return targetAccount.isUnrestrictedUnidentifiedAccess()\n        || targetAccount.getUnidentifiedAccessKey()\n        .map(targetUnidentifiedAccessKey -> MessageDigest.isEqual(targetUnidentifiedAccessKey, unidentifiedAccessKey))\n        .orElse(false);\n  }\n\n  /**\n   * Checks whether an action (e.g. sending a message or retrieving pre-keys) may be taken on the collection of target\n   * accounts by an actor presenting the given combined unidentified access key.\n   *\n   * @param targetAccounts the accounts on which an actor wishes to take an action\n   * @param combinedUnidentifiedAccessKey the unidentified access key presented by the actor\n   *\n   * @return {@code true} if an actor presenting the given unidentified access key has permission to take an action on\n   * the target accounts or {@code false} otherwise\n   */\n  public static boolean checkUnidentifiedAccess(final Collection<Account> targetAccounts, final byte[] combinedUnidentifiedAccessKey) {\n    return MessageDigest.isEqual(getCombinedUnidentifiedAccessKey(targetAccounts), combinedUnidentifiedAccessKey);\n  }\n\n  /**\n   * Calculates a combined unidentified access key for the given collection of accounts.\n   *\n   * @param accounts the accounts from which to derive a combined unidentified access key\n   * @return a combined unidentified access key\n   *\n   * @throws IllegalArgumentException if one or more of the given accounts had an unidentified access key with an\n   * unexpected length\n   */\n  public static byte[] getCombinedUnidentifiedAccessKey(final Collection<Account> accounts) {\n    return accounts.stream()\n        .filter(Predicate.not(Account::isUnrestrictedUnidentifiedAccess))\n        .map(account ->\n            account.getUnidentifiedAccessKey()\n                .filter(b -> b.length == UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH)\n                .orElseThrow(IllegalArgumentException::new))\n        .reduce(new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH],\n            (a, b) -> {\n              final byte[] xor = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH];\n              IntStream.range(0, UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH).forEach(i -> xor[i] = (byte) (a[i] ^ b[i]));\n              return xor;\n            });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/AuthenticatedDevice.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth.grpc;\n\nimport java.util.UUID;\n\npublic record AuthenticatedDevice(UUID accountIdentifier, byte deviceId) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/AuthenticationUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth.grpc;\n\nimport io.grpc.Context;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.grpc.GrpcExceptions;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\n/**\n * Provides utility methods for working with authentication in the context of gRPC calls.\n */\npublic class AuthenticationUtil {\n\n  static final Context.Key<AuthenticatedDevice> CONTEXT_AUTHENTICATED_DEVICE = Context.key(\"authenticated-device\");\n\n  /**\n   * Returns the account/device authenticated in the current gRPC context. Should only be called from a service run with\n   * the {@link RequireAuthenticationInterceptor}.\n   *\n   * @return the account/device identifier authenticated in the current gRPC context\n   * @throws IllegalStateException if no authenticated account/device could be retrieved from the current gRPC context\n   */\n  public static AuthenticatedDevice requireAuthenticatedDevice() {\n    @Nullable final AuthenticatedDevice authenticatedDevice = CONTEXT_AUTHENTICATED_DEVICE.get();\n\n    if (authenticatedDevice != null) {\n      return authenticatedDevice;\n    }\n\n    throw new IllegalStateException(\n        \"Configuration issue: service expects an authenticated device, but none was found. Request should have failed from an interceptor\");\n  }\n\n  /**\n   * Returns the account/device authenticated in the current gRPC context or \"invalid argument\" if the authenticated\n   * device is not the primary device for the account.\n   *\n   * @return the account/device identifier authenticated in the current gRPC context\n   * @throws io.grpc.StatusRuntimeException with a status of {@code INVALID_ARGUMENT} if the authenticated device is not\n   *                                        the primary device for the authenticated account\n   * @throws IllegalStateException          if no authenticated account/device could be retrieved from the current gRPC\n   *                                        context\n   */\n  public static AuthenticatedDevice requireAuthenticatedPrimaryDevice() {\n    final AuthenticatedDevice authenticatedDevice = requireAuthenticatedDevice();\n    if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {\n      throw GrpcExceptions.badAuthentication(\"RPC requires a primary device\");\n    }\n    return authenticatedDevice;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/ProhibitAuthenticationInterceptor.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth.grpc;\n\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport org.whispersystems.textsecuregcm.grpc.GrpcExceptions;\nimport org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;\n\n/**\n * A \"prohibit authentication\" interceptor ensures that requests to endpoints that should be invoked anonymously do not\n * contain an authorization header in the request metdata. Calls with an associated authenticated device are closed with\n * an {@code UNAUTHENTICATED} status.\n */\npublic class ProhibitAuthenticationInterceptor implements ServerInterceptor {\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,\n      final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {\n    final String authHeaderString = headers.get(Metadata.Key.of(RequireAuthenticationInterceptor.AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));\n    if (authHeaderString != null) {\n      return ServerInterceptorUtil.closeWithStatusException(call,\n          GrpcExceptions.badAuthentication(\"The service forbids requests with an authentication header\"));\n    }\n    return next.startCall(call, headers);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/auth/grpc/RequireAuthenticationInterceptor.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth.grpc;\n\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport io.grpc.Context;\nimport io.grpc.Contexts;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.auth.AccountAuthenticator;\nimport org.whispersystems.textsecuregcm.grpc.GrpcExceptions;\nimport org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\n\n/**\n * A \"require authentication\" interceptor authenticates requests and attaches the {@link AuthenticatedDevice} to the\n * current gRPC context. Calls without authentication or with invalid credentials are closed with an\n * {@code UNAUTHENTICATED} status. If a call's authentication status cannot be determined (i.e. because the accounts\n * database is unavailable), the interceptor will reject the call with a status of {@code UNAVAILABLE}.\n */\npublic class RequireAuthenticationInterceptor implements ServerInterceptor {\n\n  static final String AUTHORIZATION_HEADER = \"authorization\";\n\n  private final AccountAuthenticator authenticator;\n\n  public RequireAuthenticationInterceptor(final AccountAuthenticator authenticator) {\n    this.authenticator = authenticator;\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,\n      final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {\n    final String authHeaderString = headers.get(\n        Metadata.Key.of(AUTHORIZATION_HEADER, Metadata.ASCII_STRING_MARSHALLER));\n\n    if (authHeaderString == null) {\n      return ServerInterceptorUtil.closeWithStatusException(call,\n          GrpcExceptions.invalidCredentials(\"missing authorization header\"));\n    }\n\n    final Optional<BasicCredentials> basicCredentials = HeaderUtils.basicCredentialsFromAuthHeader(authHeaderString);\n    if (basicCredentials.isEmpty()) {\n      return ServerInterceptorUtil.closeWithStatusException(call,\n          GrpcExceptions.invalidCredentials(\"malformed authorization header\"));\n    }\n\n    final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> authenticated =\n        authenticator.authenticate(basicCredentials.get());\n    if (authenticated.isEmpty()) {\n      return ServerInterceptorUtil.closeWithStatusException(call,\n          GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n    }\n\n    final AuthenticatedDevice authenticatedDevice = new AuthenticatedDevice(\n        authenticated.get().accountIdentifier(),\n        authenticated.get().deviceId());\n\n    return Contexts.interceptCall(Context.current()\n            .withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE, authenticatedDevice),\n        call, headers, next);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupAuthManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.security.MessageDigest;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.stream.StreamSupport;\nimport javax.annotation.Nullable;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialResponse;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;\n\n/**\n * Issues ZK backup auth credentials for authenticated accounts\n * <p>\n * Authenticated callers can create ZK credentials that contain a blinded backup-id, so that they can later use that\n * backup id without the verifier learning that the id is associated with this account.\n * <p>\n * First use {@link #commitBackupId} to provide a blinded backup-id. This is stored in durable storage. Then the caller\n * can use {@link #getBackupAuthCredentials} to retrieve credentials that can subsequently be used to make anonymously\n * authenticated requests against their backup-id.\n */\npublic class BackupAuthManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(BackupAuthManager.class);\n\n\n  final static String BACKUP_MEDIA_EXPERIMENT_NAME = \"backupMedia\";\n\n  private final ExperimentEnrollmentManager experimentEnrollmentManager;\n  private final GenericServerSecretParams serverSecretParams;\n  private final ServerZkReceiptOperations serverZkReceiptOperations;\n  private final RedeemedReceiptsManager redeemedReceiptsManager;\n  private final Clock clock;\n  private final RateLimiters rateLimiters;\n  private final AccountsManager accountsManager;\n\n  public BackupAuthManager(\n      final ExperimentEnrollmentManager experimentEnrollmentManager,\n      final RateLimiters rateLimiters,\n      final AccountsManager accountsManager,\n      final ServerZkReceiptOperations serverZkReceiptOperations,\n      final RedeemedReceiptsManager redeemedReceiptsManager,\n      final GenericServerSecretParams serverSecretParams,\n      final Clock clock) {\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n    this.rateLimiters = rateLimiters;\n    this.accountsManager = accountsManager;\n    this.serverZkReceiptOperations = serverZkReceiptOperations;\n    this.redeemedReceiptsManager = redeemedReceiptsManager;\n    this.serverSecretParams = serverSecretParams;\n    this.clock = clock;\n  }\n\n  /**\n   * Store credential requests containing blinded backup-ids for future use.\n   *\n   * @param account                         The account using the backup-id\n   * @param device                          The device setting the account backup-id\n   * @param messagesBackupCredentialRequest A request containing the blinded backup-id the client will use to upload\n   *                                        message backups\n   * @param mediaBackupCredentialRequest    A request containing the blinded backup-id the client will use to upload\n   *                                        media backups\n   * @throws RateLimitExceededException If too many backup-ids have been committed\n   */\n  public void commitBackupId(\n      final Account account,\n      final Device device,\n      final Optional<BackupAuthCredentialRequest> messagesBackupCredentialRequest,\n      final Optional<BackupAuthCredentialRequest> mediaBackupCredentialRequest)\n      throws RateLimitExceededException, BackupPermissionException, BackupInvalidArgumentException {\n    if (!device.isPrimary()) {\n      throw new BackupPermissionException(\"Only primary device can set backup-id\");\n    }\n\n    if (messagesBackupCredentialRequest.isEmpty() && mediaBackupCredentialRequest.isEmpty()) {\n      throw new BackupInvalidArgumentException(\"Must set at least one of message/media credential requests\");\n    }\n\n    final byte[] storedMessageCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MESSAGES)\n        .orElse(null);\n    final byte[] storedMediaCredentialRequest = account.getBackupCredentialRequest(BackupCredentialType.MEDIA)\n        .orElse(null);\n\n    // If the provided credential request is null, we want to set to the existing request\n    final byte[] targetMessageCredentialRequest = messagesBackupCredentialRequest\n        .map(BackupAuthCredentialRequest::serialize)\n        .orElse(storedMessageCredentialRequest);\n    final byte[] targetMediaCredentialRequest = mediaBackupCredentialRequest\n        .map(BackupAuthCredentialRequest::serialize)\n        .orElse(storedMediaCredentialRequest);\n\n    final boolean requiresMessageRotation =\n        !MessageDigest.isEqual(targetMessageCredentialRequest, storedMessageCredentialRequest);\n    final boolean requiresMediaRotation =\n        !MessageDigest.isEqual(targetMediaCredentialRequest, storedMediaCredentialRequest);\n\n    if (!requiresMessageRotation && !requiresMediaRotation) {\n      // No need to update or enforce rate limits, this is the credential that the user has already\n      // committed to.\n      return;\n    }\n\n    if (requiresMessageRotation) {\n      rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID).validate(account.getUuid());\n    }\n\n    if (requiresMediaRotation && hasActiveVoucher(account)) {\n      rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID).validate(account.getUuid());\n    }\n\n    this.accountsManager.update(account, a ->\n        a.setBackupCredentialRequests(targetMessageCredentialRequest, targetMediaCredentialRequest));\n  }\n\n  public record BackupIdRotationLimit(boolean hasPermitsRemaining, Duration nextPermitAvailable) {}\n\n  public BackupIdRotationLimit checkBackupIdRotationLimit(final Account account) {\n    final RateLimiter messagesLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID);\n    final RateLimiter mediaLimiter = rateLimiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID);\n\n    final boolean isPaid = hasActiveVoucher(account);\n\n    final CompletionStage<Boolean> hasSetMessagesPermits =\n        messagesLimiter.hasAvailablePermitsAsync(account.getUuid(), 1);\n    final CompletionStage<Boolean> hasSetMediaPermits = isPaid\n        ? mediaLimiter.hasAvailablePermitsAsync(account.getUuid(), 1)\n        : CompletableFuture.completedFuture(true);\n\n    return hasSetMessagesPermits.thenCombine(hasSetMediaPermits, (hasMessage, hasMedia) -> {\n      if (hasMedia && hasMessage) {\n        return new BackupIdRotationLimit(true, Duration.ZERO);\n      } else {\n        final Duration timeToNextPermit = Collections.max(Arrays.asList(\n            messagesLimiter.config().permitRegenerationDuration(),\n            isPaid ? mediaLimiter.config().permitRegenerationDuration() : Duration.ZERO));\n        return new BackupIdRotationLimit(false, timeToNextPermit);\n      }\n    }).toCompletableFuture().join();\n  }\n\n  public record Credential(BackupAuthCredentialResponse credential, Instant redemptionTime) {}\n\n  /**\n   * Create a credential for every day between redemptionStart and redemptionEnd\n   * <p>\n   * This uses a {@link BackupAuthCredentialRequest} previous stored via {@link this#commitBackupId} to generate the\n   * credentials.\n   * <p>\n   * If the account has a BackupVoucher allowing access to paid backups, credentials with a redemptionTime before the\n   * voucher's expiration will include paid backup access. If the BackupVoucher exists but is already expired, this\n   * method will also remove the expired voucher from the account.\n   *\n   * @param originalAccount The account to create the credentials for\n   * @param redemptionRange The time range to return credentials for\n   * @return Credentials and the day on which they may be redeemed\n   */\n  public Map<BackupCredentialType, List<Credential>> getBackupAuthCredentials(\n      final Account originalAccount,\n      final RedemptionRange redemptionRange) throws BackupNotFoundException {\n    // If the account has an expired payment, clear it before continuing\n    final Account account;\n    if (hasExpiredVoucher(originalAccount)) {\n      account = accountsManager.update(originalAccount, a -> {\n        // Re-check in case we raced with an update\n        if (hasExpiredVoucher(a)) {\n          a.setBackupVoucher(null);\n        }\n      });\n    } else {\n      account = originalAccount;\n    }\n\n    final Map<BackupCredentialType, List<Credential>> credentials = new HashMap<>();\n    for (BackupCredentialType credentialType : BackupCredentialType.values()) {\n      // fetch the blinded backup-id the account should have previously committed to\n      final byte[] committedBytes = account.getBackupCredentialRequest(credentialType)\n          .orElseThrow(() -> new BackupNotFoundException(\"No blinded backup-id has been added to the account\"));\n\n      try {\n        final BackupLevel defaultBackupLevel = configuredBackupLevel(account);\n\n        // create a credential for every day in the requested period\n        final BackupAuthCredentialRequest credentialReq = new BackupAuthCredentialRequest(committedBytes);\n        final List<Credential> credentialList = StreamSupport.stream(redemptionRange.spliterator(), false)\n            .map(redemptionTime -> {\n              // Check if the account has a voucher that's good for a certain receiptLevel at redemption time, otherwise\n              // use the default receipt level\n              final BackupLevel backupLevel = storedBackupLevel(account, redemptionTime).orElse(defaultBackupLevel);\n              return new Credential(\n                  credentialReq.issueCredential(redemptionTime, backupLevel, credentialType, serverSecretParams),\n                  redemptionTime);\n            })\n            .toList();\n        credentials.put(credentialType, credentialList);\n      } catch (InvalidInputException e) {\n        throw new UncheckedIOException(new IOException(\"Could not deserialize stored request credential\", e));\n      }\n    }\n    return credentials;\n  }\n\n  /**\n   * Redeem a receipt to enable paid backups on the account.\n   *\n   * @param account                       The account to enable backups on\n   * @param receiptCredentialPresentation A ZK receipt presentation proving payment\n   */\n  public void redeemReceipt(\n      final Account account,\n      final ReceiptCredentialPresentation receiptCredentialPresentation)\n      throws BackupBadReceiptException, BackupInvalidArgumentException, BackupMissingIdCommitmentException {\n    try {\n      serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);\n    } catch (VerificationFailedException e) {\n      throw new BackupBadReceiptException(\"receipt credential presentation verification failed\");\n    }\n    final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();\n    final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());\n    if (clock.instant().isAfter(receiptExpiration)) {\n      throw new BackupBadReceiptException(\"receipt is already expired\");\n    }\n\n    final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();\n\n    if (BackupLevelUtil.fromReceiptLevel(receiptLevel) != BackupLevel.PAID) {\n      throw new BackupInvalidArgumentException(\"server does not recognize the requested receipt level\");\n    }\n\n    if (account.getBackupCredentialRequest(BackupCredentialType.MEDIA).isEmpty()) {\n      throw new BackupMissingIdCommitmentException();\n    }\n\n    boolean receiptAllowed = redeemedReceiptsManager\n        .put(receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, account.getUuid())\n        .join();\n    if (!receiptAllowed) {\n      throw new BackupBadReceiptException(\"receipt serial is already redeemed\");\n    }\n    extendBackupVoucher(account, new Account.BackupVoucher(receiptLevel, receiptExpiration));\n  }\n\n  /**\n   * Extend the duration of the backup voucher on an account.\n   *\n   * @param account The account to update\n   * @param backupVoucher The backup voucher to apply to this account\n   */\n  public void extendBackupVoucher(final Account account, final Account.BackupVoucher backupVoucher) {\n    accountsManager.update(account, a -> {\n      // Receipt credential expirations must be day aligned. Make sure any manually set backupVoucher is also day\n      // aligned\n      final Account.BackupVoucher newPayment =  new Account.BackupVoucher(\n          backupVoucher.receiptLevel(),\n          backupVoucher.expiration().truncatedTo(ChronoUnit.DAYS));\n      final Account.BackupVoucher existingPayment = a.getBackupVoucher();\n      a.setBackupVoucher(merge(existingPayment, newPayment));\n    });\n  }\n\n  private static Account.BackupVoucher merge(@Nullable final Account.BackupVoucher prev,\n      final Account.BackupVoucher next) {\n    if (prev == null) {\n      return next;\n    }\n\n    if (next.receiptLevel() != prev.receiptLevel()) {\n      return next;\n    }\n\n    // If the new payment has the same receipt level as the old, select the further out of the two expiration times\n    if (prev.expiration().isAfter(next.expiration())) {\n      // This should be fairly rare, either a client reused an old receipt or we reduced the validity period\n      logger.warn(\n          \"Redeemed receipt with an expiration at {} when we've previously had a redemption with a later expiration {}\",\n          next.expiration(), prev.expiration());\n      return prev;\n    }\n    return next;\n  }\n\n  private boolean hasActiveVoucher(final Account account) {\n    return account.getBackupVoucher() != null && clock.instant().isBefore(account.getBackupVoucher().expiration());\n  }\n\n  private boolean hasExpiredVoucher(final Account account) {\n    return account.getBackupVoucher() != null && !hasActiveVoucher(account);\n  }\n\n  /**\n   * Get the receipt level stored in the {@link Account.BackupVoucher} on the account if it's present and not expired.\n   *\n   * @param account        The account to check\n   * @param redemptionTime The time to check against the expiration time\n   * @return The receipt level on the backup voucher, or empty if the account does not have one or it is expired\n   */\n  private Optional<BackupLevel> storedBackupLevel(final Account account, final Instant redemptionTime) {\n    return Optional.ofNullable(account.getBackupVoucher())\n        .filter(backupVoucher -> !redemptionTime.isAfter(backupVoucher.expiration()))\n        .map(Account.BackupVoucher::receiptLevel)\n        .map(BackupLevelUtil::fromReceiptLevel);\n  }\n\n  /**\n   * Get the backup receipt level that should be used by default for this account determined via configuration.\n   *\n   * @param account the account to check\n   * @return The default receipt level that should be used for the account if the account does not have a\n   * BackupVoucher.\n   */\n  private BackupLevel configuredBackupLevel(final Account account) {\n    return this.experimentEnrollmentManager.isEnrolled(account.getUuid(), BACKUP_MEDIA_EXPERIMENT_NAME)\n        ? BackupLevel.PAID\n        : BackupLevel.FREE;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupBadReceiptException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupBadReceiptException extends BackupException {\n\n  public BackupBadReceiptException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupException extends Exception {\n\n  public BackupException() {\n    super();\n  }\n\n  public BackupException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupFailedZkAuthenticationException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupFailedZkAuthenticationException extends BackupException {\n\n  public BackupFailedZkAuthenticationException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupInvalidArgumentException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupInvalidArgumentException extends BackupException {\n  public BackupInvalidArgumentException(final String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupLevelUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\n\npublic class BackupLevelUtil {\n  public static BackupLevel fromReceiptLevel(long receiptLevel) {\n    try {\n      return BackupLevel.fromValue(Math.toIntExact(receiptLevel));\n    } catch (ArithmeticException e) {\n      throw new IllegalArgumentException(\"Invalid receipt level: \" + receiptLevel);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.util.DataSize;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.security.SecureRandom;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.function.Function;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicBackupConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentUtil;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\nimport javax.annotation.Nullable;\n\npublic class BackupManager {\n\n  static final String MESSAGE_BACKUP_NAME = \"messageBackup\";\n  public static final long MAX_MESSAGE_BACKUP_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();\n  public static final long MAX_MEDIA_OBJECT_SIZE = DataSize.mebibytes(101).toBytes();\n\n  private static final String ZK_AUTHN_COUNTER_NAME = MetricsUtil.name(BackupManager.class, \"authentication\");\n  private static final String ZK_AUTHZ_FAILURE_COUNTER_NAME = MetricsUtil.name(BackupManager.class,\n      \"authorizationFailure\");\n  private static final String USAGE_RECALCULATION_COUNTER_NAME = MetricsUtil.name(BackupManager.class,\n      \"usageRecalculation\");\n  private static final String DELETE_COUNT_DISTRIBUTION_NAME = MetricsUtil.name(BackupManager.class,\n      \"deleteCount\");\n  private static final Timer SYNCHRONOUS_DELETE_TIMER =\n      Metrics.timer(MetricsUtil.name(BackupManager.class, \"synchronousDelete\"));\n\n  private static final String NUM_OBJECTS_SUMMARY_NAME = MetricsUtil.name(BackupManager.class, \"numObjects\");\n  private static final String BYTES_USED_SUMMARY_NAME = MetricsUtil.name(BackupManager.class, \"bytesUsed\");\n\n  private static final String SUCCESS_TAG_NAME = \"success\";\n  private static final String FAILURE_REASON_TAG_NAME = \"reason\";\n\n  private static final Logger log = LoggerFactory.getLogger(BackupManager.class);\n\n  private final BackupsDb backupsDb;\n  private final GenericServerSecretParams serverSecretParams;\n  private final RateLimiters rateLimiters;\n  private final TusAttachmentGenerator tusAttachmentGenerator;\n  private final Cdn3BackupCredentialGenerator cdn3BackupCredentialGenerator;\n  private final RemoteStorageManager remoteStorageManager;\n  private final SecureRandom secureRandom = new SecureRandom();\n  private final ExternalServiceCredentialsGenerator secureValueRecoveryBCredentialsGenerator;\n  private final SecureValueRecoveryClient secureValueRecoveryBClient;\n  private final Clock clock;\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n\n  public BackupManager(\n      final BackupsDb backupsDb,\n      final GenericServerSecretParams serverSecretParams,\n      final RateLimiters rateLimiters,\n      final TusAttachmentGenerator tusAttachmentGenerator,\n      final Cdn3BackupCredentialGenerator cdn3BackupCredentialGenerator,\n      final RemoteStorageManager remoteStorageManager,\n      final ExternalServiceCredentialsGenerator secureValueRecoveryBCredentialsGenerator,\n      final SecureValueRecoveryClient secureValueRecoveryBClient,\n      final Clock clock,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n    this.backupsDb = backupsDb;\n    this.serverSecretParams = serverSecretParams;\n    this.rateLimiters = rateLimiters;\n    this.tusAttachmentGenerator = tusAttachmentGenerator;\n    this.cdn3BackupCredentialGenerator = cdn3BackupCredentialGenerator;\n    this.remoteStorageManager = remoteStorageManager;\n    this.secureValueRecoveryBClient = secureValueRecoveryBClient;\n    this.clock = clock;\n    this.secureValueRecoveryBCredentialsGenerator = secureValueRecoveryBCredentialsGenerator;\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n  }\n\n\n  /**\n   * Set the public key for the backup-id.\n   * <p>\n   * Once set, calls {@link BackupManager#authenticateBackupUser} can succeed if the presentation is signed with the\n   * private key corresponding to this public key.\n   *\n   * @param presentation a ZK credential presentation that encodes the backupId\n   * @param signature    the signature of the presentation\n   * @param publicKey    the public key of a key-pair that the presentation must be signed with\n   * @throws BackupFailedZkAuthenticationException If the provided presentation or signature were invalid\n   */\n  public void setPublicKey(\n      final BackupAuthCredentialPresentation presentation,\n      final byte[] signature,\n      final ECPublicKey publicKey) throws BackupFailedZkAuthenticationException {\n\n    // Note: this is a special case where we can't validate the presentation signature against the stored public key\n    // because we are currently setting it. We check against the provided public key, but we must also verify that\n    // there isn't an existing, different stored public key for the backup-id (verified with a condition expression)\n    final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =\n        verifyPresentation(presentation).verifySignature(signature, publicKey);\n\n    ExceptionUtils.unwrapSupply(\n        BackupPublicKeyConflictException.class,\n        () -> backupsDb.setPublicKey(presentation.getBackupId(), credentialTypeAndBackupLevel.second(), publicKey).join(),\n        _ -> {\n          Metrics.counter(ZK_AUTHN_COUNTER_NAME,\n                  SUCCESS_TAG_NAME, String.valueOf(false),\n                  FAILURE_REASON_TAG_NAME, \"public_key_conflict\")\n              .increment();\n          return new BackupFailedZkAuthenticationException(\"The provided public key did not match the stored public key\");\n        });\n  }\n\n  /**\n   * Create a form that may be used to upload a backup file for the backupId encoded in the presentation.\n   * <p>\n   * If successful, this also updates the TTL of the backup.\n   *\n   * @param backupUser an already ZK authenticated backup user\n   * @return the upload form\n   * @throws BackupPermissionException if the credential does not have the correct level\n   * @throws BackupWrongCredentialTypeException if the credential does not have the messages type\n   */\n  public BackupUploadDescriptor createMessageBackupUploadDescriptor(\n      final AuthenticatedBackupUser backupUser) throws BackupPermissionException, BackupWrongCredentialTypeException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);\n\n    // this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp\n    backupsDb.addMessageBackup(backupUser).join();\n    return cdn3BackupCredentialGenerator.generateUpload(cdnMessageBackupName(backupUser));\n  }\n\n  public BackupUploadDescriptor createTemporaryAttachmentUploadDescriptor(final AuthenticatedBackupUser backupUser)\n      throws RateLimitExceededException, BackupPermissionException, BackupWrongCredentialTypeException {\n    checkBackupLevel(backupUser, BackupLevel.PAID);\n    checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);\n\n    rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT).validate(rateLimitKey(backupUser));\n    final String attachmentKey = AttachmentUtil.generateAttachmentKey(secureRandom);\n    final AttachmentGenerator.Descriptor descriptor = tusAttachmentGenerator.generateAttachment(attachmentKey);\n    return new BackupUploadDescriptor(3, attachmentKey, descriptor.headers(), descriptor.signedUploadLocation());\n  }\n\n  /**\n   * Update the last update timestamps for the backupId in the presentation\n   *\n   * @param backupUser an already ZK authenticated backup user\n   * @throws BackupPermissionException if the credential does not have the correct level\n   */\n  public void ttlRefresh(final AuthenticatedBackupUser backupUser) throws BackupPermissionException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    // update message backup TTL\n    final StoredBackupAttributes storedBackupAttributes = backupsDb.ttlRefresh(backupUser).join();\n    if (backupUser.credentialType() == BackupCredentialType.MEDIA) {\n      final long maxTotalMediaSize =\n          dynamicConfigurationManager.getConfiguration().getBackupConfiguration().maxTotalMediaSize();\n\n      // Report that the backup is out of quota if it cannot store a max size media object\n      final boolean quotaExhausted = storedBackupAttributes.bytesUsed() >=\n          (maxTotalMediaSize - BackupManager.MAX_MEDIA_OBJECT_SIZE);\n\n      final Tags tags = Tags.of(\n          UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),\n          Tag.of(\"type\", backupUser.credentialType().name()),\n          Tag.of(\"tier\", backupUser.backupLevel().name()),\n          Tag.of(\"quotaExhausted\", String.valueOf(quotaExhausted)));\n\n      DistributionSummary.builder(NUM_OBJECTS_SUMMARY_NAME)\n          .tags(tags)\n          .register(Metrics.globalRegistry)\n          .record(storedBackupAttributes.numObjects());\n      DistributionSummary.builder(BYTES_USED_SUMMARY_NAME)\n          .tags(tags)\n          .register(Metrics.globalRegistry)\n          .record(storedBackupAttributes.bytesUsed());\n    }\n  }\n\n  public record BackupInfo(int cdn, String backupSubdir, String mediaSubdir, String messageBackupKey,\n                           Optional<Long> mediaUsedSpace) {}\n\n  /**\n   * Retrieve information about the existing backup\n   *\n   * @param backupUser an already ZK authenticated backup user\n   * @return Information about the existing backup\n   * @throws BackupPermissionException if the credential does not have the correct level\n   * @throws BackupFailedZkAuthenticationException if the provided backupuser does not exist\n   */\n  public BackupInfo backupInfo(final AuthenticatedBackupUser backupUser) throws BackupPermissionException, BackupFailedZkAuthenticationException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    final BackupsDb.BackupDescription backupDescription = ExceptionUtils.unwrapSupply(\n        BackupFailedZkAuthenticationException.class,\n        () -> backupsDb.describeBackup(backupUser).join());\n    return new BackupInfo(\n        backupDescription.cdn(),\n        backupUser.backupDir(),\n        backupUser.mediaDir(),\n        MESSAGE_BACKUP_NAME,\n        backupDescription.mediaUsedSpace());\n  }\n\n  /**\n   * Copy an encrypted object to the backup cdn, adding a layer of encryption\n   * <p>\n   * Implementation notes: <p> This method guarantees that any object that gets successfully copied to the backup cdn\n   * will also be deducted from the user's quota. </p>\n   * <p>\n   * However, the converse isn't true. It's possible we may charge the user for media they failed to copy. As a result,\n   * the quota may be over reported. It should be recalculated before taking quota enforcement actions.\n   *\n   * @return A Flux that emits the locations of the double-encrypted objects on the backup cdn, or includes an error\n   * detailing why the object could not be copied.\n   */\n  public Flux<CopyResult> copyToBackup(final CopyQuota copyQuota) {\n    final AuthenticatedBackupUser backupUser = copyQuota.backupUser();\n    final DynamicBackupConfiguration backupConfiguration =\n        dynamicConfigurationManager.getConfiguration().getBackupConfiguration();\n\n    return Flux.concat(\n\n        // Perform copies for requests that fit in our quota, first updating the usage. If the copy fails, our\n        // estimated quota usage may not be exact since we update usage first. We make a best-effort attempt\n        // to undo the usage update if we know that the copied failed for sure.\n        Flux.fromIterable(copyQuota.requestsToCopy())\n\n            // Update the usage in reasonable chunk sizes to bound how out of sync our claimed and actual usage gets\n            .buffer(backupConfiguration.usageCheckpointCount())\n            .concatMap(copyParameters -> {\n              final long quotaToConsume = copyParameters.stream()\n                  .mapToLong(CopyParameters::destinationObjectSize)\n                  .sum();\n              return Mono\n                  .fromFuture(backupsDb.trackMedia(backupUser, copyParameters.size(), quotaToConsume))\n                  .thenMany(Flux.fromIterable(copyParameters));\n            })\n\n            // Actually perform the copies now that we've updated the quota\n            .flatMapSequential(copyParams -> copyToBackup(backupUser, copyParams)\n                    .flatMap(copyResult -> switch (copyResult.outcome()) {\n                      case SUCCESS -> Mono.just(copyResult);\n                      case SOURCE_WRONG_LENGTH, SOURCE_NOT_FOUND, OUT_OF_QUOTA -> Mono\n                          .fromFuture(this.backupsDb.trackMedia(backupUser, -1, -copyParams.destinationObjectSize()))\n                          .thenReturn(copyResult);\n                    }),\n                backupConfiguration.copyConcurrency(), 1),\n\n        // There wasn't enough quota remaining to perform these copies\n        Flux.fromIterable(copyQuota.requestsToReject())\n            .map(arg -> new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, arg.destinationMediaId(), null)));\n  }\n\n  private Mono<CopyResult> copyToBackup(final AuthenticatedBackupUser backupUser, final CopyParameters copyParameters) {\n    return Mono.fromCompletionStage(() -> remoteStorageManager.copy(\n            copyParameters.sourceCdn(), copyParameters.sourceKey(), copyParameters.sourceLength(),\n            copyParameters.encryptionParameters(),\n            cdnMediaPath(backupUser, copyParameters.destinationMediaId())))\n\n        // Successfully copied!\n        .thenReturn(new CopyResult(\n            CopyResult.Outcome.SUCCESS, copyParameters.destinationMediaId(), remoteStorageManager.cdnNumber()))\n\n        // Otherwise, squash per-item copy errors that don't fail the entire operation\n        .onErrorResume(\n            // If the error maps to an explicit result type\n            throwable ->\n                CopyResult.fromCopyError(throwable, copyParameters.destinationMediaId()).isPresent(),\n            // return that result type instead of propagating the error\n            throwable ->\n                Mono.just(CopyResult.fromCopyError(throwable, copyParameters.destinationMediaId()).orElseThrow()));\n  }\n\n  public record CopyQuota(AuthenticatedBackupUser backupUser, List<CopyParameters> requestsToCopy, List<CopyParameters> requestsToReject) {\n    private static CopyQuota create(AuthenticatedBackupUser backupUser, final List<CopyParameters> toCopy, long remainingQuota) {\n      // Figure out how many of the requested objects fit in the remaining quota\n      final int index = indexWhereTotalExceeds(toCopy, CopyParameters::destinationObjectSize, remainingQuota);\n      return new CopyQuota(backupUser, toCopy.subList(0, index), toCopy.subList(index, toCopy.size()));\n    }\n  }\n\n  /**\n   * Determine which copy requests can be performed with the user's remaining quota. This does not update the quota.\n   *\n   * @param toCopy     The proposed copy requests\n   * @return QuotaResult indicating which requests fit into the remaining quota and which requests should be\n   * rejected with {@link CopyResult.Outcome#OUT_OF_QUOTA}\n   * @throws BackupInvalidArgumentException if toCopy contains an invalid copy request\n   * @throws BackupPermissionException if the credential does not have the correct level\n   * @throws BackupWrongCredentialTypeException if the credential does not have the media type\n   */\n  public CopyQuota getCopyQuota(\n      final AuthenticatedBackupUser backupUser,\n      final List<CopyParameters> toCopy)\n      throws BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {\n    checkBackupLevel(backupUser, BackupLevel.PAID);\n    checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);\n\n    for (CopyParameters copyParameters : toCopy) {\n      if (copyParameters.sourceLength() > MAX_MEDIA_OBJECT_SIZE || copyParameters.sourceLength() < 0) {\n        throw new BackupInvalidArgumentException(\"Invalid sourceObject size\");\n      }\n    }\n    final long totalBytesAdded = toCopy.stream().mapToLong(CopyParameters::destinationObjectSize).sum();\n\n    final DynamicBackupConfiguration backupConfiguration =\n        dynamicConfigurationManager.getConfiguration().getBackupConfiguration();\n    final Duration maxQuotaStaleness = backupConfiguration.maxQuotaStaleness();\n    final long maxTotalMediaSize = backupConfiguration.maxTotalMediaSize();\n\n    final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();\n    long estimatedRemainingQuota = maxTotalMediaSize - info.usageInfo().bytesUsed();\n    final boolean canStore = estimatedRemainingQuota >= totalBytesAdded;\n    if (canStore || info.lastRecalculationTime().isAfter(clock.instant().minus(maxQuotaStaleness))) {\n      return CopyQuota.create(backupUser, toCopy, estimatedRemainingQuota);\n    }\n\n    // The user is out of quota, and we have not recently recalculated the user's usage. Double check by doing a\n    // hard recalculation before actually forbidding the user from storing additional media.\n    boolean usageChanged = false;\n    try {\n      final UsageInfo usage =\n          this.remoteStorageManager.calculateBytesUsed(cdnMediaDirectory(backupUser)).toCompletableFuture().join();\n      backupsDb.setMediaUsage(backupUser, usage).join();\n      usageChanged = !usage.equals(info.usageInfo());\n\n      final long remainingQuota = maxTotalMediaSize - usage.bytesUsed();\n      return CopyQuota.create(backupUser, toCopy, remainingQuota);\n    } finally {\n      Metrics.counter(USAGE_RECALCULATION_COUNTER_NAME, Tags.of(\n              UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),\n              Tag.of(\"usageChanged\", String.valueOf(usageChanged))))\n          .increment();\n    }\n  }\n\n  public record RecalculationResult(UsageInfo oldUsage, UsageInfo newUsage) {}\n  public CompletionStage<Optional<RecalculationResult>> recalculateQuota(final StoredBackupAttributes storedBackupAttributes) {\n    if (StringUtils.isBlank(storedBackupAttributes.backupDir()) || StringUtils.isBlank(storedBackupAttributes.mediaDir())) {\n      return CompletableFuture.completedFuture(Optional.empty());\n    }\n    final String cdnPath = cdnMediaDirectory(storedBackupAttributes.backupDir(), storedBackupAttributes.mediaDir());\n    return this.remoteStorageManager.calculateBytesUsed(cdnPath).thenCompose(usage ->\n      backupsDb.setMediaUsage(storedBackupAttributes, usage).thenApply(ignored ->\n          Optional.of(new RecalculationResult(\n              new UsageInfo(storedBackupAttributes.bytesUsed(), storedBackupAttributes.numObjects()),\n              usage))));\n  }\n\n  /**\n   * @return the largest index i such that sum(ts[0],...ts[i - 1]) <= max\n   */\n  private static <T> int indexWhereTotalExceeds(List<T> ts, Function<T, Long> valueFunction, long max) {\n    long sum = 0;\n    for (int index = 0; index < ts.size(); index++) {\n      sum += valueFunction.apply(ts.get(index));\n      if (sum > max) {\n        return index;\n      }\n    }\n    return ts.size();\n  }\n\n\n  public record StorageDescriptor(int cdn, byte[] key) {}\n\n  public record StorageDescriptorWithLength(int cdn, byte[] key, long length) {}\n\n  /**\n   * Generate credentials that can be used to read from the backup CDN\n   *\n   * @param backupUser an already ZK authenticated backup user\n   * @param cdnNumber  the cdn number to get backup credentials for\n   * @return A map of headers to include with CDN requests\n   * @throws BackupPermissionException if the credential does not have the correct level\n   * @throws BackupInvalidArgumentException if the provided cdnNumber is invalid\n   */\n  public Map<String, String> generateReadAuth(final AuthenticatedBackupUser backupUser, final int cdnNumber)\n      throws BackupInvalidArgumentException, BackupPermissionException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    if (cdnNumber != 3) {\n      throw new BackupInvalidArgumentException(\"unknown cdn\");\n    }\n    return cdn3BackupCredentialGenerator.readHeaders(backupUser.backupDir());\n  }\n\n  /**\n   * Generate credentials that can be used with SVRB\n   *\n   * @param backupUser an already ZK authenticated backup user\n   * @return the credential that may be used with SVRB\n   * @throws BackupPermissionException if the credential does not have the correct level\n   * @throws BackupWrongCredentialTypeException if the credential does not have the messages type\n   */\n  public ExternalServiceCredentials generateSvrbAuth(final AuthenticatedBackupUser backupUser)\n      throws BackupPermissionException, BackupWrongCredentialTypeException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    // Clients may only use SVRB with their messages backup-id\n    checkBackupCredentialType(backupUser, BackupCredentialType.MESSAGES);\n    return secureValueRecoveryBCredentialsGenerator.generateFor(svrbIdentifier(backupUser));\n  }\n\n  private static String svrbIdentifier(final AuthenticatedBackupUser backupUser) {\n    return svrbIdentifier(BackupsDb.hashedBackupId(backupUser.backupId()));\n  }\n\n  private static String svrbIdentifier(final byte[] hashedBackupId) {\n    return HexFormat.of().formatHex(hashedBackupId);\n  }\n\n  /**\n   * List of media stored for a particular backup id\n   *\n   * @param media  A page of media entries\n   * @param cursor If set, can be passed back to a subsequent list request to resume listing from the previous point\n   */\n  public record ListMediaResult(List<StorageDescriptorWithLength> media, Optional<String> cursor) {}\n\n  /**\n   * List the media stored by the backupUser\n   *\n   * @param backupUser An already ZK authenticated backup user\n   * @param cursor     A cursor returned by a previous call that can be used to resume listing\n   * @param limit      The maximum number of list results to return\n   * @return A {@link ListMediaResult}\n   * @throws BackupPermissionException if the credential does not have the correct level\n   */\n  public ListMediaResult list(\n      final AuthenticatedBackupUser backupUser,\n      final Optional<String> cursor,\n      final int limit) throws BackupPermissionException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    final RemoteStorageManager.ListResult result =\n        remoteStorageManager.list(cdnMediaDirectory(backupUser), cursor, limit).toCompletableFuture().join();\n    return new ListMediaResult(result\n        .objects()\n        .stream()\n        .map(entry -> new StorageDescriptorWithLength(\n            remoteStorageManager.cdnNumber(),\n            decodeMediaIdFromCdn(entry.key()),\n            entry.length()\n        ))\n        .toList(),\n        result.cursor());\n  }\n\n  public void deleteEntireBackup(final AuthenticatedBackupUser backupUser) throws BackupPermissionException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n\n    final int deletionConcurrency =\n        dynamicConfigurationManager.getConfiguration().getBackupConfiguration().deletionConcurrency();\n\n    // Clients only include SVRB data with their messages backup-id\n    if (backupUser.credentialType() == BackupCredentialType.MESSAGES) {\n      secureValueRecoveryBClient.removeData(svrbIdentifier(backupUser)).join();\n    }\n    try {\n      // Try to swap out the backupDir for the user\n      backupsDb.scheduleBackupDeletion(backupUser).join();\n    } catch (Exception e) {\n      final Throwable unwrapped = ExceptionUtils.unwrap(e);\n      if (unwrapped instanceof BackupsDb.PendingDeletionException) {\n        // If there was already a pending swap, try to delete the cdn objects directly\n        SYNCHRONOUS_DELETE_TIMER.record(() -> deletePrefix(backupUser.backupDir(), deletionConcurrency).join());\n      } else {\n        throw e;\n      }\n    }\n  }\n\n\n  public Flux<StorageDescriptor> deleteMedia(final AuthenticatedBackupUser backupUser,\n      final List<StorageDescriptor> storageDescriptors)\n      throws BackupPermissionException, BackupWrongCredentialTypeException {\n    checkBackupLevel(backupUser, BackupLevel.FREE);\n    checkBackupCredentialType(backupUser, BackupCredentialType.MEDIA);\n\n    // Check for a cdn we don't know how to process\n    if (storageDescriptors.stream().anyMatch(sd -> sd.cdn() != remoteStorageManager.cdnNumber())) {\n      return Flux.error(new BackupInvalidArgumentException(\"unsupported media cdn provided\"));\n    }\n    final DynamicBackupConfiguration backupConfiguration =\n        dynamicConfigurationManager.getConfiguration().getBackupConfiguration();\n\n    return Flux.usingWhen(\n\n        // Gather usage updates into the UsageBatcher so we don't have to update our backup record on every delete\n        Mono.just(new UsageBatcher(backupConfiguration.usageCheckpointCount())),\n\n        // Deletes the objects, returning their former location. Tracks bytes removed so the quota can be updated on\n        // completion\n        batcher -> Flux.fromIterable(storageDescriptors)\n\n            // Delete the objects, allowing DELETION_CONCURRENCY operations out at a time\n            .flatMapSequential(\n                sd -> Mono.fromCompletionStage(remoteStorageManager.delete(cdnMediaPath(backupUser, sd.key()))),\n                backupConfiguration.deletionConcurrency())\n            .zipWithIterable(storageDescriptors)\n\n            // Track how much the remote storage manager indicated was deleted as part of the operation\n            .concatMap(deletedBytesAndStorageDescriptor -> {\n              final long deletedBytes = deletedBytesAndStorageDescriptor.getT1();\n              final StorageDescriptor sd = deletedBytesAndStorageDescriptor.getT2();\n\n              // If it has been a while, perform a checkpoint to make sure our usage doesn't drift too much\n              if (batcher.update(-deletedBytes)) {\n                final UsageBatcher.UsageUpdate usageUpdate = batcher.getAndReset();\n                return Mono\n                    .fromFuture(backupsDb.trackMedia(backupUser, usageUpdate.countDelta, usageUpdate.bytesDelta))\n                    .doOnError(throwable ->\n                        log.warn(\"Failed to update delta {} after successful delete operation\", usageUpdate, throwable))\n                    .thenReturn(sd);\n              } else {\n                return Mono.just(sd);\n              }\n            }),\n\n        // On cleanup, update the quota using whatever remaining updates were accumulated in the batcher\n        batcher -> {\n          final UsageBatcher.UsageUpdate update = batcher.getAndReset();\n          return Mono\n              .fromFuture(backupsDb.trackMedia(backupUser, update.countDelta, update.bytesDelta))\n              .doOnError(throwable ->\n                  log.warn(\"Failed to update delta {} after successful delete operation\", update, throwable));\n        });\n  }\n\n  /**\n   * Track pending media usage updates. Not thread safe!\n   */\n  private static class UsageBatcher {\n\n    private final int usageCheckpointCount;\n    private long runningCountDelta = 0;\n    private long runningBytesDelta = 0;\n\n    UsageBatcher(int usageCheckpointCount) {\n      this.usageCheckpointCount = usageCheckpointCount;\n    }\n\n    record UsageUpdate(long countDelta, long bytesDelta) {}\n\n    /**\n     * Stage a usage update. Returns true when it is time to make a checkpoint\n     *\n     * @param bytesDelta The amount of bytes that should be tracked as used (or if negative, freed). If the delta is\n     *                   non-zero, the count will also be updated.\n     * @return true if we should persist the usage\n     */\n    boolean update(long bytesDelta) {\n      this.runningCountDelta += Long.signum(bytesDelta);\n      this.runningBytesDelta += bytesDelta;\n      return Math.abs(runningCountDelta) >= usageCheckpointCount;\n    }\n\n    /**\n     * Get the current usage delta, and set the delta to 0\n     * @return A {@link UsageUpdate} to apply\n     */\n    UsageUpdate getAndReset() {\n      final UsageUpdate update = new UsageUpdate(runningCountDelta, runningBytesDelta);\n      runningCountDelta = 0;\n      runningBytesDelta = 0;\n      return update;\n    }\n  }\n\n  private static final ECPublicKey INVALID_PUBLIC_KEY = ECKeyPair.generate().getPublicKey();\n\n  /**\n   * Authenticate the ZK anonymous backup credential's presentation\n   * <p>\n   * This validates:\n   * <li> The presentation was for a credential issued by the server </li>\n   * <li> The credential is in its redemption window </li>\n   * <li> The backup-id matches a previously committed blinded backup-id and server issued receipt level </li>\n   * <li> The signature of the credential matches an existing publicKey associated with this backup-id </li>\n   *\n   * @param presentation A {@link BackupAuthCredentialPresentation}\n   * @param signature    An XEd25519 signature of the presentation bytes\n   * @return On authentication success, the authenticated backup-id and backup-tier encoded in the presentation\n   * @throws BackupFailedZkAuthenticationException If the provided presentation or signature were invalid\n   */\n  public AuthenticatedBackupUser authenticateBackupUser(\n      final BackupAuthCredentialPresentation presentation,\n      final byte[] signature,\n      final String userAgentString) throws BackupFailedZkAuthenticationException {\n\n    final PresentationSignatureVerifier signatureVerifier = verifyPresentation(presentation);\n\n    final Optional<BackupsDb.AuthenticationData> optionalAuthenticationData =\n        backupsDb.retrieveAuthenticationData(presentation.getBackupId()).join();\n    final UserAgent userAgent = parseUserAgent(userAgentString);\n    final BackupsDb.AuthenticationData authenticationData = optionalAuthenticationData\n        .orElseGet(() -> {\n          Metrics.counter(ZK_AUTHN_COUNTER_NAME, Tags.of(\n                  Tag.of(SUCCESS_TAG_NAME, String.valueOf(false)),\n                  Tag.of(FAILURE_REASON_TAG_NAME, \"missing_public_key\"),\n                  UserAgentTagUtil.getPlatformTag(userAgent)))\n              .increment();\n          // There was no stored public key, use a bunk public key so that validation will fail\n          return new BackupsDb.AuthenticationData(INVALID_PUBLIC_KEY, null, null);\n        });\n\n    final Pair<BackupCredentialType, BackupLevel> credentialTypeAndBackupLevel =\n        signatureVerifier.verifySignature(signature, authenticationData.publicKey());\n\n    Metrics.counter(ZK_AUTHN_COUNTER_NAME, SUCCESS_TAG_NAME, String.valueOf(true)).increment();\n    return new AuthenticatedBackupUser(\n        presentation.getBackupId(),\n        credentialTypeAndBackupLevel.first(),\n        credentialTypeAndBackupLevel.second(),\n        authenticationData.backupDir(),\n        authenticationData.mediaDir(),\n        userAgent);\n  }\n\n  /**\n   * List all backups stored in the backups table\n   *\n   * @param segments  Number of segments to read in parallel from the underlying backup database\n   * @return Flux of {@link StoredBackupAttributes} for each backup record in the backups table\n   */\n  public Flux<StoredBackupAttributes> listBackupAttributes(final int segments) {\n    return this.backupsDb.listBackupAttributes(segments);\n  }\n\n  /**\n   * List all backups whose media or messages refresh timestamp are older than the provided purgeTime\n   *\n   * @param segments  Number of segments to read in parallel from the underlying backup database\n   * @param scheduler Scheduler for running downstream operations\n   * @param purgeTime If a backup's last message refresh time is strictly before purgeTime, it will be marked as\n   *                  requiring full deletion. If only the last refresh time is strictly before purgeTime, it will be\n   *                  marked as requiring message deletion. Otherwise, it will not be included in the results.\n   * @return Flux of backups that require some deletion action\n   */\n  public Flux<ExpiredBackup> getExpiredBackups(final int segments, final Scheduler scheduler, final Instant purgeTime) {\n    return this.backupsDb.getExpiredBackups(segments, scheduler, purgeTime);\n  }\n\n  /**\n   * Delete some or all of the objects associated with the backup, and update the backup database.\n   *\n   * @param expiredBackup The backup to expire. If the {@link ExpiredBackup} is a media expiration, only the media\n   *                      objects will be deleted, otherwise all backup objects will be deleted\n   * @return A stage that completes when the deletion operation is finished\n   */\n  public CompletableFuture<Void> expireBackup(final ExpiredBackup expiredBackup) {\n    // Clients only include SVRB data with their messages backup-id\n    final CompletableFuture<Void> svrbRemoval = switch(expiredBackup.expirationType()) {\n      case ALL -> secureValueRecoveryBClient.removeData(svrbIdentifier(expiredBackup.hashedBackupId()));\n      case MEDIA, GARBAGE_COLLECTION ->  CompletableFuture.completedFuture(null);\n    };\n    return svrbRemoval.thenCompose(_ -> backupsDb.startExpiration(expiredBackup)\n        // the deletion operation is effectively single threaded -- it's expected that the caller can increase\n        // concurrency by deleting more backups at once, rather than increasing concurrency deleting an individual\n        // backup\n        .thenCompose(ignored -> deletePrefix(expiredBackup.prefixToDelete(), 1))\n        .thenCompose(ignored -> backupsDb.finishExpiration(expiredBackup)));\n  }\n\n  /**\n   * List and delete all files associated with a prefix\n   *\n   * @param prefixToDelete The prefix to expire.\n   */\n  private CompletableFuture<Void> deletePrefix(final String prefixToDelete, int concurrentDeletes) {\n    if (prefixToDelete.length() != BackupsDb.BACKUP_DIRECTORY_PATH_LENGTH\n        && prefixToDelete.length() != BackupsDb.MEDIA_DIRECTORY_PATH_LENGTH) {\n      throw new IllegalArgumentException(\"Unexpected prefix deletion for \" + prefixToDelete);\n    }\n    final String prefix = prefixToDelete + \"/\";\n    return Mono\n        .fromCompletionStage(this.remoteStorageManager.list(prefix, Optional.empty(), 1000))\n        .expand(listResult -> {\n          if (listResult.cursor().isEmpty()) {\n            return Mono.empty();\n          }\n          return Mono.fromCompletionStage(() -> this.remoteStorageManager.list(prefix, listResult.cursor(), 1000));\n        })\n        .flatMap(listResult -> Flux.fromIterable(listResult.objects()))\n        .flatMap(\n            result -> Mono.fromCompletionStage(() -> remoteStorageManager.delete(prefix + result.key())),\n            concurrentDeletes)\n        .count()\n        .doOnSuccess(itemsRemoved -> DistributionSummary.builder(DELETE_COUNT_DISTRIBUTION_NAME)\n            .register(Metrics.globalRegistry)\n            .record(itemsRemoved))\n        .then()\n        .toFuture();\n  }\n\n  interface PresentationSignatureVerifier {\n\n    Pair<BackupCredentialType, BackupLevel> verifySignature(byte[] signature, ECPublicKey publicKey) throws BackupFailedZkAuthenticationException;\n  }\n\n  /**\n   * Verify the presentation was issued by us, which should be done before checking the stored public key\n   *\n   * @param presentation A ZK credential presentation that encodes the backupId and the receipt level of the requester\n   * @return A function that can be used to verify a signature provided with the presentation\n   */\n  private PresentationSignatureVerifier verifyPresentation(final BackupAuthCredentialPresentation presentation)\n      throws BackupFailedZkAuthenticationException {\n    try {\n      presentation.verify(clock.instant(), serverSecretParams);\n    } catch (VerificationFailedException e) {\n      Metrics.counter(ZK_AUTHN_COUNTER_NAME,\n              SUCCESS_TAG_NAME, String.valueOf(false),\n              FAILURE_REASON_TAG_NAME, \"presentation_verification\")\n          .increment();\n      throw new BackupFailedZkAuthenticationException(\"backup auth credential presentation verification failed\");\n    }\n    return (signature, publicKey) -> {\n      if (!publicKey.verifySignature(presentation.serialize(), signature)) {\n        Metrics.counter(ZK_AUTHN_COUNTER_NAME,\n                SUCCESS_TAG_NAME, String.valueOf(false),\n                FAILURE_REASON_TAG_NAME, \"signature_validation\")\n            .increment();\n        throw new BackupFailedZkAuthenticationException(\"backup auth credential presentation signature verification failed\");\n      }\n      return new Pair<>(presentation.getType(), presentation.getBackupLevel());\n    };\n  }\n\n  /**\n   * Check that the authenticated backup user is authorized to use the provided backupLevel\n   *\n   * @param backupUser  The backup user to check\n   * @param backupLevel The authorization level to verify the backupUser has access to\n   * @throws BackupPermissionException if the backupUser is not authorized to access {@code backupLevel}\n   */\n  @VisibleForTesting\n  static void checkBackupLevel(final AuthenticatedBackupUser backupUser, final BackupLevel backupLevel)\n      throws BackupPermissionException {\n    if (backupUser.backupLevel().compareTo(backupLevel) < 0) {\n      Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME, Tags.of(\n              UserAgentTagUtil.getPlatformTag(backupUser.userAgent()),\n              Tag.of(FAILURE_REASON_TAG_NAME, \"level\")))\n          .increment();\n\n      throw new BackupPermissionException(\"credential does not support the requested operation\");\n    }\n  }\n\n  /**\n   * Check that the authenticated backup user is authenticated with the given credential type\n   *\n   * @param backupUser     The backup user to check\n   * @param credentialType The credential type to require\n   * @throws BackupWrongCredentialTypeException error if the backup user is not authenticated with the given\n   * {@code credentialType}\n   */\n  @VisibleForTesting\n  static void checkBackupCredentialType(final AuthenticatedBackupUser backupUser, final BackupCredentialType credentialType) throws BackupWrongCredentialTypeException {\n    if (backupUser.credentialType() != credentialType) {\n      Metrics.counter(ZK_AUTHZ_FAILURE_COUNTER_NAME,\n              FAILURE_REASON_TAG_NAME, \"credential_type\")\n          .increment();\n\n      throw new BackupWrongCredentialTypeException(\"wrong credential type for the requested operation\");\n    }\n  }\n\n  @VisibleForTesting\n  static String encodeMediaIdForCdn(final byte[] bytes) {\n    return Base64.getUrlEncoder().encodeToString(bytes);\n  }\n\n  private static byte[] decodeMediaIdFromCdn(final String base64) {\n    return Base64.getUrlDecoder().decode(base64);\n  }\n\n  private static String cdnMessageBackupName(final AuthenticatedBackupUser backupUser) {\n    return \"%s/%s\".formatted(backupUser.backupDir(), MESSAGE_BACKUP_NAME);\n  }\n\n  private static String cdnMediaDirectory(final String backupDir, final String mediaDir) {\n    return \"%s/%s/\".formatted(backupDir, mediaDir);\n  }\n\n  private static String cdnMediaDirectory(final AuthenticatedBackupUser backupUser) {\n    return cdnMediaDirectory(backupUser.backupDir(), backupUser.mediaDir());\n  }\n\n  private static String cdnMediaPath(final AuthenticatedBackupUser backupUser, final byte[] mediaId) {\n    return \"%s%s\".formatted(cdnMediaDirectory(backupUser), encodeMediaIdForCdn(mediaId));\n  }\n\n  static String rateLimitKey(final AuthenticatedBackupUser backupUser) {\n    return Base64.getEncoder().encodeToString(BackupsDb.hashedBackupId(backupUser.backupId()));\n  }\n\n  private static @Nullable UserAgent parseUserAgent(final String userAgentString) {\n    try {\n      return UserAgentUtil.parseUserAgentString(userAgentString);\n    } catch (UnrecognizedUserAgentException e) {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupMissingIdCommitmentException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupMissingIdCommitmentException extends BackupException {\n  public BackupMissingIdCommitmentException() {\n    super();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupNotFoundException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupNotFoundException extends BackupException {\n\n  public BackupNotFoundException(final String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupPermissionException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupPermissionException extends BackupException {\n  public BackupPermissionException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupPublicKeyConflictException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupPublicKeyConflictException extends BackupException {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupUploadDescriptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport java.util.Map;\n\npublic record BackupUploadDescriptor(\n    int cdn,\n    String key,\n    Map<String, String> headers,\n    String signedUploadLocation) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupWrongCredentialTypeException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic class BackupWrongCredentialTypeException extends BackupException {\n  public BackupWrongCredentialTypeException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/BackupsDb.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SecureRandom;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Predicate;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Scheduler;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport software.amazon.awssdk.services.dynamodb.model.Update;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\n/**\n * Tracks backup metadata in a persistent store.\n * <p>\n * It's assumed that the caller has already validated that the backupUser being operated on has valid credentials and\n * possesses the appropriate {@link BackupLevel} to perform the current operation.\n * <p>\n * Backup records track two timestamps indicating the last time that a user interacted with their backup. One for the\n * last refresh that contained a credential including media level, and the other for any access. After a period of\n * inactivity stale backups can be purged (either just the media, or the entire backup). Callers can discover what\n * backups are stale and whether only the media or the entire backup is stale via {@link #getExpiredBackups}.\n * <p>\n * Because backup objects reside on a transactionally unrelated store, expiring anything from the backup requires a 2\n * phase process. First the caller calls {@link #startExpiration} which will atomically update the user's backup\n * directories and record the cdn directory that should be expired. Then the caller must delete the expired directory,\n * calling {@link #finishExpiration} to clear the recorded expired prefix when complete. Since the user's backup\n * directories have been swapped, the deleter does not have to account for a user coming back and starting to upload\n * concurrently with the deletion.\n * <p>\n * If the directory deletion fails, a subsequent call to {@link #getExpiredBackups} will return the backup again\n * indicating that the old expired prefix needs to be cleaned up before any other expiration action is taken. For\n * example, if a media expiration fails and then in the next expiration pass the backup has become eligible for total\n * deletion, the caller still must process the stale media expiration first before processing the full deletion.\n */\npublic class BackupsDb {\n\n  private static final int DIR_NAME_LENGTH = generateDirName(new SecureRandom()).length();\n  public static final int BACKUP_DIRECTORY_PATH_LENGTH = DIR_NAME_LENGTH;\n  public static final int MEDIA_DIRECTORY_PATH_LENGTH = BACKUP_DIRECTORY_PATH_LENGTH + \"/\".length() + DIR_NAME_LENGTH;\n  private static final Logger logger = LoggerFactory.getLogger(BackupsDb.class);\n  static final int BACKUP_CDN = 3;\n\n  private final DynamoDbAsyncClient dynamoClient;\n  private final String backupTableName;\n  private final Clock clock;\n\n  private final SecureRandom secureRandom;\n\n  // The backups table\n\n  // B: 16 bytes that identifies the backup\n  public static final String KEY_BACKUP_ID_HASH = \"U\";\n  // N: Time in seconds since epoch of the last backup refresh. This timestamp must be periodically updated to avoid\n  // garbage collection of archive objects.\n  public static final String ATTR_LAST_REFRESH = \"R\";\n  // N: Time in seconds since epoch of the last backup media refresh. This timestamp can only be updated if the client\n  // has BackupLevel.PAID, and must be periodically updated to avoid garbage collection of media objects.\n  public static final String ATTR_LAST_MEDIA_REFRESH = \"MR\";\n  // B: A 32 byte public key that should be used to sign the presentation used to authenticate requests against the\n  // backup-id\n  public static final String ATTR_PUBLIC_KEY = \"P\";\n  // N: Bytes consumed by this backup\n  public static final String ATTR_MEDIA_BYTES_USED = \"MB\";\n  // N: Number of media objects in the backup\n  public static final String ATTR_MEDIA_COUNT = \"MC\";\n  // N: The cdn number where the message backup is stored\n  public static final String ATTR_CDN = \"CDN\";\n  // N: Time in seconds since epoch of last backup media usage recalculation. This timestamp is updated whenever we\n  // recalculate the up-to-date bytes used by querying the cdn(s) directly.\n  public static final String ATTR_MEDIA_USAGE_LAST_RECALCULATION = \"MBTS\";\n  // S: The name of the user's backup directory on the CDN\n  public static final String ATTR_BACKUP_DIR = \"BD\";\n  // S: The name of the user's media directory within the backup directory on the CDN\n  public static final String ATTR_MEDIA_DIR = \"MD\";\n  // S: A prefix pending deletion\n  public static final String ATTR_EXPIRED_PREFIX = \"EP\";\n\n  public BackupsDb(\n      final DynamoDbAsyncClient dynamoClient,\n      final String backupTableName,\n      final Clock clock) {\n    this.dynamoClient = dynamoClient;\n    this.backupTableName = backupTableName;\n    this.clock = clock;\n    this.secureRandom = new SecureRandom();\n  }\n\n  /**\n   * Set the public key associated with a backupId.\n   *\n   * @param authenticatedBackupId    The backup-id bytes that should be associated with the provided public key\n   * @param authenticatedBackupLevel The backup level\n   * @param publicKey                The public key to associate with the backup id\n   * @return A stage that completes when the public key has been set. If the backup-id already has a set public key that\n   * does not match, the stage will be completed exceptionally with a {@link BackupPublicKeyConflictException}\n   */\n  CompletableFuture<Void> setPublicKey(\n      final byte[] authenticatedBackupId,\n      final BackupLevel authenticatedBackupLevel,\n      final ECPublicKey publicKey) {\n    final byte[] hashedBackupId = hashedBackupId(authenticatedBackupId);\n    return dynamoClient.updateItem(new UpdateBuilder(backupTableName, authenticatedBackupLevel, hashedBackupId)\n            .addSetExpression(\"#publicKey = :publicKey\",\n                Map.entry(\"#publicKey\", ATTR_PUBLIC_KEY),\n                Map.entry(\":publicKey\", AttributeValues.b(publicKey.serialize())))\n            // When the user sets a public key, we ensure that they have a backupDir/mediaDir assigned\n            .setDirectoryNamesIfMissing(secureRandom)\n            .setRefreshTimes(clock)\n            .withConditionExpression(\"attribute_not_exists(#publicKey) OR #publicKey = :publicKey\")\n            .updateItemBuilder()\n            .build())\n        .exceptionally(ExceptionUtils.marshal(ConditionalCheckFailedException.class, e ->\n            // There was already a row for this backup-id and it contained a different publicKey\n            new BackupPublicKeyConflictException()))\n        .thenRun(Util.NOOP);\n  }\n\n  /**\n   * Data stored to authenticate a backup user\n   *\n   * @param publicKey The public key for the backup entry. All credentials for this backup user must be signed * by this\n   *                  public key for the credential to be valid\n   * @param backupDir The current backupDir for the backup user. If authentication is successful, the user may be given\n   *                  credentials for this backupDir on the CDN\n   * @param mediaDir  The current mediaDir for the backup user. If authentication is successful, the user may be given *\n   *                  credentials for the path backupDir/mediaDir on the CDN\n   */\n  record AuthenticationData(ECPublicKey publicKey, String backupDir, String mediaDir) {}\n\n  CompletableFuture<Optional<AuthenticationData>> retrieveAuthenticationData(byte[] backupId) {\n    final byte[] hashedBackupId = hashedBackupId(backupId);\n    return dynamoClient.getItem(GetItemRequest.builder()\n            .tableName(backupTableName)\n            .key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))\n            .consistentRead(true)\n            .projectionExpression(\"#publicKey,#backupDir,#mediaDir\")\n            .expressionAttributeNames(Map.of(\n                \"#publicKey\", ATTR_PUBLIC_KEY,\n                \"#backupDir\", ATTR_BACKUP_DIR,\n                \"#mediaDir\", ATTR_MEDIA_DIR))\n            .build())\n        .thenApply(response -> extractStoredPublicKey(response.item())\n            .map(pubKey -> new AuthenticationData(\n                pubKey,\n                getDirName(response.item(), ATTR_BACKUP_DIR),\n                getDirName(response.item(), ATTR_MEDIA_DIR))));\n  }\n\n  private static String getDirName(final Map<String, AttributeValue> item, final String attr) {\n    return AttributeValues.get(item, attr).map(AttributeValue::s).orElseThrow(() -> {\n      logger.error(\"Backups with public keys should have directory names\");\n      throw new UncheckedIOException(new IOException(\"Backups with public keys must have directory names\"));\n    });\n  }\n\n  private static Optional<ECPublicKey> extractStoredPublicKey(final Map<String, AttributeValue> item) {\n    return AttributeValues.get(item, ATTR_PUBLIC_KEY)\n        .map(AttributeValue::b)\n        .map(SdkBytes::asByteArray)\n        .map(BackupsDb::deserializeStoredPublicKey);\n  }\n\n  private static ECPublicKey deserializeStoredPublicKey(final byte[] publicKeyBytes) {\n    try {\n      return new ECPublicKey(publicKeyBytes);\n    } catch (InvalidKeyException e) {\n      logger.error(\"Invalid publicKey {}\", HexFormat.of().formatHex(publicKeyBytes), e);\n      throw new UncheckedIOException(new IOException(\"Could not deserialize stored public key\"));\n    }\n  }\n\n  /**\n   * Update the quota in the backup table\n   *\n   * @param backupUser      The backup user\n   * @param mediaBytesDelta The length of the media after encryption. A negative length implies media being removed\n   * @param mediaCountDelta The number of media objects being added, or if negative, removed\n   * @return A stage that completes successfully once the table are updated.\n   */\n  CompletableFuture<Void> trackMedia(final AuthenticatedBackupUser backupUser, final long mediaCountDelta,\n      final long mediaBytesDelta) {\n    return dynamoClient\n        .updateItem(\n            // Update the media quota and TTL\n            UpdateBuilder.forUser(backupTableName, backupUser)\n                .incrementMediaBytes(mediaBytesDelta)\n                .incrementMediaCount(mediaCountDelta)\n                .updateItemBuilder()\n                .build())\n        .thenRun(Util.NOOP);\n  }\n\n\n  /**\n   * Update the last update timestamps for the backupId in the presentation\n   *\n   * @param backupUser an already authorized backup user\n   */\n  CompletableFuture<StoredBackupAttributes> ttlRefresh(final AuthenticatedBackupUser backupUser) {\n    final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);\n    // update message backup TTL\n    return dynamoClient.updateItem(UpdateBuilder.forUser(backupTableName, backupUser)\n            .setRefreshTimes(today)\n            .updateItemBuilder()\n            .returnValues(ReturnValue.ALL_OLD)\n            .build())\n        .thenApply(updateItemResponse -> fromItem(updateItemResponse.attributes()));\n  }\n\n\n  /**\n   * Track that a backup will be stored for the user\n   *\n   * @param backupUser an already authorized backup user\n   * @return A future that completes with the attributes of the backup before the update\n   */\n  CompletableFuture<StoredBackupAttributes> addMessageBackup(final AuthenticatedBackupUser backupUser) {\n    final Instant today = clock.instant().truncatedTo(ChronoUnit.DAYS);\n    // this could race with concurrent updates, but the only effect would be last-writer-wins on the timestamp\n    return dynamoClient.updateItem(\n            UpdateBuilder.forUser(backupTableName, backupUser)\n                .setRefreshTimes(today)\n                .setCdn(BACKUP_CDN)\n                .updateItemBuilder()\n                .returnValues(ReturnValue.ALL_OLD)\n                .build())\n        .thenApply(updateItemResponse -> fromItem(updateItemResponse.attributes()));\n  }\n\n  /**\n   * Indicates that we couldn't schedule a deletion because one was already scheduled. The caller may want to delete the\n   * objects directly.\n   */\n  static class PendingDeletionException extends IOException {}\n\n  /**\n   * Attempt to mark a backup as expired and swap in a new empty backupDir for the user.\n   * <p>\n   * After successful completion, the backupDir for the backup-id will be swapped to a new empty directory on the cdn,\n   * and the row will be immediately marked eligible for expiration via {@link #getExpiredBackups}.\n   * <p>\n   * If there is already a pending deletion, this will not swap the backupDir. The expiration timestamps will be\n   * updated, but the existing backupDir will remain. The caller should handle this case and start the deletion\n   * immediately by catching {@link PendingDeletionException}.\n   *\n   * @param backupUser The backupUser whose data should be eventually deleted\n   * @return A future that completes successfully if the user's data is now inaccessible, or with a\n   * {@link PendingDeletionException} if the backupDir could not be changed.\n   */\n  CompletableFuture<Void> scheduleBackupDeletion(final AuthenticatedBackupUser backupUser) {\n    final byte[] hashedBackupId = hashedBackupId(backupUser);\n\n    // Clear usage metadata, swap names of things we intend to delete, and record our intent to delete in attr_expired_prefix\n    return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)\n            .clearMediaUsage(clock)\n            .expireDirectoryNames(secureRandom, ExpiredBackup.ExpirationType.ALL)\n            .setRefreshTimes(Instant.ofEpochSecond(0))\n            .addSetExpression(\"#expiredPrefix = :expiredPrefix\",\n                Map.entry(\"#expiredPrefix\", ATTR_EXPIRED_PREFIX),\n                Map.entry(\":expiredPrefix\", AttributeValues.s(backupUser.backupDir())))\n            .withConditionExpression(\"attribute_not_exists(#expiredPrefix) OR #expiredPrefix = :expiredPrefix\")\n            .updateItemBuilder()\n            .build())\n        .exceptionallyCompose(ExceptionUtils.exceptionallyHandler(ConditionalCheckFailedException.class, e ->\n            // We already have a pending deletion for this backup-id. This is most likely to occur when the caller\n            // is toggling backups on and off. In this case, it should be pretty cheap to directly delete the backup.\n            // Instead of changing the backupDir, just make sure the row has expired/ timestamps and tell the caller we\n            // couldn't schedule the deletion.\n            dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)\n                    .setRefreshTimes(Instant.ofEpochSecond(0))\n                    .updateItemBuilder()\n                    .build())\n                .thenApply(ignore -> {\n                  throw ExceptionUtils.wrap(new PendingDeletionException());\n                })))\n        .thenRun(Util.NOOP);\n  }\n\n  record BackupDescription(int cdn, Optional<Long> mediaUsedSpace) {}\n\n  /**\n   * Retrieve information about the backup\n   *\n   * @param backupUser an already authorized backup user\n   * @return A {@link BackupDescription} containing the cdn of the message backup and the total number of media space\n   * bytes used by the backup user.\n   * @throws BackupNotFoundException If the provided backupUser's backup-id does not exist\n   */\n  CompletableFuture<BackupDescription> describeBackup(final AuthenticatedBackupUser backupUser) {\n    return dynamoClient.getItem(GetItemRequest.builder()\n            .tableName(backupTableName)\n            .key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))\n            .projectionExpression(\"#cdn,#mediaBytesUsed\")\n            .expressionAttributeNames(Map.of(\"#cdn\", ATTR_CDN, \"#mediaBytesUsed\", ATTR_MEDIA_BYTES_USED))\n            .consistentRead(true)\n            .build())\n        .thenApply(response -> {\n          if (!response.hasItem()) {\n            // At this point, the user has already authenticated against this backup record, so we must have raced\n            // with a deletion. Just throw the same error we would have thrown if authentication had failed\n            throw ExceptionUtils.wrap(new BackupFailedZkAuthenticationException(\"Backup ID not found\"));\n          }\n          // If the client hasn't already uploaded a backup, return the cdn we would return if they did create one\n          final int cdn = AttributeValues.getInt(response.item(), ATTR_CDN, BACKUP_CDN);\n          final Optional<Long> mediaUsed = AttributeValues.get(response.item(), ATTR_MEDIA_BYTES_USED)\n              .map(AttributeValue::n)\n              .map(Long::parseLong);\n\n          return new BackupDescription(cdn, mediaUsed);\n        });\n  }\n\n  public record TimestampedUsageInfo(UsageInfo usageInfo, Instant lastRecalculationTime) {}\n\n  CompletableFuture<TimestampedUsageInfo> getMediaUsage(final AuthenticatedBackupUser backupUser) {\n    return dynamoClient.getItem(GetItemRequest.builder()\n            .tableName(backupTableName)\n            .key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser))))\n            .projectionExpression(\"#mediaBytesUsed,#mediaCount,#usageRecalc\")\n            .expressionAttributeNames(Map.of(\n                \"#mediaBytesUsed\", ATTR_MEDIA_BYTES_USED,\n                \"#mediaCount\", ATTR_MEDIA_COUNT,\n                \"#usageRecalc\", ATTR_MEDIA_USAGE_LAST_RECALCULATION))\n            .consistentRead(true)\n            .build())\n        .thenApply(response -> {\n          final long mediaUsed = AttributeValues.getLong(response.item(), ATTR_MEDIA_BYTES_USED, 0L);\n          final long mediaCount = AttributeValues.getLong(response.item(), ATTR_MEDIA_COUNT, 0L);\n          final long recalcSeconds = AttributeValues.getLong(response.item(), ATTR_MEDIA_USAGE_LAST_RECALCULATION, 0L);\n          return new TimestampedUsageInfo(new UsageInfo(mediaUsed, mediaCount), Instant.ofEpochSecond(recalcSeconds));\n        });\n\n\n  }\n\n  CompletableFuture<Void> setMediaUsage(final AuthenticatedBackupUser backupUser, UsageInfo usageInfo) {\n    return setMediaUsage(UpdateBuilder.forUser(backupTableName, backupUser), usageInfo);\n  }\n\n  CompletableFuture<Void> setMediaUsage(final StoredBackupAttributes backupAttributes, UsageInfo usageInfo) {\n    return setMediaUsage(new UpdateBuilder(backupTableName, BackupLevel.PAID, backupAttributes.hashedBackupId()), usageInfo);\n  }\n\n  private CompletableFuture<Void> setMediaUsage(final UpdateBuilder updateBuilder, UsageInfo usageInfo) {\n    return dynamoClient.updateItem(\n            updateBuilder\n                .addSetExpression(\"#mediaBytesUsed = :mediaBytesUsed\",\n                    Map.entry(\"#mediaBytesUsed\", ATTR_MEDIA_BYTES_USED),\n                    Map.entry(\":mediaBytesUsed\", AttributeValues.n(usageInfo.bytesUsed())))\n                .addSetExpression(\"#mediaCount = :mediaCount\",\n                    Map.entry(\"#mediaCount\", ATTR_MEDIA_COUNT),\n                    Map.entry(\":mediaCount\", AttributeValues.n(usageInfo.numObjects())))\n                .addSetExpression(\"#mediaRecalc = :mediaRecalc\",\n                    Map.entry(\"#mediaRecalc\", ATTR_MEDIA_USAGE_LAST_RECALCULATION),\n                    Map.entry(\":mediaRecalc\", AttributeValues.n(clock.instant().getEpochSecond())))\n                .updateItemBuilder()\n                .build())\n        .thenRun(Util.NOOP);\n  }\n\n\n  /**\n   * Marks the backup as undergoing expiration.\n   * <p>\n   * This must be called before beginning to delete items in the CDN with the prefix specified by\n   * {@link ExpiredBackup#prefixToDelete()}. If the prefix has been successfully deleted, {@link #finishExpiration} must\n   * be called.\n   *\n   * @param expiredBackup The backup to expire\n   * @return A stage that completes when the backup has been marked for expiration\n   */\n  CompletableFuture<Void> startExpiration(final ExpiredBackup expiredBackup) {\n    if (expiredBackup.expirationType() == ExpiredBackup.ExpirationType.GARBAGE_COLLECTION) {\n      // We've already updated the row on a prior (failed) attempt, just need to remove the data from the cdn now\n      return CompletableFuture.completedFuture(null);\n    }\n\n    // Clear usage metadata, swap names of things we intend to delete, and record our intent to delete in attr_expired_prefix\n    return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, expiredBackup.hashedBackupId())\n            .clearMediaUsage(clock)\n            .expireDirectoryNames(secureRandom, expiredBackup.expirationType())\n            .addRemoveExpression(Map.entry(\"#mediaRefresh\", ATTR_LAST_MEDIA_REFRESH))\n            .addSetExpression(\"#expiredPrefix = :expiredPrefix\",\n                Map.entry(\"#expiredPrefix\", ATTR_EXPIRED_PREFIX),\n                Map.entry(\":expiredPrefix\", AttributeValues.s(expiredBackup.prefixToDelete())))\n            .withConditionExpression(\"attribute_not_exists(#expiredPrefix) OR #expiredPrefix = :expiredPrefix\")\n            .updateItemBuilder()\n            .build())\n        .thenRun(Util.NOOP);\n  }\n\n  /**\n   * Complete expiration of a backup started with {@link #startExpiration}\n   * <p>\n   * If the expiration was for the entire backup, this will delete the entire item for the backup.\n   *\n   * @param expiredBackup The backup to expire\n   * @return A stage that completes when the expiration is marked as finished\n   */\n  CompletableFuture<Void> finishExpiration(final ExpiredBackup expiredBackup) {\n    final byte[] hashedBackupId = expiredBackup.hashedBackupId();\n    if (expiredBackup.expirationType() == ExpiredBackup.ExpirationType.ALL) {\n      final long expectedLastRefresh = expiredBackup.lastRefresh().getEpochSecond();\n      return dynamoClient.deleteItem(DeleteItemRequest.builder()\n              .tableName(backupTableName)\n              .key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))\n              .conditionExpression(\"#lastRefresh <= :expectedLastRefresh\")\n              .expressionAttributeNames(Map.of(\"#lastRefresh\", ATTR_LAST_REFRESH))\n              .expressionAttributeValues(Map.of(\":expectedLastRefresh\", AttributeValues.n(expectedLastRefresh)))\n              .build())\n          .thenRun(Util.NOOP);\n    } else {\n      return dynamoClient.updateItem(new UpdateBuilder(backupTableName, BackupLevel.PAID, hashedBackupId)\n              .addRemoveExpression(Map.entry(\"#expiredPrefixes\", ATTR_EXPIRED_PREFIX))\n              .updateItemBuilder()\n              .build())\n          .thenRun(Util.NOOP);\n    }\n  }\n\n  Flux<StoredBackupAttributes> listBackupAttributes(final int segments) {\n    if (segments < 1) {\n      throw new IllegalArgumentException(\"Total number of segments must be positive\");\n    }\n\n    return Flux.range(0, segments)\n        .flatMap(segment -> dynamoClient.scanPaginator(ScanRequest.builder()\n                .tableName(backupTableName)\n                .consistentRead(true)\n                .segment(segment)\n                .totalSegments(segments)\n                .expressionAttributeNames(Map.of(\n                    \"#backupIdHash\", KEY_BACKUP_ID_HASH,\n                    \"#refresh\", ATTR_LAST_REFRESH,\n                    \"#mediaRefresh\", ATTR_LAST_MEDIA_REFRESH,\n                    \"#bytesUsed\", ATTR_MEDIA_BYTES_USED,\n                    \"#numObjects\", ATTR_MEDIA_COUNT,\n                    \"#backupDir\", ATTR_BACKUP_DIR,\n                    \"#mediaDir\", ATTR_MEDIA_DIR))\n                .projectionExpression(\"#backupIdHash, #refresh, #mediaRefresh, #bytesUsed, #numObjects, #backupDir, #mediaDir\")\n                .build()))\n        // Don't use the SDK's item publisher, works around https://github.com/aws/aws-sdk-java-v2/issues/6411\n        .concatMap(page -> Flux.fromIterable(page.items()))\n        .filter(item -> item.containsKey(KEY_BACKUP_ID_HASH))\n        .map(BackupsDb::fromItem);\n  }\n\n  private static StoredBackupAttributes fromItem(Map<String, AttributeValue> item) {\n    return new StoredBackupAttributes(\n        AttributeValues.getByteArray(item, KEY_BACKUP_ID_HASH, null),\n        AttributeValues.getString(item, ATTR_BACKUP_DIR, null),\n        AttributeValues.getString(item, ATTR_MEDIA_DIR, null),\n        Instant.ofEpochSecond(AttributeValues.getLong(item, ATTR_LAST_REFRESH, 0L)),\n        Instant.ofEpochSecond(AttributeValues.getLong(item, ATTR_LAST_MEDIA_REFRESH, 0L)),\n        AttributeValues.getLong(item, ATTR_MEDIA_BYTES_USED, 0L),\n        AttributeValues.getLong(item, ATTR_MEDIA_COUNT, 0L));\n  }\n\n  Flux<ExpiredBackup> getExpiredBackups(final int segments, final Scheduler scheduler, final Instant purgeTime) {\n    if (segments < 1) {\n      throw new IllegalArgumentException(\"Total number of segments must be positive\");\n    }\n\n    return Flux.range(0, segments)\n        .parallel()\n        .runOn(scheduler)\n        .flatMap(segment -> dynamoClient.scanPaginator(ScanRequest.builder()\n                .tableName(backupTableName)\n                .consistentRead(true)\n                .segment(segment)\n                .totalSegments(segments)\n                .expressionAttributeNames(Map.of(\n                    \"#backupIdHash\", KEY_BACKUP_ID_HASH,\n                    \"#refresh\", ATTR_LAST_REFRESH,\n                    \"#mediaRefresh\", ATTR_LAST_MEDIA_REFRESH,\n                    \"#backupDir\", ATTR_BACKUP_DIR,\n                    \"#mediaDir\", ATTR_MEDIA_DIR,\n                    \"#expiredPrefix\", ATTR_EXPIRED_PREFIX))\n                .expressionAttributeValues(Map.of(\":purgeTime\", AttributeValues.n(purgeTime.getEpochSecond())))\n                .projectionExpression(\"#backupIdHash, #refresh, #mediaRefresh, #backupDir, #mediaDir, #expiredPrefix\")\n                .filterExpression(\n                    \"(#refresh < :purgeTime) OR (#mediaRefresh < :purgeTime) OR attribute_exists(#expiredPrefix)\")\n                .build())\n            .items())\n        .sequential()\n        .filter(Predicate.not(Map::isEmpty))\n        .mapNotNull(item -> {\n          final byte[] hashedBackupId = AttributeValues.getByteArray(item, KEY_BACKUP_ID_HASH, null);\n          if (hashedBackupId == null) {\n            return null;\n          }\n          final String backupDir = AttributeValues.getString(item, ATTR_BACKUP_DIR, null);\n          final String mediaDir = AttributeValues.getString(item, ATTR_MEDIA_DIR, null);\n          if (backupDir == null || mediaDir == null) {\n            // Could be the case for backups that have not yet set a public key\n            return null;\n          }\n          final long lastRefresh = AttributeValues.getLong(item, ATTR_LAST_REFRESH, Long.MAX_VALUE);\n          final long lastMediaRefresh = AttributeValues.getLong(item, ATTR_LAST_MEDIA_REFRESH, Long.MAX_VALUE);\n          final String existingExpiration = AttributeValues.getString(item, ATTR_EXPIRED_PREFIX, null);\n\n          final ExpiredBackup expiredBackup;\n          if (existingExpiration != null) {\n            // If we have work from a failed previous expiration, handle that before worrying about any new expirations.\n            // This guarantees we won't accumulate expirations\n            expiredBackup = new ExpiredBackup(hashedBackupId, ExpiredBackup.ExpirationType.GARBAGE_COLLECTION,\n                Instant.ofEpochSecond(lastRefresh), existingExpiration);\n          } else if (lastRefresh < purgeTime.getEpochSecond()) {\n            // The whole backup was expired\n            expiredBackup = new ExpiredBackup(hashedBackupId, ExpiredBackup.ExpirationType.ALL,\n                Instant.ofEpochSecond(lastRefresh), backupDir);\n          } else if (lastMediaRefresh < purgeTime.getEpochSecond()) {\n            // The media was expired\n            expiredBackup = new ExpiredBackup(hashedBackupId, ExpiredBackup.ExpirationType.MEDIA,\n                Instant.ofEpochSecond(lastRefresh), backupDir + \"/\" + mediaDir);\n          } else {\n            return null;\n          }\n\n          if (!isValid(expiredBackup)) {\n            logger.error(\"Not expiring backup {} for backupId {} with invalid cdn path prefixes\",\n                HexFormat.of().formatHex(expiredBackup.hashedBackupId()),\n                expiredBackup);\n            return null;\n          }\n          return expiredBackup;\n        });\n  }\n\n  /**\n   * Backup expiration will expire any prefix we tell it to, so confirm that the directory names that came out of the\n   * database have the correct shape before handing them off.\n   *\n   * @param expiredBackup The ExpiredBackup object to check\n   * @return Whether this is a valid expiration object\n   */\n  private static boolean isValid(final ExpiredBackup expiredBackup) {\n    // expired prefixes should be of the form \"backupDir\" or \"backupDir/mediaDir\"\n    return switch (expiredBackup.expirationType()) {\n      case MEDIA -> expiredBackup.prefixToDelete().length() == MEDIA_DIRECTORY_PATH_LENGTH;\n      case ALL -> expiredBackup.prefixToDelete().length() == BACKUP_DIRECTORY_PATH_LENGTH;\n      case GARBAGE_COLLECTION -> expiredBackup.prefixToDelete().length() == MEDIA_DIRECTORY_PATH_LENGTH ||\n          expiredBackup.prefixToDelete().length() == BACKUP_DIRECTORY_PATH_LENGTH;\n    };\n  }\n\n  /**\n   * Build ddb update statements for the backups table\n   */\n  private static class UpdateBuilder {\n\n    private final List<String> setStatements = new ArrayList<>();\n    private final List<String> removeStatements = new ArrayList<>();\n    private final Map<String, AttributeValue> attrValues = new HashMap<>();\n    private final Map<String, String> attrNames = new HashMap<>();\n\n    private final String tableName;\n    private final BackupLevel backupLevel;\n    private final byte[] hashedBackupId;\n    private String conditionExpression = null;\n\n    static UpdateBuilder forUser(String tableName, AuthenticatedBackupUser backupUser) {\n      return new UpdateBuilder(tableName, backupUser.backupLevel(), hashedBackupId(backupUser));\n    }\n\n    UpdateBuilder(String tableName, BackupLevel backupLevel, byte[] hashedBackupId) {\n      this.tableName = tableName;\n      this.backupLevel = backupLevel;\n      this.hashedBackupId = hashedBackupId;\n    }\n\n    private void addAttrValue(Map.Entry<String, AttributeValue> attrValue) {\n      final AttributeValue old = attrValues.put(attrValue.getKey(), attrValue.getValue());\n      if (old != null && !old.equals(attrValue.getValue())) {\n        throw new IllegalArgumentException(\"duplicate attrValue key used for different values\");\n      }\n    }\n\n    private void addAttrName(Map.Entry<String, String> attrName) {\n      final String oldName = attrNames.put(attrName.getKey(), attrName.getValue());\n      if (oldName != null && !oldName.equals(attrName.getValue())) {\n        throw new IllegalArgumentException(\"duplicate attrName key used for different attribute names\");\n      }\n    }\n\n    private void addAttrs(final Map.Entry<String, String> attrName, final Map.Entry<String, AttributeValue> attrValue) {\n      addAttrName(attrName);\n      addAttrValue(attrValue);\n    }\n\n    UpdateBuilder addSetExpression(\n        final String update,\n        final Map.Entry<String, String> attrName,\n        final Map.Entry<String, AttributeValue> attrValue) {\n      setStatements.add(update);\n      addAttrs(attrName, attrValue);\n      return this;\n    }\n\n    UpdateBuilder addSetExpression(final String update) {\n      setStatements.add(update);\n      return this;\n    }\n\n    UpdateBuilder addRemoveExpression(final Map.Entry<String, String> attrName) {\n      addAttrName(attrName);\n      removeStatements.add(attrName.getKey());\n      return this;\n    }\n\n    UpdateBuilder withConditionExpression(final String conditionExpression) {\n      this.conditionExpression = conditionExpression;\n      return this;\n    }\n\n    UpdateBuilder withConditionExpression(\n        final String conditionExpression,\n        final Map.Entry<String, String> attrName,\n        final Map.Entry<String, AttributeValue> attrValue) {\n      this.addAttrs(attrName, attrValue);\n      this.conditionExpression = conditionExpression;\n      return this;\n    }\n\n    UpdateBuilder setCdn(final int cdn) {\n      return addSetExpression(\n          \"#cdn = :cdn\",\n          Map.entry(\"#cdn\", ATTR_CDN),\n          Map.entry(\":cdn\", AttributeValues.n(cdn)));\n    }\n\n    UpdateBuilder incrementMediaCount(long delta) {\n      addAttrName(Map.entry(\"#mediaCount\", ATTR_MEDIA_COUNT));\n      addAttrValue(Map.entry(\":zero\", AttributeValues.n(0)));\n      addAttrValue(Map.entry(\":mediaCountDelta\", AttributeValues.n(delta)));\n      addSetExpression(\"#mediaCount = if_not_exists(#mediaCount, :zero) + :mediaCountDelta\");\n      return this;\n    }\n\n    UpdateBuilder incrementMediaBytes(long delta) {\n      addAttrName(Map.entry(\"#mediaBytes\", ATTR_MEDIA_BYTES_USED));\n      addAttrValue(Map.entry(\":zero\", AttributeValues.n(0)));\n      addAttrValue(Map.entry(\":mediaBytesDelta\", AttributeValues.n(delta)));\n      addSetExpression(\"#mediaBytes = if_not_exists(#mediaBytes, :zero) + :mediaBytesDelta\");\n      return this;\n    }\n\n    UpdateBuilder clearMediaUsage(final Clock clock) {\n      addSetExpression(\"#mediaBytesUsed = :mediaBytesUsed\",\n          Map.entry(\"#mediaBytesUsed\", ATTR_MEDIA_BYTES_USED),\n          Map.entry(\":mediaBytesUsed\", AttributeValues.n(0L)));\n      addSetExpression(\"#mediaCount = :mediaCount\",\n          Map.entry(\"#mediaCount\", ATTR_MEDIA_COUNT),\n          Map.entry(\":mediaCount\", AttributeValues.n(0L)));\n      addSetExpression(\"#mediaRecalc = :mediaRecalc\",\n          Map.entry(\"#mediaRecalc\", ATTR_MEDIA_USAGE_LAST_RECALCULATION),\n          Map.entry(\":mediaRecalc\", AttributeValues.n(clock.instant().getEpochSecond())));\n      return this;\n    }\n\n    UpdateBuilder setDirectoryNamesIfMissing(final SecureRandom secureRandom) {\n      final String backupDir = generateDirName(secureRandom);\n      final String mediaDir = generateDirName(secureRandom);\n      addSetExpression(\"#backupDir = if_not_exists(#backupDir, :backupDir)\",\n          Map.entry(\"#backupDir\", ATTR_BACKUP_DIR),\n          Map.entry(\":backupDir\", AttributeValues.s(backupDir)));\n\n      addSetExpression(\"#mediaDir = if_not_exists(#mediaDir, :mediaDir)\",\n          Map.entry(\"#mediaDir\", ATTR_MEDIA_DIR),\n          Map.entry(\":mediaDir\", AttributeValues.s(mediaDir)));\n      return this;\n    }\n\n    UpdateBuilder expireDirectoryNames(\n        final SecureRandom secureRandom,\n        final ExpiredBackup.ExpirationType expirationType) {\n      final String backupDir = generateDirName(secureRandom);\n      final String mediaDir = generateDirName(secureRandom);\n      return switch (expirationType) {\n        case GARBAGE_COLLECTION -> this;\n        case MEDIA -> this.addSetExpression(\"#mediaDir = :mediaDir\",\n            Map.entry(\"#mediaDir\", ATTR_MEDIA_DIR),\n            Map.entry(\":mediaDir\", AttributeValues.s(mediaDir)));\n        case ALL -> this\n            .addSetExpression(\"#mediaDir = :mediaDir\",\n                Map.entry(\"#mediaDir\", ATTR_MEDIA_DIR),\n                Map.entry(\":mediaDir\", AttributeValues.s(mediaDir)))\n            .addSetExpression(\"#backupDir = :backupDir\",\n                Map.entry(\"#backupDir\", ATTR_BACKUP_DIR),\n                Map.entry(\":backupDir\", AttributeValues.s(backupDir)));\n      };\n    }\n\n    UpdateBuilder setRefreshTimes(final Clock clock) {\n      return setRefreshTimes(clock.instant().truncatedTo(ChronoUnit.DAYS));\n    }\n\n    /**\n     * Set the lastRefresh time as part of the update\n     * <p>\n     * This always updates lastRefreshTime, and updates lastMediaRefreshTime if the backup user has the appropriate\n     * level.\n     */\n    UpdateBuilder setRefreshTimes(final Instant refreshTime) {\n      if (!refreshTime.truncatedTo(ChronoUnit.DAYS).equals(refreshTime)) {\n        throw new IllegalArgumentException(\"Refresh time must be day aligned\");\n      }\n      addSetExpression(\"#lastRefreshTime = :lastRefreshTime\",\n          Map.entry(\"#lastRefreshTime\", ATTR_LAST_REFRESH),\n          Map.entry(\":lastRefreshTime\", AttributeValues.n(refreshTime.getEpochSecond())));\n\n      if (backupLevel.compareTo(BackupLevel.PAID) >= 0) {\n        // update the media time if we have the appropriate level\n        addSetExpression(\"#lastMediaRefreshTime = :lastMediaRefreshTime\",\n            Map.entry(\"#lastMediaRefreshTime\", ATTR_LAST_MEDIA_REFRESH),\n            Map.entry(\":lastMediaRefreshTime\", AttributeValues.n(refreshTime.getEpochSecond())));\n      }\n      return this;\n    }\n\n    private String updateExpression() {\n      final StringBuilder sb = new StringBuilder();\n      if (!setStatements.isEmpty()) {\n        sb.append(\"SET \");\n        sb.append(String.join(\",\", setStatements));\n      }\n      if (!removeStatements.isEmpty()) {\n        sb.append(\" REMOVE \");\n        sb.append(String.join(\",\", removeStatements));\n      }\n      return sb.toString();\n    }\n\n    /**\n     * Prepare a non-transactional update\n     *\n     * @return An {@link UpdateItemRequest#builder()} that can be used with updateItem\n     */\n    UpdateItemRequest.Builder updateItemBuilder() {\n      final UpdateItemRequest.Builder bldr = UpdateItemRequest.builder()\n          .tableName(tableName)\n          .key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))\n          .updateExpression(updateExpression())\n          .expressionAttributeNames(attrNames);\n      if (!this.attrValues.isEmpty()) {\n        bldr.expressionAttributeValues(attrValues);\n      }\n      if (this.conditionExpression != null) {\n        bldr.conditionExpression(conditionExpression);\n      }\n      return bldr;\n    }\n\n    /**\n     * Prepare a transactional update\n     *\n     * @return An {@link Update#builder()} that can be used with transactItem\n     */\n    Update.Builder transactItemBuilder() {\n      final Update.Builder bldr = Update.builder()\n          .tableName(tableName)\n          .key(Map.of(KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId)))\n          .updateExpression(updateExpression())\n          .expressionAttributeNames(attrNames)\n          .expressionAttributeValues(attrValues);\n      if (this.conditionExpression != null) {\n        bldr.conditionExpression(conditionExpression);\n      }\n      return bldr;\n    }\n  }\n\n  static String generateDirName(final SecureRandom secureRandom) {\n    final byte[] bytes = new byte[16];\n    secureRandom.nextBytes(bytes);\n    return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);\n  }\n\n  private static byte[] hashedBackupId(final AuthenticatedBackupUser backupId) {\n    return hashedBackupId(backupId.backupId());\n  }\n\n  static byte[] hashedBackupId(final byte[] backupId) {\n    try {\n      return Arrays.copyOf(MessageDigest.getInstance(\"SHA-256\").digest(backupId), 16);\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/Cdn3BackupCredentialGenerator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport org.apache.http.HttpHeaders;\nimport org.whispersystems.textsecuregcm.attachments.TusConfiguration;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.util.Base64;\nimport java.util.Map;\n\npublic class Cdn3BackupCredentialGenerator {\n\n  public static final String CDN_PATH = \"backups\";\n  public static final int BACKUP_CDN = 3;\n\n  private static String READ_PERMISSION = \"read\";\n  private static String WRITE_PERMISSION = \"write\";\n  private static String PERMISSION_SEPARATOR = \"$\";\n\n  // Write entities will be of the form 'write$backups/<string>\n  private static final String WRITE_ENTITY_PREFIX = String.format(\"%s%s%s/\", WRITE_PERMISSION, PERMISSION_SEPARATOR,\n      CDN_PATH);\n  // Read entities will be of the form 'read$backups/<string>\n  private static final String READ_ENTITY_PREFIX = String.format(\"%s%s%s/\", READ_PERMISSION, PERMISSION_SEPARATOR,\n      CDN_PATH);\n\n  private final ExternalServiceCredentialsGenerator credentialsGenerator;\n  private final String tusUri;\n\n  public Cdn3BackupCredentialGenerator(final TusConfiguration cfg) {\n    this.tusUri = cfg.uploadUri();\n    this.credentialsGenerator = credentialsGenerator(Clock.systemUTC(), cfg);\n  }\n\n  private static ExternalServiceCredentialsGenerator credentialsGenerator(final Clock clock,\n      final TusConfiguration cfg) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .prependUsername(false)\n        .withClock(clock)\n        .build();\n  }\n\n  public BackupUploadDescriptor generateUpload(final String key) {\n    if (key.isBlank()) {\n      throw new IllegalArgumentException(\"Upload descriptors must have non-empty keys\");\n    }\n    final String entity = WRITE_ENTITY_PREFIX + key;\n    final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(entity);\n    final String b64Key = Base64.getEncoder().encodeToString(key.getBytes(StandardCharsets.UTF_8));\n    final Map<String, String> headers = Map.of(\n        HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials),\n        \"Upload-Metadata\", String.format(\"filename %s\", b64Key));\n\n    return new BackupUploadDescriptor(\n        BACKUP_CDN,\n        key,\n        headers,\n        tusUri + \"/\" + CDN_PATH);\n  }\n\n  public Map<String, String> readHeaders(final String hashedBackupId) {\n    if (hashedBackupId.isBlank()) {\n      throw new IllegalArgumentException(\"Backup subdir name must be non-empty\");\n    }\n    final ExternalServiceCredentials credentials = credentialsGenerator.generateFor(\n        READ_ENTITY_PREFIX + hashedBackupId);\n    return Map.of(HttpHeaders.AUTHORIZATION, HeaderUtils.basicAuthHeader(credentials));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/Cdn3RemoteStorageManager.java",
    "content": "package org.whispersystems.textsecuregcm.backup;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ScheduledExecutorService;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.HttpUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class Cdn3RemoteStorageManager implements RemoteStorageManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(Cdn3RemoteStorageManager.class);\n\n  private final FaultTolerantHttpClient storageManagerHttpClient;\n  private final String storageManagerBaseUrl;\n  private final String clientId;\n  private final String clientSecret;\n  private final Map<Integer, String> sourceSchemes;\n\n  static final String CLIENT_ID_HEADER = \"CF-Access-Client-Id\";\n  static final String CLIENT_SECRET_HEADER = \"CF-Access-Client-Secret\";\n\n  private static final String STORAGE_MANAGER_STATUS_COUNTER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class,\n      \"storageManagerStatus\");\n\n  private static final String STORAGE_MANAGER_TIMER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class,\n      \"storageManager\");\n  private static final String OPERATION_TAG_NAME = \"op\";\n  private static final String STATUS_TAG_NAME = \"status\";\n\n  private static final String OBJECT_REMOVED_ON_DELETE_COUNTER_NAME = MetricsUtil.name(Cdn3RemoteStorageManager.class, \"objectRemovedOnDelete\");\n\n  public Cdn3RemoteStorageManager(\n      final ExecutorService httpExecutor,\n      final ScheduledExecutorService retryExecutor,\n      final Cdn3StorageManagerConfiguration configuration) {\n\n    // strip trailing \"/\" for easier URI construction\n    this.storageManagerBaseUrl = StringUtils.removeEnd(configuration.baseUri(), \"/\");\n    this.clientId = configuration.clientId();\n    this.clientSecret = configuration.clientSecret().value();\n\n    // Client used for calls to storage-manager\n    this.storageManagerHttpClient = FaultTolerantHttpClient.newBuilder(\"cdn3-storage-manager\", httpExecutor)\n        .withCircuitBreaker(configuration.circuitBreakerConfigurationName())\n        .withRetry(configuration.retryConfigurationName(), retryExecutor)\n        .withConnectTimeout(Duration.ofSeconds(10))\n        .withVersion(HttpClient.Version.HTTP_2)\n        .withNumClients(configuration.numHttpClients())\n        .build();\n    this.sourceSchemes = configuration.sourceSchemes();\n  }\n\n  @Override\n  public int cdnNumber() {\n    return 3;\n  }\n\n  @Override\n  public CompletionStage<Void> copy(\n      final int sourceCdn,\n      final String sourceKey,\n      final int expectedSourceLength,\n      final MediaEncryptionParameters encryptionParameters,\n      final String destinationKey) {\n    final String sourceScheme = this.sourceSchemes.get(sourceCdn);\n    if (sourceScheme == null) {\n      return CompletableFuture.failedFuture(\n          new SourceObjectNotFoundException(\"Cdn3RemoteStorageManager cannot copy from \" + sourceCdn));\n    }\n    final String requestBody = new Cdn3CopyRequest(\n        encryptionParameters,\n        new Cdn3CopyRequest.SourceDescriptor(sourceScheme, sourceKey),\n        expectedSourceLength,\n        destinationKey).json();\n\n    final Timer.Sample sample = Timer.start();\n    final HttpRequest request = HttpRequest.newBuilder()\n        .PUT(HttpRequest.BodyPublishers.ofString(requestBody))\n        .uri(URI.create(copyUrl()))\n        .header(\"Content-Type\", \"application/json\")\n        .header(CLIENT_ID_HEADER, clientId)\n        .header(CLIENT_SECRET_HEADER, clientSecret)\n        .build();\n    return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenAccept(response -> {\n          Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,\n                  OPERATION_TAG_NAME, \"copy\",\n                  STATUS_TAG_NAME, Integer.toString(response.statusCode()))\n              .increment();\n          if (response.statusCode() == Response.Status.NOT_FOUND.getStatusCode()) {\n            throw ExceptionUtils.wrap(new SourceObjectNotFoundException());\n          } else if (response.statusCode() == Response.Status.CONFLICT.getStatusCode()) {\n            throw ExceptionUtils.wrap(new InvalidLengthException(response.body()));\n          } else if (!HttpUtils.isSuccessfulResponse(response.statusCode())) {\n            logger.info(\"Failed to copy via storage-manager {} {}\", response.statusCode(), response.body());\n            throw ExceptionUtils.wrap(new IOException(\"Failed to copy object: \" + response.statusCode()));\n          }\n        })\n        .whenComplete((ignored, ignoredException) ->\n            sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, \"copy\")));\n  }\n\n  /**\n   * Serialized copy request for cdn3 storage manager\n   */\n  record Cdn3CopyRequest(\n      String encryptionKey, String hmacKey,\n      SourceDescriptor source, int expectedSourceLength,\n      String dst) {\n\n    Cdn3CopyRequest(MediaEncryptionParameters parameters, SourceDescriptor source, int expectedSourceLength,\n        String dst) {\n      this(Base64.getEncoder().encodeToString(parameters.aesEncryptionKey().getEncoded()),\n          Base64.getEncoder().encodeToString(parameters.hmacSHA256Key().getEncoded()),\n          source, expectedSourceLength, dst);\n    }\n\n    record SourceDescriptor(String scheme, String key) {}\n\n    String json() {\n      try {\n        return SystemMapper.jsonMapper().writeValueAsString(this);\n      } catch (JsonProcessingException e) {\n        throw new IllegalStateException(\"Could not serialize copy request\", e);\n      }\n    }\n  }\n\n  @Override\n  public CompletionStage<ListResult> list(\n      final String prefix,\n      final Optional<String> cursor,\n      final long limit) {\n    final Timer.Sample sample = Timer.start();\n\n    final Map<String, String> queryParams = new HashMap<>();\n    queryParams.put(\"prefix\", prefix);\n    queryParams.put(\"limit\", Long.toString(limit));\n    cursor.ifPresent(s -> queryParams.put(\"cursor\", cursor.get()));\n\n    final HttpRequest request = HttpRequest.newBuilder().GET()\n        .uri(URI.create(\"%s%s\".formatted(listUrl(), HttpUtils.queryParamString(queryParams.entrySet()))))\n        .header(CLIENT_ID_HEADER, clientId)\n        .header(CLIENT_SECRET_HEADER, clientSecret)\n        .build();\n\n    return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())\n        .thenApply(response -> {\n          Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,\n                  OPERATION_TAG_NAME, \"list\",\n                  STATUS_TAG_NAME, Integer.toString(response.statusCode()))\n              .increment();\n          try {\n            return parseListResponse(response, prefix);\n          } catch (IOException e) {\n            throw ExceptionUtils.wrap(e);\n          }\n        })\n        .whenComplete((ignored, ignoredException) ->\n            sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, \"list\")));\n  }\n\n  /**\n   * Serialized list response from storage manager\n   */\n  record Cdn3ListResponse(@NotNull List<Entry> objects, @Nullable String cursor) {\n\n    record Entry(@NotNull String key, @NotNull long size) {}\n  }\n\n  private static ListResult parseListResponse(final HttpResponse<InputStream> httpListResponse, final String prefix)\n      throws IOException {\n    if (!HttpUtils.isSuccessfulResponse(httpListResponse.statusCode())) {\n      throw new IOException(\"Failed to list objects: \" + httpListResponse.statusCode());\n    }\n    final Cdn3ListResponse result = SystemMapper.jsonMapper()\n        .readValue(httpListResponse.body(), Cdn3ListResponse.class);\n\n    final List<ListResult.Entry> objects = new ArrayList<>(result.objects.size());\n    for (Cdn3ListResponse.Entry entry : result.objects) {\n      if (!entry.key().startsWith(prefix)) {\n        logger.error(\"unexpected listing result from cdn3 - entry {} does not contain requested prefix {}\",\n            entry.key(), prefix);\n        throw new IOException(\"prefix listing returned unexpected result\");\n      }\n      objects.add(new ListResult.Entry(entry.key().substring(prefix.length()), entry.size()));\n    }\n    return new ListResult(objects, Optional.ofNullable(result.cursor));\n  }\n\n\n  /**\n   * Serialized usage response from storage manager\n   */\n  record UsageResponse(@NotNull long numObjects, @NotNull long bytesUsed) {}\n\n\n  @Override\n  public CompletionStage<UsageInfo> calculateBytesUsed(final String prefix) {\n    final Timer.Sample sample = Timer.start();\n    final HttpRequest request = HttpRequest.newBuilder().GET()\n        .uri(URI.create(\"%s%s\".formatted(\n            usageUrl(),\n            HttpUtils.queryParamString(Map.of(\"prefix\", prefix).entrySet()))))\n        .header(CLIENT_ID_HEADER, clientId)\n        .header(CLIENT_SECRET_HEADER, clientSecret)\n        .build();\n    return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())\n        .thenApply(response -> {\n          Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,\n                  OPERATION_TAG_NAME, \"usage\",\n                  STATUS_TAG_NAME, Integer.toString(response.statusCode()))\n              .increment();\n          try {\n            return parseUsageResponse(response);\n          } catch (IOException e) {\n            throw ExceptionUtils.wrap(e);\n          }\n        })\n        .whenComplete((ignored, ignoredException) ->\n            sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, \"usage\")));\n  }\n\n  private static UsageInfo parseUsageResponse(final HttpResponse<InputStream> httpUsageResponse) throws IOException {\n    if (!HttpUtils.isSuccessfulResponse(httpUsageResponse.statusCode())) {\n      throw new IOException(\"Failed to retrieve usage: \" + httpUsageResponse.statusCode());\n    }\n    final UsageResponse response = SystemMapper.jsonMapper().readValue(httpUsageResponse.body(), UsageResponse.class);\n    return new UsageInfo(response.bytesUsed(), response.numObjects);\n  }\n\n  /**\n   * Serialized delete response from storage manager\n   */\n  record DeleteResponse(@NotNull long bytesDeleted) {}\n\n  public CompletionStage<Long> delete(final String key) {\n    final Timer.Sample sample = Timer.start();\n    final HttpRequest request = HttpRequest.newBuilder().DELETE()\n        .uri(URI.create(deleteUrl(key)))\n        .header(CLIENT_ID_HEADER, clientId)\n        .header(CLIENT_SECRET_HEADER, clientSecret)\n        .build();\n    return this.storageManagerHttpClient.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())\n        .thenApply(response -> {\n          Metrics.counter(STORAGE_MANAGER_STATUS_COUNTER_NAME,\n                  OPERATION_TAG_NAME, \"delete\",\n                  STATUS_TAG_NAME, Integer.toString(response.statusCode()))\n              .increment();\n          try {\n            long bytesDeleted = parseDeleteResponse(response);\n            Metrics.counter(OBJECT_REMOVED_ON_DELETE_COUNTER_NAME,\n                    \"removed\", Boolean.toString(bytesDeleted > 0))\n                .increment();\n            return bytesDeleted;\n          } catch (IOException e) {\n            throw ExceptionUtils.wrap(e);\n          }\n        })\n        .whenComplete((ignored, ignoredException) ->\n            sample.stop(Metrics.timer(STORAGE_MANAGER_TIMER_NAME, OPERATION_TAG_NAME, \"delete\")));\n  }\n\n  private long parseDeleteResponse(final HttpResponse<InputStream> httpDeleteResponse) throws IOException {\n    if (!HttpUtils.isSuccessfulResponse(httpDeleteResponse.statusCode())) {\n      throw new IOException(\"Failed to retrieve usage: \" + httpDeleteResponse.statusCode());\n    }\n    return SystemMapper.jsonMapper().readValue(httpDeleteResponse.body(), DeleteResponse.class).bytesDeleted();\n  }\n\n  private String deleteUrl(final String key) {\n    return \"%s/%s/%s\".formatted(storageManagerBaseUrl, Cdn3BackupCredentialGenerator.CDN_PATH, key);\n  }\n\n  private String usageUrl() {\n    return \"%s/usage\".formatted(storageManagerBaseUrl);\n  }\n\n  private String listUrl() {\n    return \"%s/%s/\".formatted(storageManagerBaseUrl, Cdn3BackupCredentialGenerator.CDN_PATH);\n  }\n\n  private String copyUrl() {\n    return \"%s/copy\".formatted(storageManagerBaseUrl);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/CopyParameters.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\n/**\n * Descriptor for a single copy-and-encrypt operation\n *\n * @param sourceCdn            The cdn of the object to copy\n * @param sourceKey            The mediaId within the cdn of the object to copy\n * @param sourceLength         The length of the object to copy\n * @param encryptionParameters Encryption parameters to double encrypt the object\n * @param destinationMediaId   The mediaId of the destination object\n */\npublic record CopyParameters(\n    int sourceCdn,\n    String sourceKey,\n    int sourceLength,\n    MediaEncryptionParameters encryptionParameters,\n    byte[] destinationMediaId) {\n\n  /**\n   * @return The size of the double-encrypted destination object after it is copied\n   */\n  long destinationObjectSize() {\n    return encryptionParameters().outputSize(sourceLength());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/CopyResult.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\n\nimport javax.annotation.Nullable;\nimport java.util.Optional;\n\n\n/**\n * The result of a copy operation\n *\n * @param outcome Whether the copy was a success\n * @param mediaId The destination mediaId\n * @param cdn On success, the destination cdn\n */\npublic record CopyResult(Outcome outcome, byte[] mediaId, @Nullable Integer cdn) {\n\n  public enum Outcome {\n    SUCCESS,\n    SOURCE_NOT_FOUND,\n    SOURCE_WRONG_LENGTH,\n    OUT_OF_QUOTA\n  }\n\n  /**\n   * Map an exception returned by {@link RemoteStorageManager#copy} to CopyResult with the appropriate outcome.\n   *\n   * @param throwable result of a failed copy operation\n   * @param key the copy destination mediaId\n   * @return The appropriate CopyResult, or empty if the exception does not match to an Outcome.\n   */\n  static Optional<CopyResult> fromCopyError(final Throwable throwable, final byte[] key) {\n    final Throwable unwrapped = ExceptionUtils.unwrap(throwable);\n    if (unwrapped instanceof SourceObjectNotFoundException) {\n      return Optional.of(new CopyResult(Outcome.SOURCE_NOT_FOUND, key, null));\n    } else if (unwrapped instanceof InvalidLengthException) {\n      return Optional.of(new CopyResult(Outcome.SOURCE_WRONG_LENGTH, key, null));\n    } else {\n      return Optional.empty();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/ExpiredBackup.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\nimport java.time.Instant;\n\n/**\n * Represents a backup that requires some or all of its content to be deleted\n *\n * @param hashedBackupId The hashedBackupId that owns this content\n * @param expirationType What triggered the expiration\n * @param lastRefresh    The timestamp of the last time the backup user was seen\n * @param prefixToDelete The prefix on the CDN associated with this backup that should be deleted\n */\npublic record ExpiredBackup(\n    byte[] hashedBackupId,\n    ExpirationType expirationType,\n    Instant lastRefresh,\n    String prefixToDelete) {\n\n  public enum ExpirationType {\n    // The prefixToDelete expiration is for the entire backup\n    ALL,\n    // The prefixToDelete is for the media associated with the backup\n    MEDIA,\n    // The prefixToDelete is from a prior expiration attempt\n    GARBAGE_COLLECTION\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/InvalidLengthException.java",
    "content": "package org.whispersystems.textsecuregcm.backup;\n\nimport java.io.IOException;\n\npublic class InvalidLengthException extends IOException {\n\n  public InvalidLengthException(String s) {\n    super(s);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/MediaEncryptionParameters.java",
    "content": "package org.whispersystems.textsecuregcm.backup;\n\nimport javax.crypto.spec.SecretKeySpec;\n\npublic record MediaEncryptionParameters(\n    SecretKeySpec aesEncryptionKey,\n    SecretKeySpec hmacSHA256Key) {\n\n  public MediaEncryptionParameters(byte[] encryptionKey, byte[] macKey) {\n    this(\n        new SecretKeySpec(encryptionKey, \"AES\"),\n        new SecretKeySpec(macKey, \"HmacSHA256\"));\n  }\n\n  public int outputSize(final int inputSize) {\n    // AES-256 has 16-byte block size, and always adds a block if the plaintext is a multiple of the block size\n    final int numBlocks = (inputSize + 16) / 16;\n    // 16-byte IV will be generated and prepended to the ciphertext\n    // IV + AES-256 encrypted data + HmacSHA256\n    return 16 + (numBlocks * 16) + 32;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/RemoteStorageManager.java",
    "content": "package org.whispersystems.textsecuregcm.backup;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.CompletionStage;\n\n/**\n * Handles management operations over a external cdn storage system.\n */\npublic interface RemoteStorageManager {\n\n  /**\n   * @return The cdn number that this RemoteStorageManager manages\n   */\n  int cdnNumber();\n\n  /**\n   * Copy and the object from a remote source into the backup, adding an additional layer of encryption\n   *\n   * @param sourceCdn            The cdn number where the source attachment is stored\n   * @param sourceKey            The key of the source attachment within the attachment cdn\n   * @param expectedSourceLength The length of the source object, should match the content-length of the object returned\n   *                             from the sourceUri.\n   * @param encryptionParameters The encryption keys that should be used to apply an additional layer of encryption to\n   *                             the object\n   * @param dstKey               The key within the backup cdn where the copied object will be written\n   * @return A stage that completes successfully when the source has been successfully re-encrypted and copied into\n   * uploadDescriptor. The returned CompletionStage can be completed exceptionally with the following exceptions.\n   * <ul>\n   *  <li> {@link InvalidLengthException} If the expectedSourceLength does not match the length of the sourceUri </li>\n   *  <li> {@link SourceObjectNotFoundException} If the no object at sourceUri is found </li>\n   *  <li> {@link java.io.IOException} If there was a generic IO issue </li>\n   * </ul>\n   */\n  CompletionStage<Void> copy(\n      int sourceCdn,\n      String sourceKey,\n      int expectedSourceLength,\n      MediaEncryptionParameters encryptionParameters,\n      String dstKey);\n\n  /**\n   * Result of a {@link #list} operation\n   *\n   * @param objects An {@link Entry} for each object returned by the list request\n   * @param cursor  An opaque string that can be used to resume listing from where a previous request left off, empty if\n   *                the request reached the end of the list of matching objects.\n   */\n  record ListResult(List<Entry> objects, Optional<String> cursor) {\n\n    /**\n     * An entry representing a remote stored object under a prefix\n     *\n     * @param key    The name of the object with the prefix removed\n     * @param length The length of the object in bytes\n     */\n    record Entry(String key, long length) {}\n  }\n\n  /**\n   * List objects on the remote cdn.\n   *\n   * @param prefix The prefix of the objects to list\n   * @param cursor The cursor returned by a previous call to list, or empty if starting from the first object with the\n   *               provided prefix\n   * @param limit  The maximum number of items to return in the list\n   * @return A {@link ListResult} of objects that match the prefix.\n   */\n  CompletionStage<ListResult> list(final String prefix, final Optional<String> cursor, final long limit);\n\n  /**\n   * Calculate the total number of bytes stored by objects with the provided prefix\n   *\n   * @param prefix The prefix of the objects to sum\n   * @return The number of bytes used\n   */\n  CompletionStage<UsageInfo> calculateBytesUsed(final String prefix);\n\n  /**\n   * Delete the specified object.\n   *\n   * @param key the key of the stored object to delete.\n   * @return the number of bytes freed by the deletion operation\n   */\n  CompletionStage<Long> delete(final String key);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/SecureValueRecoveryBCredentialsGeneratorFactory.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport java.time.Clock;\n\npublic class SecureValueRecoveryBCredentialsGeneratorFactory {\n  private SecureValueRecoveryBCredentialsGeneratorFactory() {}\n\n\n  @VisibleForTesting\n  static ExternalServiceCredentialsGenerator svrbCredentialsGenerator(\n      final SecureValueRecoveryConfiguration cfg,\n      final Clock clock) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .withUserDerivationKey(cfg.userIdTokenSharedSecret().value())\n        .prependUsername(false)\n        .withDerivedUsernameTruncateLength(16)\n        .withClock(clock)\n        .build();\n  }\n\n  public static ExternalServiceCredentialsGenerator svrbCredentialsGenerator(final SecureValueRecoveryConfiguration cfg) {\n    return svrbCredentialsGenerator(cfg, Clock.systemUTC());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/SourceObjectNotFoundException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport java.io.IOException;\n\npublic class SourceObjectNotFoundException extends IOException {\n  public SourceObjectNotFoundException() {\n    super();\n  }\n  public SourceObjectNotFoundException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/StoredBackupAttributes.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\nimport java.time.Instant;\n\n/**\n * Attributes stored in the backups table for a single backup id\n *\n * @param hashedBackupId   The hashed backup-id of this entry\n * @param backupDir        The cdn backupDir of this entry\n * @param mediaDir         The cdn mediaDir (within the backupDir) of this entry\n * @param lastRefresh      The last time the record was updated with a messages or media tier credential\n * @param lastMediaRefresh The last time the record was updated with a media tier credential\n * @param bytesUsed        The number of media bytes used by the backup\n * @param numObjects       The number of media objects used byt the backup\n */\npublic record StoredBackupAttributes(\n    byte[] hashedBackupId,\n    String backupDir,\n    String mediaDir,\n    Instant lastRefresh,\n    Instant lastMediaRefresh,\n    long bytesUsed,\n    long numObjects) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/backup/UsageInfo.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.backup;\n\npublic record UsageInfo(long bytesUsed, long numObjects) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/badges/BadgeTranslator.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.badges;\n\nimport java.util.List;\nimport java.util.Locale;\nimport org.whispersystems.textsecuregcm.entities.Badge;\n\npublic interface BadgeTranslator {\n  Badge translate(List<Locale> acceptableLanguages, String badgeId);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverter.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.badges;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.ResourceBundle;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport org.signal.i18n.HeaderControlledResourceBundleLookup;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.entities.SelfBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\n\npublic class ConfiguredProfileBadgeConverter implements ProfileBadgeConverter, BadgeTranslator {\n\n  @VisibleForTesting\n  static final String BASE_NAME = \"org.signal.badges.Badges\";\n\n  private final Clock clock;\n  private final Map<String, BadgeConfiguration> knownBadges;\n  private final List<String> badgeIdsEnabledForAll;\n  private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup;\n\n  public ConfiguredProfileBadgeConverter(\n      final Clock clock,\n      final BadgesConfiguration badgesConfiguration,\n      final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) {\n    this.clock = clock;\n    this.knownBadges = badgesConfiguration.getBadges().stream()\n        .collect(Collectors.toMap(BadgeConfiguration::getId, Function.identity()));\n    this.badgeIdsEnabledForAll = badgesConfiguration.getBadgeIdsEnabledForAll();\n    this.headerControlledResourceBundleLookup = headerControlledResourceBundleLookup;\n  }\n\n  @Override\n  public Badge translate(final List<Locale> acceptableLanguages, final String badgeId) {\n    final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME,\n        acceptableLanguages);\n    final BadgeConfiguration configuration = knownBadges.get(badgeId);\n    return newBadge(\n        false,\n        configuration.getId(),\n        configuration.getCategory(),\n        resourceBundle.getString(configuration.getId() + \"_name\"),\n        resourceBundle.getString(configuration.getId() + \"_description\"),\n        configuration.getSprites(),\n        configuration.getSvg(),\n        configuration.getSvgs(),\n        null,\n        false);\n  }\n\n  @Override\n  public List<Badge> convert(\n      final List<Locale> acceptableLanguages,\n      final List<AccountBadge> accountBadges,\n      final boolean isSelf) {\n    if (accountBadges.isEmpty() && badgeIdsEnabledForAll.isEmpty()) {\n      return List.of();\n    }\n\n    final Instant now = clock.instant();\n    final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME,\n        acceptableLanguages);\n    List<Badge> badges = accountBadges.stream()\n        .filter(accountBadge -> (isSelf || accountBadge.visible())\n            && now.isBefore(accountBadge.expiration())\n            && knownBadges.containsKey(accountBadge.id()))\n        .map(accountBadge -> {\n          BadgeConfiguration configuration = knownBadges.get(accountBadge.id());\n          return newBadge(\n              isSelf,\n              accountBadge.id(),\n              configuration.getCategory(),\n              resourceBundle.getString(accountBadge.id() + \"_name\"),\n              resourceBundle.getString(accountBadge.id() + \"_description\"),\n              configuration.getSprites(),\n              configuration.getSvg(),\n              configuration.getSvgs(),\n              accountBadge.expiration(),\n              accountBadge.visible());\n        })\n        .collect(Collectors.toCollection(ArrayList::new));\n    badges.addAll(badgeIdsEnabledForAll.stream().filter(knownBadges::containsKey).map(id -> {\n      BadgeConfiguration configuration = knownBadges.get(id);\n      return newBadge(\n          isSelf,\n          id,\n          configuration.getCategory(),\n          resourceBundle.getString(id + \"_name\"),\n          resourceBundle.getString(id + \"_description\"),\n          configuration.getSprites(),\n          configuration.getSvg(),\n          configuration.getSvgs(),\n          now.plus(Duration.ofDays(1)),\n          true);\n    }).collect(Collectors.toList()));\n    return badges;\n  }\n\n  private Badge newBadge(\n      final boolean isSelf,\n      final String id,\n      final String category,\n      final String name,\n      final String description,\n      final List<String> sprites,\n      final String svg,\n      final List<BadgeSvg> svgs,\n      final Instant expiration,\n      final boolean visible) {\n    if (isSelf) {\n      return new SelfBadge(id, category, name, description, sprites, svg, svgs, expiration, visible);\n    } else {\n      return new Badge(id, category, name, description, sprites, svg, svgs);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/badges/LevelTranslator.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.badges;\n\nimport java.util.List;\nimport java.util.Locale;\n\npublic interface LevelTranslator {\n  String translate(List<Locale> acceptableLanguages, String badgeId);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/badges/ProfileBadgeConverter.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.badges;\n\nimport java.util.List;\nimport java.util.Locale;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\n\npublic interface ProfileBadgeConverter {\n\n  /**\n   * Converts the {@link AccountBadge}s for an account into the objects\n   * that can be returned on a profile fetch.\n   */\n  List<Badge> convert(List<Locale> acceptableLanguages, List<AccountBadge> accountBadges, boolean isSelf);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/captcha/Action.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport java.util.Arrays;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\npublic enum Action {\n  CHALLENGE(\"challenge\"),\n  REGISTRATION(\"registration\");\n\n  private final String actionName;\n\n  Action(String actionName) {\n    this.actionName = actionName;\n  }\n\n  public String getActionName() {\n    return actionName;\n  }\n\n  private static final Map<String, Action> ENUM_MAP = Arrays\n      .stream(Action.values())\n      .collect(Collectors.toMap(\n          a -> a.actionName,\n          Function.identity()));\n  @JsonCreator\n  public static Action fromString(String key) {\n    return ENUM_MAP.get(key.toLowerCase(Locale.ROOT).strip());\n  }\n\n  static Optional<Action> parse(final String action) {\n    return Optional.ofNullable(fromString(action));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/captcha/AssessmentResult.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport java.util.Objects;\nimport java.util.Optional;\n\npublic class AssessmentResult {\n\n  private final boolean solved;\n  private final float actualScore;\n  private final float defaultScoreThreshold;\n  private final String scoreString;\n\n  /**\n   * A captcha assessment\n   *\n   * @param solved if false, the captcha was not successfully completed\n   * @param actualScore float representation of the risk level from [0, 1.0], with 1.0 being the least risky\n   * @param defaultScoreThreshold  the score threshold which the score will be evaluated against by default\n   * @param scoreString a quantized string representation of the risk level, suitable for use in metrics\n   */\n  private AssessmentResult(boolean solved, float actualScore, float defaultScoreThreshold, final String scoreString) {\n    this.solved = solved;\n    this.actualScore = actualScore;\n    this.defaultScoreThreshold = defaultScoreThreshold;\n    this.scoreString = scoreString;\n  }\n\n  /**\n   * Construct an {@link AssessmentResult} from a captcha evaluation score\n   *\n   * @param actualScore the score\n   * @param defaultScoreThreshold the threshold to compare the score against by default\n   */\n  public static AssessmentResult fromScore(float actualScore, float defaultScoreThreshold) {\n    if (actualScore < 0 || actualScore > 1.0 || defaultScoreThreshold < 0 || defaultScoreThreshold > 1.0) {\n      throw new IllegalArgumentException(\"invalid captcha score\");\n    }\n    return new AssessmentResult(true, actualScore, defaultScoreThreshold, AssessmentResult.scoreString(actualScore));\n  }\n\n  /**\n   * Construct a captcha assessment that will always be invalid\n   */\n  public static AssessmentResult invalid() {\n    return new AssessmentResult(false, 0.0f, 0.0f, \"\");\n  }\n\n  /**\n   * Construct a captcha assessment that will always be valid\n   */\n  public static AssessmentResult alwaysValid() {\n    return new AssessmentResult(true, 1.0f, 0.0f, \"1.0\");\n  }\n\n  /**\n   * Check if the captcha assessment should be accepted using the default score threshold\n   *\n   * @return true if this assessment should be accepted under the default score threshold\n   */\n  public boolean isValid() {\n    return isValid(Optional.empty());\n  }\n\n  /**\n   * Check if the captcha assessment should be accepted\n   *\n   * @param scoreThreshold the minimum score the assessment requires to pass, uses default if empty\n   * @return true if the assessment scored higher than the provided scoreThreshold\n   */\n  public boolean isValid(Optional<Float> scoreThreshold) {\n    if (!solved) {\n      return false;\n    }\n    // Since HCaptcha scores are truncated to 2 decimal places, we can multiply by 100 and round to the nearest int for\n    // comparison instead of floating point comparisons\n    return normalizedIntScore(this.actualScore) >= normalizedIntScore(scoreThreshold.orElse(this.defaultScoreThreshold));\n  }\n\n  private static int normalizedIntScore(final float score) {\n    return Math.round(score * 100);\n  }\n\n  public String getScoreString() {\n    return scoreString;\n  }\n\n  public float getScore() {\n    return this.actualScore;\n  }\n\n\n  /**\n   * Map a captcha score in [0.0, 1.0] to a low cardinality discrete space in [0, 100] suitable for use in metrics\n   */\n  private static String scoreString(final float score) {\n    final int x = Math.round(score * 10); // [0, 10]\n    return Integer.toString(x * 10); // [0, 100] in increments of 10\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o)\n      return true;\n    if (o == null || getClass() != o.getClass())\n      return false;\n    AssessmentResult that = (AssessmentResult) o;\n    return solved == that.solved && Float.compare(that.actualScore, actualScore) == 0\n        && Float.compare(that.defaultScoreThreshold, defaultScoreThreshold) == 0 && Objects.equals(scoreString,\n        that.scoreString);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(solved, actualScore, defaultScoreThreshold, scoreString);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaChecker.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.ws.rs.BadRequestException;\nimport java.io.IOException;\nimport java.util.Locale;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.function.Function;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class CaptchaChecker {\n  private static final Logger logger = LoggerFactory.getLogger(CaptchaChecker.class);\n  private static final String INVALID_SITEKEY_COUNTER_NAME = name(CaptchaChecker.class, \"invalidSiteKey\");\n  private static final String ASSESSMENTS_COUNTER_NAME = name(CaptchaChecker.class, \"assessments\");\n  private static final String INVALID_ACTION_COUNTER_NAME = name(CaptchaChecker.class, \"invalidActions\");\n\n  @VisibleForTesting\n  static final String SEPARATOR = \".\";\n\n  private static final String SHORT_SUFFIX = \"-short\";\n\n  private final ShortCodeExpander shortCodeExpander;\n  private final Function<String, CaptchaClient> captchaClientSupplier;\n\n  public CaptchaChecker(\n      final ShortCodeExpander shortCodeRetriever,\n      final Function<String, CaptchaClient> captchaClientSupplier) {\n    this.shortCodeExpander = shortCodeRetriever;\n    this.captchaClientSupplier = captchaClientSupplier;\n  }\n\n\n  /**\n   * Check if a solved captcha should be accepted\n   *\n   * @param maybeAci       optional account UUID of the user solving the captcha\n   * @param expectedAction the {@link Action} for which this captcha solution is intended\n   * @param input          expected to contain a prefix indicating the captcha scheme, sitekey, token, and action. The\n   *                       expected format is {@code version-prefix.sitekey.action.token}\n   * @param ip             IP of the solver\n   * @param userAgent      User-Agent of the solver\n   * @return An {@link AssessmentResult} indicating whether the solution should be accepted, and a score that can be\n   * used for metrics\n   * @throws IOException         if there is an error validating the captcha with the underlying service\n   * @throws BadRequestException if input is not in the expected format\n   */\n  public AssessmentResult verify(\n      final Optional<UUID> maybeAci,\n      final Action expectedAction,\n      final String input,\n      final String ip,\n      final String userAgent) throws IOException {\n    final String[] parts = input.split(\"\\\\\" + SEPARATOR, 4);\n\n    // we allow missing actions, if we're missing 1 part, assume it's the action\n    if (parts.length < 4) {\n      throw new BadRequestException(\"too few parts\");\n    }\n\n    final String prefix = parts[0];\n    final String siteKey = parts[1].toLowerCase(Locale.ROOT).strip();\n    final String action = parts[2];\n    String token = parts[3];\n\n    String provider = prefix;\n    if (prefix.endsWith(SHORT_SUFFIX)) {\n      // This is a \"short\" solution that points to the actual solution. We need to fetch the\n      // full solution before proceeding\n      provider = prefix.substring(0, prefix.length() - SHORT_SUFFIX.length());\n      token = shortCodeExpander.retrieve(token).orElseThrow(() -> new BadRequestException(\"invalid shortcode\"));\n    }\n\n    final CaptchaClient client = this.captchaClientSupplier.apply(provider);\n    if (client == null) {\n      throw new BadRequestException(\"invalid captcha scheme\");\n    }\n\n    final Action parsedAction = Action.parse(action)\n        .orElseThrow(() -> {\n          Metrics.counter(INVALID_ACTION_COUNTER_NAME, \"action\", action).increment();\n          return new BadRequestException(\"invalid captcha action\");\n        });\n\n    if (!parsedAction.equals(expectedAction)) {\n      Metrics.counter(INVALID_ACTION_COUNTER_NAME, \"action\", action).increment();\n      throw new BadRequestException(\"invalid captcha action\");\n    }\n\n    final Set<String> allowedSiteKeys = client.validSiteKeys(parsedAction);\n    if (!allowedSiteKeys.contains(siteKey)) {\n      logger.debug(\"invalid site-key {}, action={}, token={}\", siteKey, action, token);\n      Metrics.counter(INVALID_SITEKEY_COUNTER_NAME, \"action\", action).increment();\n      throw new BadRequestException(\"invalid captcha site-key\");\n    }\n\n    final AssessmentResult result = client.verify(maybeAci, siteKey, parsedAction, token, ip, userAgent);\n    Metrics.counter(ASSESSMENTS_COUNTER_NAME,\n            \"action\", action,\n            \"score\", result.getScoreString(),\n            \"provider\", provider)\n        .increment();\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/captcha/CaptchaClient.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport java.io.IOException;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\n\npublic interface CaptchaClient {\n\n\n  /**\n   * @return the identifying captcha scheme that this CaptchaClient handles\n   */\n  String scheme();\n\n  /**\n   * @param action the action to retrieve site keys for\n   * @return siteKeys this client is willing to accept\n   */\n  Set<String> validSiteKeys(final Action action);\n\n  /**\n   * Verify a provided captcha solution\n   *\n   * @param maybeAci  optional account service identifier of the user\n   * @param siteKey   identifying string for the captcha service\n   * @param action    an action indicating the purpose of the captcha\n   * @param token     the captcha solution that will be verified\n   * @param ip        the ip of the captcha solver\n   * @param userAgent the User-Agent string of the captcha solver\n   * @return An {@link AssessmentResult} indicating whether the solution should be accepted\n   * @throws IOException if the underlying captcha provider returns an error\n   */\n  AssessmentResult verify(\n      final Optional<UUID> maybeAci,\n      final String siteKey,\n      final Action action,\n      final String token,\n      final String ip,\n      final String userAgent) throws IOException;\n\n  static CaptchaClient noop() {\n    return new CaptchaClient() {\n      @Override\n      public String scheme() {\n        return \"noop\";\n      }\n\n      @Override\n      public Set<String> validSiteKeys(final Action action) {\n        return Set.of(\"noop\");\n      }\n\n      @Override\n      public AssessmentResult verify(final Optional<UUID> maybeAci, final String siteKey, final Action action, final String token, final String ip,\n          final String userAgent) throws IOException {\n        return AssessmentResult.alwaysValid();\n      }\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/captcha/RegistrationCaptchaManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport java.io.IOException;\nimport java.util.Optional;\nimport java.util.UUID;\n\npublic class RegistrationCaptchaManager {\n\n  private final CaptchaChecker captchaChecker;\n\n  public RegistrationCaptchaManager(final CaptchaChecker captchaChecker) {\n    this.captchaChecker = captchaChecker;\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  public Optional<AssessmentResult> assessCaptcha(final Optional<UUID> aci, final Optional<String> captcha, final String sourceHost, final String userAgent)\n      throws IOException {\n    return captcha.isPresent()\n        ? Optional.of(captchaChecker.verify(aci, Action.REGISTRATION, captcha.get(), sourceHost, userAgent))\n        : Optional.empty();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/captcha/ShortCodeExpander.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport io.micrometer.core.instrument.Metrics;\nimport org.apache.http.HttpStatus;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.URLEncoder;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Optional;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\npublic class ShortCodeExpander {\n  private static final String EXPAND_COUNTER_NAME = name(ShortCodeExpander.class, \"expand\");\n\n  private final HttpClient client;\n  private final URI shortenerHost;\n\n  public ShortCodeExpander(final HttpClient client, final String shortenerHost) {\n    this.client = client;\n    this.shortenerHost = URI.create(shortenerHost);\n  }\n\n  public Optional<String> retrieve(final String shortCode) throws IOException {\n    final URI uri = shortenerHost.resolve(URLEncoder.encode(shortCode, StandardCharsets.UTF_8));\n    final HttpRequest request = HttpRequest.newBuilder().uri(uri).GET().build();\n\n    try {\n      final HttpResponse<String> response = this.client.send(request, HttpResponse.BodyHandlers.ofString());\n      Metrics.counter(EXPAND_COUNTER_NAME, \"responseCode\", Integer.toString(response.statusCode())).increment();\n      return switch (response.statusCode()) {\n        case HttpStatus.SC_OK -> Optional.of(response.body());\n        case HttpStatus.SC_NOT_FOUND -> Optional.empty();\n        default -> throw new IOException(\"Failed to look up shortcode\");\n      };\n    } catch (InterruptedException e) {\n      throw new IOException(e);\n    }\n  }\n\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/AccountsTableConfiguration.java",
    "content": "package org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotBlank;\nimport org.whispersystems.textsecuregcm.configuration.DynamoDbTables.Table;\n\npublic class AccountsTableConfiguration extends Table {\n\n  private final String phoneNumberTableName;\n  private final String phoneNumberIdentifierTableName;\n  private final String usernamesTableName;\n  private final String usedLinkDeviceTokensTableName;\n\n  @JsonCreator\n  public AccountsTableConfiguration(\n      @JsonProperty(\"tableName\") final String tableName,\n      @JsonProperty(\"phoneNumberTableName\") final String phoneNumberTableName,\n      @JsonProperty(\"phoneNumberIdentifierTableName\") final String phoneNumberIdentifierTableName,\n      @JsonProperty(\"usernamesTableName\") final String usernamesTableName,\n      @JsonProperty(\"usedLinkDeviceTokensTableName\") final String usedLinkDeviceTokensTableName) {\n\n    super(tableName);\n\n    this.phoneNumberTableName = phoneNumberTableName;\n    this.phoneNumberIdentifierTableName = phoneNumberIdentifierTableName;\n    this.usernamesTableName = usernamesTableName;\n    this.usedLinkDeviceTokensTableName = usedLinkDeviceTokensTableName;\n  }\n\n  @NotBlank\n  public String getPhoneNumberTableName() {\n    return phoneNumberTableName;\n  }\n\n  @NotBlank\n  public String getPhoneNumberIdentifierTableName() {\n    return phoneNumberIdentifierTableName;\n  }\n\n  @NotBlank\n  public String getUsernamesTableName() {\n    return usernamesTableName;\n  }\n\n  @NotBlank\n  public String getUsedLinkDeviceTokensTableName() {\n    return usedLinkDeviceTokensTableName;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/ApnConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\n\n\npublic record ApnConfiguration(@NotNull SecretString teamId,\n                               @NotNull SecretString keyId,\n                               @NotNull SecretString signingKey,\n                               @NotBlank String bundleId,\n                               boolean sandbox) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleAppStoreConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.apple.itunes.storekit.model.Environment;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport javax.annotation.Nullable;\n\n/**\n * @param env                 The ios environment to use, typically SANDBOX or PRODUCTION\n * @param bundleId            The bundleId of the app\n * @param appAppleId          The integer id of the app\n * @param issuerId            The issuerId for the keys:\n *                            https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests\n * @param keyId               The keyId for encodedKey\n * @param encodedKey          A private key with the \"In-App Purchase\" key type\n * @param subscriptionGroupId The subscription group for in-app purchases\n * @param productIdToLevel    A map of productIds offered in the product catalog to their corresponding numeric\n *                            subscription levels\n * @param appleRootCerts      Apple root certificates to verify signed API responses, encoded as base64 strings:\n *                            https://www.apple.com/certificateauthority/\n * @param retryConfigurationName The name of the retry configuration to use in the App Store client; if `null`, uses the\n *                               global default configuration.\n */\npublic record AppleAppStoreConfiguration(\n    @NotNull Environment env,\n    @NotBlank String bundleId,\n    @NotNull Long appAppleId,\n    @NotBlank String issuerId,\n    @NotBlank String keyId,\n    @NotNull SecretString encodedKey,\n    @NotBlank String subscriptionGroupId,\n    @NotNull Map<String, Long> productIdToLevel,\n    @NotNull List<@NotBlank String> appleRootCerts,\n    @Nullable String retryConfigurationName) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/AppleDeviceCheckConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport java.time.Duration;\n\n/**\n * Configuration for Apple DeviceCheck\n *\n * @param production               Whether this is for production or sandbox attestations\n * @param teamId                   The teamId to validate attestations against\n * @param bundleId                 The bundleId to validation attestations against\n */\npublic record AppleDeviceCheckConfiguration(\n    boolean production,\n    String teamId,\n    String bundleId) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/AwsCredentialsProviderFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.jackson.Discoverable;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = DefaultAwsCredentialsFactory.class)\npublic interface AwsCredentialsProviderFactory extends Discoverable {\n\n  AwsCredentialsProvider build();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgeConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic class BadgeConfiguration {\n  public static final String CATEGORY_TESTING = \"testing\";\n\n  private final String id;\n  private final String category;\n  private final List<String> sprites;\n  private final String svg;\n  private final List<BadgeSvg> svgs;\n\n  @JsonCreator\n  public BadgeConfiguration(\n      @JsonProperty(\"id\") final String id,\n      @JsonProperty(\"category\") final String category,\n      @JsonProperty(\"sprites\") final List<String> sprites,\n      @JsonProperty(\"svg\") final String svg,\n      @JsonProperty(\"svgs\") final List<BadgeSvg> svgs) {\n    this.id = id;\n    this.category = category;\n    this.sprites = sprites;\n    this.svg = svg;\n    this.svgs = svgs;\n  }\n\n  @NotEmpty\n  public String getId() {\n    return id;\n  }\n\n  @NotEmpty\n  public String getCategory() {\n    return category;\n  }\n\n  @NotNull\n  @ExactlySize(6)\n  public List<String> getSprites() {\n    return sprites;\n  }\n\n  @NotEmpty\n  public String getSvg() {\n    return svg;\n  }\n\n  @NotNull\n  public List<BadgeSvg> getSvgs() {\n    return svgs;\n  }\n\n  public boolean isTestBadge() {\n    return CATEGORY_TESTING.equals(category);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/BadgesConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonSetter;\nimport com.fasterxml.jackson.annotation.Nulls;\nimport io.dropwizard.validation.ValidationMethod;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class BadgesConfiguration {\n  private final List<BadgeConfiguration> badges;\n  private final List<String> badgeIdsEnabledForAll;\n  private final Map<Long, String> receiptLevels;\n\n  @JsonCreator\n  public BadgesConfiguration(\n      @JsonProperty(\"badges\") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<BadgeConfiguration> badges,\n      @JsonProperty(\"badgeIdsEnabledForAll\") @JsonSetter(nulls = Nulls.AS_EMPTY) final List<String> badgeIdsEnabledForAll,\n      @JsonProperty(\"receiptLevels\") @JsonSetter(nulls = Nulls.AS_EMPTY) final Map<Long, String> receiptLevels) {\n    this.badges = Objects.requireNonNull(badges);\n    this.badgeIdsEnabledForAll = Objects.requireNonNull(badgeIdsEnabledForAll);\n    this.receiptLevels = Objects.requireNonNull(receiptLevels);\n  }\n\n  @Valid\n  @NotNull\n  public List<BadgeConfiguration> getBadges() {\n    return badges;\n  }\n\n  @Valid\n  @NotNull\n  public List<String> getBadgeIdsEnabledForAll() {\n    return badgeIdsEnabledForAll;\n  }\n\n  @Valid\n  @NotNull\n  public Map<Long, String> getReceiptLevels() {\n    return receiptLevels;\n  }\n\n  @JsonIgnore\n  @ValidationMethod(message = \"contains receipt level mappings that are not configured badges\")\n  public boolean isAllReceiptLevelsConfigured() {\n    final Set<String> badgeNames = badges.stream().map(BadgeConfiguration::getId).collect(Collectors.toSet());\n    return badgeNames.containsAll(receiptLevels.values());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/BraintreeConfiguration.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Map;\nimport java.util.Set;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;\n\n/**\n * @param merchantId          the Braintree merchant ID\n * @param publicKey           the Braintree API public key\n * @param privateKey          the Braintree API private key\n * @param environment         the Braintree environment (\"production\" or \"sandbox\")\n * @param supportedCurrenciesByPaymentMethod the set of supported currencies\n * @param graphqlUrl          the Braintree GraphQL URl to use (this must match the environment)\n * @param merchantAccounts    merchant account within the merchant for processing individual currencies\n * @param circuitBreakerConfigurationName the name of the circuit breaker configuration for the breaker used by the\n *                                        GraphQL HTTP client; if `null`, uses the global default configuration\n */\npublic record BraintreeConfiguration(@NotBlank String merchantId,\n                                     @NotNull SecretString publicKey,\n                                     @NotNull SecretString privateKey,\n                                     @NotBlank String environment,\n                                     @Valid @NotEmpty Map<PaymentMethod, Set<@NotBlank String>> supportedCurrenciesByPaymentMethod,\n                                     @NotBlank String graphqlUrl,\n                                     @NotEmpty Map<String, String> merchantAccounts,\n                                     @Nullable String circuitBreakerConfigurationName,\n                                     @Valid @NotNull PubSubPublisherFactory pubSubPublisher) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/CallQualitySurveyConfiguration.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\n\npublic record CallQualitySurveyConfiguration (@Valid @NotNull PubSubPublisherFactory pubSubPublisher) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/Cdn3StorageManagerConfiguration.java",
    "content": "package org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Collections;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport javax.annotation.Nullable;\n\n/**\n * Configuration for the cdn3 storage manager\n *\n * @param baseUri        The base URI of the storage manager\n * @param clientId       The cloudflare client ID to use to authenticate to the storage manager\n * @param clientSecret   The cloudflare client secret to use to authenticate to the storage manager\n * @param sourceSchemes  A map of cdn id to a retrieval scheme understood by the storage-manager. This is used by the\n *                       storage-manager when copying to determine how to read a source object. Current schemes are\n *                       'gcs' and 'r2'\n * @param numHttpClients The number http clients to use with the storage-manager to support request striping\n * @param circuitBreakerConfigurationName The name of a circuit breaker configuration for the storage-manager http\n *                                        client; if `null`, uses the global default configuration\n * @param retryConfigurationName          The name of a retry configuration for the storage-manager http client; if\n *                                        `null`, uses the global default configuration\n */\npublic record Cdn3StorageManagerConfiguration(\n    @NotNull String baseUri,\n    @NotNull String clientId,\n    @NotNull SecretString clientSecret,\n    @NotNull Map<Integer, String> sourceSchemes,\n    @NotNull Integer numHttpClients,\n    @Nullable String circuitBreakerConfigurationName,\n    @Nullable String retryConfigurationName) {\n\n  public Cdn3StorageManagerConfiguration {\n    if (numHttpClients == null) {\n      numHttpClients = 2;\n    }\n    if (sourceSchemes == null) {\n      sourceSchemes = Collections.emptyMap();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/CdnConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport javax.annotation.Nullable;\nimport java.net.URI;\n\npublic record CdnConfiguration(@NotNull @Valid StaticAwsCredentialsFactory credentials,\n                               @NotBlank String bucket,\n                               @NotBlank String region,\n                               @Nullable URI endpointOverride) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/CircuitBreakerConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class CircuitBreakerConfiguration {\n\n  @JsonProperty\n  @NotNull\n  @Min(1)\n  @Max(100)\n  private int failureRateThreshold = 50;\n\n  @JsonProperty\n  @NotNull\n  @Min(1)\n  private int permittedNumberOfCallsInHalfOpenState = 10;\n\n  @JsonProperty\n  @NotNull\n  @Min(1)\n  private int slidingWindowSize = 100;\n\n  @JsonProperty\n  @NotNull\n  @Min(1)\n  private int slidingWindowMinimumNumberOfCalls = 100;\n\n  @JsonProperty\n  @NotNull\n  private Duration waitDurationInOpenState = Duration.ofSeconds(10);\n\n  @JsonProperty\n  private List<String> ignoredExceptions = Collections.emptyList();\n\n\n  public int getFailureRateThreshold() {\n    return failureRateThreshold;\n  }\n\n  public int getPermittedNumberOfCallsInHalfOpenState() {\n    return permittedNumberOfCallsInHalfOpenState;\n  }\n\n  public int getSlidingWindowSize() {\n    return slidingWindowSize;\n  }\n\n  public int getSlidingWindowMinimumNumberOfCalls() {\n    return slidingWindowMinimumNumberOfCalls;\n  }\n\n  public Duration getWaitDurationInOpenState() {\n    return waitDurationInOpenState;\n  }\n\n  public List<Class<?>> getIgnoredExceptions() {\n    return ignoredExceptions.stream()\n        .map(name -> {\n          try {\n            return Class.forName(name);\n          } catch (final ClassNotFoundException e) {\n            throw new RuntimeException(e);\n          }\n        })\n        .collect(Collectors.toList());\n  }\n\n  @VisibleForTesting\n  public void setFailureRateThreshold(int failureRateThreshold) {\n    this.failureRateThreshold = failureRateThreshold;\n  }\n\n  @VisibleForTesting\n  public void setSlidingWindowSize(int size) {\n    this.slidingWindowSize = size;\n  }\n\n  @VisibleForTesting\n  public void setSlidingWindowMinimumNumberOfCalls(int size) {\n    this.slidingWindowMinimumNumberOfCalls = size;\n  }\n\n  @VisibleForTesting\n  public void setPermittedNumberOfCallsInHalfOpenState(int size) {\n    this.permittedNumberOfCallsInHalfOpenState = size;\n  }\n\n  @VisibleForTesting\n  public void setWaitDurationInOpenState(Duration duration) {\n    this.waitDurationInOpenState = duration;\n  }\n\n  @VisibleForTesting\n  public void setIgnoredExceptions(final List<String> ignoredExceptions) {\n    this.ignoredExceptions = ignoredExceptions;\n  }\n\n  public CircuitBreakerConfig toCircuitBreakerConfig() {\n    return CircuitBreakerConfig.custom()\n        .failureRateThreshold(getFailureRateThreshold())\n        .ignoreExceptions(getIgnoredExceptions().toArray(new Class[0]))\n        .permittedNumberOfCallsInHalfOpenState(getPermittedNumberOfCallsInHalfOpenState())\n        .waitDurationInOpenState(getWaitDurationInOpenState())\n        .slidingWindow(getSlidingWindowSize(), getSlidingWindowMinimumNumberOfCalls(),\n            CircuitBreakerConfig.SlidingWindowType.COUNT_BASED,\n            CircuitBreakerConfig.SlidingWindowSynchronizationStrategy.SYNCHRONIZED)\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/ClientReleaseConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\n\npublic record ClientReleaseConfiguration(@NotNull Duration refreshInterval) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/CloudflareTurnConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport java.util.List;\nimport jakarta.validation.constraints.Positive;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport javax.annotation.Nullable;\n\n/**\n * Configuration properties for Cloudflare TURN integration.\n *\n * @param apiToken the API token to use when requesting TURN tokens from Cloudflare\n * @param endpoint the URI of the Cloudflare API endpoint that vends TURN tokens\n * @param requestedCredentialTtl the lifetime of TURN tokens to request from Cloudflare\n * @param clientCredentialTtl the time clients may cache a TURN token; must be less than or equal to {@link #requestedCredentialTtl}\n * @param urls a collection of TURN URLs to include verbatim in responses to clients\n * @param urlsWithIps a collection of {@link String#format(String, Object...)} patterns to be populated with resolved IP\n *                    addresses for {@link #hostname} in responses to clients; each pattern must include a single\n *                    {@code %s} placeholder for the IP address\n * @param circuitBreakerConfigurationName the name of a circuit breaker configuration for requests to Cloudflare; if\n *                                        `null`, uses the global default configuration\n * @param retryConfigurationName the name of a retry policy for requests to Cloudflare; if `null`, uses the global\n *                               default configuration\n * @param hostname the hostname to resolve to IP addresses for use with {@link #urlsWithIps}; also transmitted to\n *                 clients for use as an SNI when connecting to pre-resolved hosts\n * @param numHttpClients the number of parallel HTTP clients to use to communicate with Cloudflare\n */\npublic record CloudflareTurnConfiguration(@NotNull SecretString apiToken,\n                                          @NotBlank String endpoint,\n                                          @NotNull Duration requestedCredentialTtl,\n                                          @NotNull Duration clientCredentialTtl,\n                                          @NotNull @NotEmpty @Valid List<@NotBlank String> urls,\n                                          @NotNull @NotEmpty @Valid List<@NotBlank String> urlsWithIps,\n                                          @Nullable String circuitBreakerConfigurationName,\n                                          @Nullable String retryConfigurationName,\n                                          @NotBlank String hostname,\n                                          @Positive int numHttpClients) {\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isClientTtlShorterThanRequestedTtl() {\n    return clientCredentialTtl.compareTo(requestedCredentialTtl) <= 0;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DefaultAwsCredentialsFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.auth.credentials.WebIdentityTokenFileCredentialsProvider;\n\n@JsonTypeName(\"default\")\npublic record DefaultAwsCredentialsFactory() implements AwsCredentialsProviderFactory {\n\n  public AwsCredentialsProvider build() {\n    return WebIdentityTokenFileCredentialsProvider.create();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DefaultPubSubPublisherFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport com.google.api.gax.batching.BatchingSettings;\nimport com.google.api.gax.batching.FlowControlSettings;\nimport com.google.api.gax.batching.FlowController;\nimport com.google.api.gax.core.FixedCredentialsProvider;\nimport com.google.auth.oauth2.ExternalAccountCredentials;\nimport com.google.cloud.pubsub.v1.Publisher;\nimport com.google.cloud.pubsub.v1.PublisherInterface;\nimport com.google.pubsub.v1.TopicName;\nimport jakarta.validation.constraints.NotBlank;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\n\n@JsonTypeName(\"default\")\npublic record DefaultPubSubPublisherFactory(@NotBlank String project,\n                                            @NotBlank String topic,\n                                            @NotBlank String credentialConfiguration) implements\n    PubSubPublisherFactory {\n\n  @Override\n  public PublisherInterface build() throws IOException {\n\n    final FlowControlSettings flowControlSettings = FlowControlSettings.newBuilder()\n        .setLimitExceededBehavior(FlowController.LimitExceededBehavior.ThrowException)\n        .setMaxOutstandingElementCount(100L)\n        .setMaxOutstandingRequestBytes(16 * 1024 * 1024L) // 16MB\n        .build();\n\n    final BatchingSettings batchingSettings = BatchingSettings.newBuilder()\n        .setFlowControlSettings(flowControlSettings)\n        .setDelayThreshold(org.threeten.bp.Duration.ofMillis(10))\n        // These thresholds are actually the default, setting them explicitly since creating a custom batchingSettings resets them\n        .setElementCountThreshold(100L)\n        .setRequestByteThreshold(5000L)\n        .build();\n\n    try (final ByteArrayInputStream credentialConfigInputStream =\n        new ByteArrayInputStream(credentialConfiguration.getBytes(StandardCharsets.UTF_8))) {\n\n      return Publisher.newBuilder(\n              TopicName.of(project, topic))\n          .setCredentialsProvider(\n              FixedCredentialsProvider.create(ExternalAccountCredentials.fromStream(credentialConfigInputStream)))\n          .setBatchingSettings(batchingSettings)\n          .build();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DeviceCheckConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport java.time.Duration;\n\n/**\n * Configuration for Device Check operations\n *\n * @param backupRedemptionDuration How long to grant backup access for redemptions via device check\n * @param backupRedemptionLevel    What backup level to grant redemptions via device check\n */\npublic record DeviceCheckConfiguration(Duration backupRedemptionDuration, long backupRedemptionLevel) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2ClientConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic record DirectoryV2ClientConfiguration(@ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,\n                                             @ExactlySize(32) SecretBytes userIdTokenSharedSecret) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DirectoryV2Configuration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\n\npublic class DirectoryV2Configuration {\n\n  private final DirectoryV2ClientConfiguration clientConfiguration;\n\n  @JsonCreator\n  public DirectoryV2Configuration(@JsonProperty(\"client\") DirectoryV2ClientConfiguration clientConfiguration) {\n    this.clientConfiguration = clientConfiguration;\n  }\n\n  @Valid\n  public DirectoryV2ClientConfiguration getDirectoryV2ClientConfiguration() {\n    return clientConfiguration;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbClientConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Positive;\nimport java.time.Duration;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.core.client.config.ClientOverrideConfiguration;\nimport software.amazon.awssdk.http.crt.AwsCrtHttpClient;\nimport software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient;\nimport software.amazon.awssdk.metrics.MetricPublisher;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\n\n@JsonTypeName(\"default\")\npublic record DynamoDbClientConfiguration(@NotBlank String region,\n                                          @NotNull Duration clientExecutionTimeout,\n                                          @NotNull Duration clientRequestTimeout,\n                                          @Positive int maxConnections) implements DynamoDbClientFactory {\n\n  public DynamoDbClientConfiguration {\n    if (clientExecutionTimeout == null) {\n      clientExecutionTimeout = Duration.ofSeconds(30);\n    }\n\n    if (clientRequestTimeout == null) {\n      clientRequestTimeout = Duration.ofSeconds(10);\n    }\n\n    if (maxConnections == 0) {\n      maxConnections = 50;\n    }\n  }\n\n  @Override\n  public DynamoDbClient buildSyncClient(final AwsCredentialsProvider credentialsProvider, final MetricPublisher metricPublisher) {\n    return DynamoDbClient.builder()\n        .region(Region.of(region()))\n        .credentialsProvider(credentialsProvider)\n        .overrideConfiguration(ClientOverrideConfiguration.builder()\n            .apiCallTimeout(clientExecutionTimeout())\n            .apiCallAttemptTimeout(clientRequestTimeout())\n            .addMetricPublisher(metricPublisher)\n            .build())\n        .httpClientBuilder(AwsCrtHttpClient.builder()\n            .maxConcurrency(maxConnections()))\n        .build();\n  }\n\n  @Override\n  public DynamoDbAsyncClient buildAsyncClient(final AwsCredentialsProvider credentialsProvider, final MetricPublisher metricPublisher) {\n    return DynamoDbAsyncClient.builder()\n        .region(Region.of(region()))\n        .credentialsProvider(credentialsProvider)\n        .overrideConfiguration(ClientOverrideConfiguration.builder()\n            .apiCallTimeout(clientExecutionTimeout())\n            .apiCallAttemptTimeout(clientRequestTimeout())\n            .addMetricPublisher(metricPublisher)\n            .build())\n        .httpClientBuilder(NettyNioAsyncHttpClient.builder()\n            .maxConcurrency(maxConnections()))\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbClientFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.jackson.Discoverable;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.metrics.MetricPublisher;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = DynamoDbClientConfiguration.class)\npublic interface DynamoDbClientFactory extends Discoverable {\n\n  DynamoDbClient buildSyncClient(AwsCredentialsProvider awsCredentialsProvider, MetricPublisher metricPublisher);\n\n  DynamoDbAsyncClient buildAsyncClient(AwsCredentialsProvider awsCredentialsProvider, MetricPublisher metricPublisher);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/DynamoDbTables.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\n\npublic class DynamoDbTables {\n\n  public static class Table {\n    private final String tableName;\n\n    @JsonCreator\n    public Table(\n        @JsonProperty(\"tableName\") final String tableName) {\n      this.tableName = tableName;\n    }\n\n    @NotEmpty\n    public String getTableName() {\n      return tableName;\n    }\n  }\n\n  public static class TableWithExpiration extends Table {\n    private final Duration expiration;\n\n    @JsonCreator\n    public TableWithExpiration(\n        @JsonProperty(\"tableName\") final String tableName,\n        @JsonProperty(\"expiration\") final Duration expiration) {\n      super(tableName);\n      this.expiration = expiration;\n    }\n\n    @NotNull\n    public Duration getExpiration() {\n      return expiration;\n    }\n  }\n\n  private final AccountsTableConfiguration accounts;\n\n  private final Table appleDeviceChecks;\n  private final Table appleDeviceCheckPublicKeys;\n  private final Table backups;\n  private final Table clientPublicKeys;\n  private final Table clientReleases;\n  private final Table deletedAccounts;\n  private final Table deletedAccountsLock;\n  private final IssuedReceiptsTableConfiguration issuedReceipts;\n  private final Table ecKeys;\n  private final Table ecSignedPreKeys;\n  private final Table kemLastResortKeys;\n  private final Table pagedKemKeys;\n  private final TableWithExpiration messages;\n  private final TableWithExpiration onetimeDonations;\n  private final Table phoneNumberIdentifiers;\n  private final Table profiles;\n  private final Table pushChallenge;\n  private final Table pushNotificationExperimentSamples;\n  private final TableWithExpiration redeemedReceipts;\n  private final TableWithExpiration registrationRecovery;\n  private final Table remoteConfig;\n  private final Table reportMessage;\n  private final TableWithExpiration scheduledJobs;\n  private final Table subscriptions;\n  private final Table verificationSessions;\n\n  public DynamoDbTables(\n      @JsonProperty(\"accounts\") final AccountsTableConfiguration accounts,\n      @JsonProperty(\"appleDeviceChecks\") final Table appleDeviceChecks,\n      @JsonProperty(\"appleDeviceCheckPublicKeys\") final Table appleDeviceCheckPublicKeys,\n      @JsonProperty(\"backups\") final Table backups,\n      @JsonProperty(\"clientPublicKeys\") final Table clientPublicKeys,\n      @JsonProperty(\"clientReleases\") final Table clientReleases,\n      @JsonProperty(\"deletedAccounts\") final Table deletedAccounts,\n      @JsonProperty(\"deletedAccountsLock\") final Table deletedAccountsLock,\n      @JsonProperty(\"issuedReceipts\") final IssuedReceiptsTableConfiguration issuedReceipts,\n      @JsonProperty(\"ecKeys\") final Table ecKeys,\n      @JsonProperty(\"ecSignedPreKeys\") final Table ecSignedPreKeys,\n      @JsonProperty(\"pqLastResortKeys\") final Table kemLastResortKeys,\n      @JsonProperty(\"pagedPqKeys\") final Table pagedKemKeys,\n      @JsonProperty(\"messages\") final TableWithExpiration messages,\n      @JsonProperty(\"onetimeDonations\") final TableWithExpiration onetimeDonations,\n      @JsonProperty(\"phoneNumberIdentifiers\") final Table phoneNumberIdentifiers,\n      @JsonProperty(\"profiles\") final Table profiles,\n      @JsonProperty(\"pushChallenge\") final Table pushChallenge,\n      @JsonProperty(\"pushNotificationExperimentSamples\") final Table pushNotificationExperimentSamples,\n      @JsonProperty(\"redeemedReceipts\") final TableWithExpiration redeemedReceipts,\n      @JsonProperty(\"registrationRecovery\") final TableWithExpiration registrationRecovery,\n      @JsonProperty(\"remoteConfig\") final Table remoteConfig,\n      @JsonProperty(\"reportMessage\") final Table reportMessage,\n      @JsonProperty(\"scheduledJobs\") final TableWithExpiration scheduledJobs,\n      @JsonProperty(\"subscriptions\") final Table subscriptions,\n      @JsonProperty(\"verificationSessions\") final Table verificationSessions) {\n\n    this.accounts = accounts;\n    this.appleDeviceChecks = appleDeviceChecks;\n    this.appleDeviceCheckPublicKeys = appleDeviceCheckPublicKeys;\n    this.backups = backups;\n    this.clientPublicKeys = clientPublicKeys;\n    this.clientReleases = clientReleases;\n    this.deletedAccounts = deletedAccounts;\n    this.deletedAccountsLock = deletedAccountsLock;\n    this.issuedReceipts = issuedReceipts;\n    this.ecKeys = ecKeys;\n    this.ecSignedPreKeys = ecSignedPreKeys;\n    this.pagedKemKeys = pagedKemKeys;\n    this.kemLastResortKeys = kemLastResortKeys;\n    this.messages = messages;\n    this.onetimeDonations = onetimeDonations;\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n    this.profiles = profiles;\n    this.pushChallenge = pushChallenge;\n    this.pushNotificationExperimentSamples = pushNotificationExperimentSamples;\n    this.redeemedReceipts = redeemedReceipts;\n    this.registrationRecovery = registrationRecovery;\n    this.remoteConfig = remoteConfig;\n    this.reportMessage = reportMessage;\n    this.scheduledJobs = scheduledJobs;\n    this.subscriptions = subscriptions;\n    this.verificationSessions = verificationSessions;\n  }\n\n  @NotNull\n  @Valid\n  public AccountsTableConfiguration getAccounts() {\n    return accounts;\n  }\n\n  @NotNull\n  @Valid\n  public Table getAppleDeviceChecks() {\n    return appleDeviceChecks;\n  }\n\n  @NotNull\n  @Valid\n  public Table getAppleDeviceCheckPublicKeys() {\n    return appleDeviceCheckPublicKeys;\n  }\n\n  @NotNull\n  @Valid\n  public Table getBackups() {\n    return backups;\n  }\n\n  @NotNull\n  @Valid\n  public Table getClientPublicKeys() {\n    return clientPublicKeys;\n  }\n\n  @NotNull\n  @Valid\n  public Table getClientReleases() {\n    return clientReleases;\n  }\n\n  @NotNull\n  @Valid\n  public Table getDeletedAccounts() {\n    return deletedAccounts;\n  }\n\n  @NotNull\n  @Valid\n  public Table getDeletedAccountsLock() {\n    return deletedAccountsLock;\n  }\n\n  @NotNull\n  @Valid\n  public IssuedReceiptsTableConfiguration getIssuedReceipts() {\n    return issuedReceipts;\n  }\n\n  @NotNull\n  @Valid\n  public Table getEcKeys() {\n    return ecKeys;\n  }\n\n  @NotNull\n  @Valid\n  public Table getEcSignedPreKeys() {\n    return ecSignedPreKeys;\n  }\n\n  @NotNull\n  @Valid\n  public Table getPagedKemKeys() {\n    return pagedKemKeys;\n  }\n\n  @NotNull\n  @Valid\n  public Table getKemLastResortKeys() {\n    return kemLastResortKeys;\n  }\n\n  @NotNull\n  @Valid\n  public TableWithExpiration getMessages() {\n    return messages;\n  }\n\n  @NotNull\n  @Valid\n  public TableWithExpiration getOnetimeDonations() {\n    return onetimeDonations;\n  }\n\n  @NotNull\n  @Valid\n  public Table getPhoneNumberIdentifiers() {\n    return phoneNumberIdentifiers;\n  }\n\n  @NotNull\n  @Valid\n  public Table getProfiles() {\n    return profiles;\n  }\n\n  @NotNull\n  @Valid\n  public Table getPushChallenge() {\n    return pushChallenge;\n  }\n\n  @NotNull\n  @Valid\n  public Table getPushNotificationExperimentSamples() {\n    return pushNotificationExperimentSamples;\n  }\n\n  @NotNull\n  @Valid\n  public TableWithExpiration getRedeemedReceipts() {\n    return redeemedReceipts;\n  }\n\n  @NotNull\n  @Valid\n  public TableWithExpiration getRegistrationRecovery() {\n    return registrationRecovery;\n  }\n\n  @NotNull\n  @Valid\n  public Table getRemoteConfig() {\n    return remoteConfig;\n  }\n\n  @NotNull\n  @Valid\n  public Table getReportMessage() {\n    return reportMessage;\n  }\n\n  @NotNull\n  @Valid\n  public TableWithExpiration getScheduledJobs() {\n    return scheduledJobs;\n  }\n\n  @NotNull\n  @Valid\n  public Table getSubscriptions() {\n    return subscriptions;\n  }\n\n  @NotNull\n  @Valid\n  public Table getVerificationSessions() {\n    return verificationSessions;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/ExternalRequestFilterConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Set;\nimport org.whispersystems.textsecuregcm.util.InetAddressRange;\n\npublic record ExternalRequestFilterConfiguration(@Valid @NotNull Set<@NotNull String> paths,\n                                                 @Valid @NotNull Set<@NotNull InetAddressRange> permittedInternalRanges,\n                                                 @Valid @NotNull Set<@NotNull String> grpcMethods) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/FaultTolerantRedisClientFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.jackson.Discoverable;\nimport io.lettuce.core.resource.ClientResources;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = RedisConfiguration.class)\npublic interface FaultTolerantRedisClientFactory extends Discoverable {\n\n  FaultTolerantRedisClient build(String name, ClientResources clientResources);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/FaultTolerantRedisClusterFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.jackson.Discoverable;\nimport io.lettuce.core.resource.ClientResources;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = RedisClusterConfiguration.class)\npublic interface FaultTolerantRedisClusterFactory extends Discoverable {\n\n  FaultTolerantRedisClusterClient build(String name, ClientResources.Builder clientResourcesBuilder);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/FcmConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\n\npublic record FcmConfiguration(@NotNull SecretString credentials) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/GcpAttachmentsConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport io.dropwizard.validation.ValidationMethod;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\n\npublic record GcpAttachmentsConfiguration(@NotBlank String domain,\n                                          @NotBlank String email,\n                                          @Min(1) int maxSizeInBytes,\n                                          String pathPrefix,\n                                          @NotNull SecretString rsaSigningKey) {\n  @SuppressWarnings(\"unused\")\n  @ValidationMethod(message = \"pathPrefix must be empty or start with /\")\n  public boolean isPathPrefixValid() {\n    return StringUtils.isEmpty(pathPrefix) || pathPrefix.startsWith(\"/\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/GenericZkConfig.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic record GenericZkConfig(@NotNull SecretBytes serverSecret) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/GooglePlayBillingConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\n\n/**\n * @param credentialsJson  Service account credentials for Play Billing API\n * @param packageName      The app package name\n * @param applicationName  The app application name\n * @param productIdToLevel A map of productIds offered in the play billing subscription catalog to their corresponding\n *                         signal subscription level\n */\npublic record GooglePlayBillingConfiguration(\n    @NotBlank String credentialsJson,\n    @NotNull String packageName,\n    @NotBlank String applicationName,\n    @NotNull Map<String, Long> productIdToLevel) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/GrpcConfiguration.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\n\npublic record GrpcConfiguration(@NotNull String bindAddress, @NotNull Integer port) {\n  public GrpcConfiguration {\n    if (bindAddress == null || bindAddress.isEmpty()) {\n      bindAddress = \"localhost\";\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/HlrLookupConfiguration.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport javax.annotation.Nullable;\n\npublic record HlrLookupConfiguration(SecretString apiKey,\n                                     SecretString apiSecret,\n                                     @Nullable String circuitBreakerConfigurationName,\n                                     @Nullable String retryConfigurationName) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/IdlePrimaryDeviceReminderConfiguration.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport java.time.Duration;\n\npublic record IdlePrimaryDeviceReminderConfiguration(Duration minIdleDuration) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/IssuedReceiptsTableConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotEmpty;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.util.EnumMapUtil;\nimport java.time.Duration;\nimport java.util.EnumMap;\nimport java.util.Map;\n\npublic class IssuedReceiptsTableConfiguration extends DynamoDbTables.TableWithExpiration {\n\n  private final byte[] generator;\n\n  /**\n   * The maximum number of issued receipts the issued receipt manager should issue for a particular itemId\n   */\n  private final EnumMap<PaymentProvider, Integer> maxIssuedReceiptsPerPaymentId;\n\n  public IssuedReceiptsTableConfiguration(\n      @JsonProperty(\"tableName\") final String tableName,\n      @JsonProperty(\"expiration\") final Duration expiration,\n      @JsonProperty(\"generator\") final byte[] generator,\n      @JsonProperty(\"maxIssuedReceiptsPerPaymentId\") final Map<PaymentProvider, Integer> maxIssuedReceiptsPerPaymentId) {\n    super(tableName, expiration);\n    this.generator = generator;\n    this.maxIssuedReceiptsPerPaymentId = EnumMapUtil.toCompleteEnumMap(PaymentProvider.class, maxIssuedReceiptsPerPaymentId);\n  }\n\n  @NotEmpty\n  public byte[] getGenerator() {\n    return generator;\n  }\n\n  public EnumMap<PaymentProvider, Integer> getmaxIssuedReceiptsPerPaymentId() {\n    return maxIssuedReceiptsPerPaymentId;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/KeyTransparencyServiceConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Positive;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\n\npublic record KeyTransparencyServiceConfiguration(@NotBlank String host,\n                                                  @Positive int port,\n                                                  @NotBlank String tlsCertificate,\n                                                  @NotBlank String clientCertificate,\n                                                  @NotNull SecretString clientPrivateKey) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/LinkDeviceSecretConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic record LinkDeviceSecretConfiguration(SecretBytes secret) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/MaxDeviceConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\n\npublic class MaxDeviceConfiguration {\n\n  @JsonProperty\n  @NotEmpty\n  private String number;\n\n  @JsonProperty\n  @NotNull\n  private int count;\n\n  public String getNumber() {\n    return number;\n  }\n\n  public int getCount() {\n    return count;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/MessageByteLimitCardinalityEstimatorConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\n\npublic record MessageByteLimitCardinalityEstimatorConfiguration(@NotNull Duration period) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/MessageCacheConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\n\npublic class MessageCacheConfiguration {\n\n  @JsonProperty\n  @NotNull\n  @Valid\n  private FaultTolerantRedisClusterFactory cluster;\n\n  @JsonProperty\n  private int persistDelayMinutes = 10;\n\n  public FaultTolerantRedisClusterFactory getRedisClusterConfiguration() {\n    return cluster;\n  }\n\n  public int getPersistDelayMinutes() {\n    return persistDelayMinutes;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/MonitoredS3ObjectConfiguration.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport jakarta.validation.constraints.NotBlank;\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport javax.annotation.Nullable;\n\n@JsonTypeName(\"default\")\npublic record MonitoredS3ObjectConfiguration(\n    @NotBlank String s3Region,\n    @NotBlank String s3Bucket,\n    @NotBlank String objectKey,\n    @Nullable URI endpointOverride,\n    Long maxSize,\n    Duration refreshInterval) implements S3ObjectMonitorFactory {\n\n  private static final long DEFAULT_MAXSIZE = 16 * 1024 * 1024;\n  private static final Duration DEFAULT_REFRESH_INTERVAL = Duration.ofMinutes(5);\n\n  public MonitoredS3ObjectConfiguration {\n    if (maxSize == null) {\n      maxSize = DEFAULT_MAXSIZE;\n    }\n    if (refreshInterval == null) {\n      refreshInterval = DEFAULT_REFRESH_INTERVAL;\n    }\n  }\n\n  @Override\n  public S3ObjectMonitor build(final AwsCredentialsProvider awsCredentialsProvider,\n      final ScheduledExecutorService refreshExecutorService) {\n\n    if (endpointOverride != null && !endpointOverride.toString().isEmpty()) {\n      return new S3ObjectMonitor(awsCredentialsProvider, endpointOverride, s3Region, s3Bucket, objectKey,\n          maxSize, refreshExecutorService, refreshInterval);\n    }\n    return new S3ObjectMonitor(awsCredentialsProvider, s3Region, s3Bucket, objectKey, maxSize, refreshExecutorService,\n        refreshInterval);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationConfiguration.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.Positive;\nimport java.math.BigDecimal;\nimport java.time.Duration;\nimport java.util.Map;\n\n/**\n * @param boost      configuration for individual donations\n * @param gift       configuration for gift donations\n * @param currencies map of lower-cased ISO 3 currency codes and the suggested donation amounts in that currency\n */\npublic record OneTimeDonationConfiguration(@Valid ExpiringLevelConfiguration boost,\n                                           @Valid ExpiringLevelConfiguration gift,\n                                           Map<String, @Valid OneTimeDonationCurrencyConfiguration> currencies,\n                                           BigDecimal sepaMaximumEuros) {\n\n  /**\n   * @param badge      the numeric donation level ID\n   * @param level      the badge ID associated with the level\n   * @param expiration the duration after which the level expires\n   */\n  public record ExpiringLevelConfiguration(@NotEmpty String badge, @Positive long level, Duration expiration) {\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/OneTimeDonationCurrencyConfiguration.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.DecimalMin;\nimport jakarta.validation.constraints.NotNull;\nimport java.math.BigDecimal;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\n/**\n * One-time donation configuration for a given currency\n *\n * @param minimum the minimum amount permitted to be charged in this currency\n * @param gift    the suggested gift donation amount\n * @param boosts  the list of suggested one-time donation amounts\n */\npublic record OneTimeDonationCurrencyConfiguration(\n    @NotNull @DecimalMin(\"0.01\") BigDecimal minimum,\n    @NotNull @DecimalMin(\"0.01\") BigDecimal gift,\n    @Valid\n    @ExactlySize(6)\n    @NotNull\n    List<@NotNull @DecimalMin(\"0.01\") BigDecimal> boosts) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/OpenTelemetryConfiguration.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonAnyGetter;\nimport com.fasterxml.jackson.annotation.JsonAnySetter;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.micrometer.registry.otlp.HistogramFlavor;\nimport io.micrometer.registry.otlp.OtlpConfig;\nimport java.time.Duration;\nimport java.util.Map;\n\npublic record OpenTelemetryConfiguration(\n  @JsonProperty boolean enabled,\n  @JsonProperty Duration shutdownWaitDuration,\n  @JsonProperty int maxBucketCount,\n  @JsonProperty Map<String, Integer> maxBucketsPerMeter,\n  @JsonAnyGetter @JsonAnySetter Map<String, String> otlpConfig\n) implements OtlpConfig {\n\n  @Override\n  public String get(String key) {\n    return otlpConfig.get(key.split(\"\\\\.\", 2)[1]);\n  }\n\n  @Override\n  public Map<String, Integer> maxBucketsPerMeter() {\n    if (maxBucketsPerMeter == null) {\n      return Map.of();\n    }\n    return maxBucketsPerMeter;\n  }\n\n  @Override\n  public HistogramFlavor histogramFlavor() {\n    return HistogramFlavor.BASE2_EXPONENTIAL_BUCKET_HISTOGRAM;\n  }\n\n  public Duration shutdownWaitDuration() {\n    if (shutdownWaitDuration == null) {\n      return step().plus(step().dividedBy(2));\n    }\n    return shutdownWaitDuration;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/PagedSingleUseKEMPreKeyStoreConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotBlank;\nimport javax.annotation.Nullable;\nimport java.net.URI;\n\npublic record PagedSingleUseKEMPreKeyStoreConfiguration(\n    @NotBlank String bucket,\n    @NotBlank String region,\n    @Nullable URI endpointOverride) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/PaymentsServiceClientsConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.net.http.HttpClient;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport org.whispersystems.textsecuregcm.currency.CoinGeckoClient;\nimport org.whispersystems.textsecuregcm.currency.FixerClient;\n\n@JsonTypeName(\"default\")\npublic record PaymentsServiceClientsConfiguration(@NotNull SecretString coinGeckoApiKey,\n                                                  @NotNull SecretString fixerApiKey,\n                                                  @NotEmpty Map<@NotBlank String, String> coinGeckoCurrencyIds) implements\n    PaymentsServiceClientsFactory {\n\n  @Override\n  public FixerClient buildFixerClient(final HttpClient httpClient) {\n    return new FixerClient(httpClient, fixerApiKey.value());\n  }\n\n  @Override\n  public CoinGeckoClient buildCoinGeckoClient(final HttpClient httpClient) {\n    return new CoinGeckoClient(httpClient, coinGeckoApiKey.value(), coinGeckoCurrencyIds);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/PaymentsServiceClientsFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.jackson.Discoverable;\nimport org.whispersystems.textsecuregcm.currency.CoinGeckoClient;\nimport org.whispersystems.textsecuregcm.currency.FixerClient;\nimport java.net.http.HttpClient;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = PaymentsServiceClientsConfiguration.class)\npublic interface PaymentsServiceClientsFactory extends Discoverable {\n\n  FixerClient buildFixerClient(final HttpClient httpClient);\n\n  CoinGeckoClient buildCoinGeckoClient(HttpClient httpClient);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/PaymentsServiceConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic record PaymentsServiceConfiguration(@NotNull SecretBytes userAuthenticationTokenSharedSecret,\n                                           @NotEmpty List<String> paymentCurrencies,\n                                           @NotNull @Valid PaymentsServiceClientsFactory externalClients) {\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/PubSubPublisherFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport com.google.cloud.pubsub.v1.PublisherInterface;\nimport io.dropwizard.jackson.Discoverable;\nimport java.io.IOException;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = DefaultPubSubPublisherFactory.class)\npublic interface PubSubPublisherFactory extends Discoverable {\n\n  PublisherInterface build() throws IOException;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisClusterConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.lettuce.core.resource.ClientResources;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\n\n@JsonTypeName(\"default\")\npublic class RedisClusterConfiguration implements FaultTolerantRedisClusterFactory {\n\n  @JsonProperty\n  @NotEmpty\n  private String configurationUri;\n\n  @JsonProperty\n  @NotNull\n  private Duration timeout = Duration.ofSeconds(1);\n\n  @JsonProperty\n  @Nullable\n  private String circuitBreakerConfigurationName;\n\n  @VisibleForTesting\n  void setConfigurationUri(final String configurationUri) {\n    this.configurationUri = configurationUri;\n  }\n\n  public String getConfigurationUri() {\n    return configurationUri;\n  }\n\n  public Duration getTimeout() {\n    return timeout;\n  }\n\n  @Nullable public String getCircuitBreakerConfigurationName() {\n    return circuitBreakerConfigurationName;\n  }\n\n  @Override\n  public FaultTolerantRedisClusterClient build(final String name, final ClientResources.Builder clientResourcesBuilder) {\n    return new FaultTolerantRedisClusterClient(name, this, clientResourcesBuilder);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/RedisConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.lettuce.core.resource.ClientResources;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\n\n@JsonTypeName(\"default\")\npublic class RedisConfiguration implements FaultTolerantRedisClientFactory {\n\n  @JsonProperty\n  @NotEmpty\n  private String uri;\n\n  @JsonProperty\n  @NotNull\n  private Duration timeout = Duration.ofSeconds(1);\n\n  @JsonProperty\n  @Nullable\n  private String circuitBreakerConfigurationName;\n\n  public String getUri() {\n    return uri;\n  }\n\n  @VisibleForTesting\n  public void setUri(String uri) {\n    this.uri = uri;\n  }\n\n  public Duration getTimeout() {\n    return timeout;\n  }\n\n  @Nullable public String getCircuitBreakerConfigurationName() {\n    return circuitBreakerConfigurationName;\n  }\n\n  @Override\n  public FaultTolerantRedisClient build(final String name, final ClientResources clientResources) {\n    return new FaultTolerantRedisClient(name, this, clientResources.mutate());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceClientFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.jackson.Discoverable;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = RegistrationServiceConfiguration.class)\npublic interface RegistrationServiceClientFactory extends Discoverable {\n\n  RegistrationServiceClient build(Environment environment, Executor callbackExecutor,\n      ScheduledExecutorService identityRefreshExecutor);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/RegistrationServiceConfiguration.java",
    "content": "package org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport io.dropwizard.core.setup.Environment;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport java.io.IOException;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.registration.IdentityTokenCallCredentials;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\n\n@JsonTypeName(\"default\")\npublic record RegistrationServiceConfiguration(@NotBlank String host,\n                                               int port,\n                                               @NotBlank String credentialConfigurationJson,\n                                               @NotBlank String identityTokenAudience,\n                                               @NotBlank String registrationCaCertificate,\n                                               @NotNull SecretBytes collationKeySalt) implements\n    RegistrationServiceClientFactory {\n\n  @Override\n  public RegistrationServiceClient build(final Environment environment, final Executor callbackExecutor,\n      final ScheduledExecutorService identityRefreshExecutor) {\n    try {\n      final IdentityTokenCallCredentials callCredentials = IdentityTokenCallCredentials.fromCredentialConfig(\n          credentialConfigurationJson, identityTokenAudience, identityRefreshExecutor);\n\n      environment.lifecycle().manage(callCredentials);\n\n      return new RegistrationServiceClient(host, port, callCredentials, registrationCaCertificate, collationKeySalt.value(),\n          identityRefreshExecutor);\n    } catch (IOException e) {\n      throw new RuntimeException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/RemoteConfigConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Map;\n\npublic record RemoteConfigConfiguration(@NotNull Map<String, String> globalConfig) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/ReportMessageConfiguration.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\n\npublic class ReportMessageConfiguration {\n\n  @JsonProperty\n  @NotNull\n  private final Duration reportTtl = Duration.ofDays(7);\n\n  @JsonProperty\n  @NotNull\n  private final Duration counterTtl = Duration.ofDays(1);\n\n  public Duration getReportTtl() {\n    return reportTtl;\n  }\n\n  public Duration getCounterTtl() {\n    return counterTtl;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/RetryConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.github.resilience4j.retry.RetryConfig;\nimport jakarta.validation.constraints.Min;\nimport java.time.Duration;\n\npublic class RetryConfiguration {\n\n  @JsonProperty\n  @Min(1)\n  private int maxAttempts = 3;\n\n  @JsonProperty\n  @Min(1)\n  private long waitDuration = RetryConfig.DEFAULT_WAIT_DURATION;\n\n  public int getMaxAttempts() {\n    return maxAttempts;\n  }\n\n  public void setMaxAttempts(final int maxAttempts) {\n    this.maxAttempts = maxAttempts;\n  }\n\n  public long getWaitDuration() {\n    return waitDuration;\n  }\n\n  public void setWaitDuration(final long waitDuration) {\n    this.waitDuration = waitDuration;\n  }\n\n  public <T> RetryConfig.Builder<T> toRetryConfigBuilder() {\n    return RetryConfig.<T>custom()\n        .maxAttempts(getMaxAttempts())\n        .waitDuration(Duration.ofMillis(getWaitDuration()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/S3ObjectMonitorFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.dropwizard.jackson.Discoverable;\nimport org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport java.util.concurrent.ScheduledExecutorService;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = MonitoredS3ObjectConfiguration.class)\npublic interface S3ObjectMonitorFactory extends Discoverable {\n\n  S3ObjectMonitor build(AwsCredentialsProvider awsCredentialsProvider,\n      ScheduledExecutorService refreshExecutorService);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureStorageServiceConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic record SecureStorageServiceConfiguration(@NotNull SecretBytes userAuthenticationTokenSharedSecret,\n                                                @NotBlank String uri,\n                                                @NotEmpty List<@NotBlank String> storageCaCertificates,\n                                                @Nullable String circuitBreakerConfigurationName,\n                                                @Nullable String retryConfigurationName) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/SecureValueRecoveryConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport javax.annotation.Nullable;\n\npublic record SecureValueRecoveryConfiguration(\n    @NotBlank String uri,\n    @ExactlySize(32) SecretBytes userAuthenticationTokenSharedSecret,\n    @ExactlySize(32) SecretBytes userIdTokenSharedSecret,\n    @NotEmpty List<@NotBlank String> svrCaCertificates,\n    @Nullable String circuitBreakerConfigurationName,\n    @Nullable String retryConfigurationName) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/ShortCodeExpanderConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\npublic record ShortCodeExpanderConfiguration(String baseUrl) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/SpamFilterConfiguration.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotBlank;\n\npublic class SpamFilterConfiguration {\n\n  @JsonProperty\n  @NotBlank\n  private final String environment;\n\n  @JsonCreator\n  public SpamFilterConfiguration(@JsonProperty(\"environment\") final String environment) {\n    this.environment = environment;\n  }\n\n  public String getEnvironment() {\n    return environment;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/StaticAwsCredentialsFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\", defaultImpl = StaticAwsCredentialsFactory.class)\n@JsonTypeName(\"static\")\npublic record StaticAwsCredentialsFactory(@NotNull SecretString accessKeyId,\n                                          @NotNull SecretString secretAccessKey) implements\n    AwsCredentialsProviderFactory {\n\n  @Override\n  public AwsCredentialsProvider build() {\n    return StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKeyId.value(), secretAccessKey.value()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/StripeConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Map;\nimport java.util.Set;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;\n\npublic record StripeConfiguration(@NotNull SecretString apiKey,\n                                  @NotNull SecretBytes idempotencyKeyGenerator,\n                                  @NotBlank String boostDescription,\n                                  @Valid @NotEmpty Map<PaymentMethod, Set<@NotBlank String>> supportedCurrenciesByPaymentMethod) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.collect.Sets;\nimport io.dropwizard.validation.ValidationMethod;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.function.Predicate;\nimport org.whispersystems.textsecuregcm.backup.BackupLevelUtil;\n\npublic class SubscriptionConfiguration {\n\n  private final Duration badgeGracePeriod;\n  private final Duration badgeExpiration;\n\n  private final Duration backupExpiration;\n  private final Duration backupGracePeriod;\n  private final Duration backupFreeTierMediaDuration;\n  private final Map<Long, SubscriptionLevelConfiguration.Donation> donationLevels;\n  private final Map<Long, SubscriptionLevelConfiguration.Backup> backupLevels;\n\n  @JsonCreator\n  public SubscriptionConfiguration(\n      @JsonProperty(\"badgeGracePeriod\") @Valid Duration badgeGracePeriod,\n      @JsonProperty(\"badgeExpiration\") @Valid Duration badgeExpiration,\n      @JsonProperty(\"backupExpiration\") @Valid Duration backupExpiration,\n      @JsonProperty(\"backupGracePeriod\") @Valid Duration backupGracePeriod,\n      @JsonProperty(\"backupFreeTierMediaDuration\") @Valid Duration backupFreeTierMediaDuration,\n      @JsonProperty(\"levels\") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Donation> donationLevels,\n      @JsonProperty(\"backupLevels\") @Valid Map<@NotNull @Min(1) Long, SubscriptionLevelConfiguration.@NotNull @Valid Backup> backupLevels) {\n    this.badgeGracePeriod = badgeGracePeriod;\n    this.badgeExpiration = badgeExpiration;\n    this.backupFreeTierMediaDuration = backupFreeTierMediaDuration;\n    this.donationLevels = donationLevels;\n    this.backupExpiration = backupExpiration;\n    this.backupGracePeriod = backupGracePeriod;\n    this.backupLevels = backupLevels == null ? Collections.emptyMap() : backupLevels;\n  }\n\n  public Duration getBadgeGracePeriod() {\n    return badgeGracePeriod;\n  }\n\n  // This is the badge expiration time starting from when a payment successfully completes\n  public Duration getBadgeExpiration() {\n    return badgeExpiration;\n  }\n\n  public Duration getBackupExpiration() {\n    return backupExpiration;\n  }\n\n  public Duration getBackupGracePeriod() {\n    return backupGracePeriod;\n  }\n\n  public SubscriptionLevelConfiguration getSubscriptionLevel(long level) {\n    return Optional\n        .<SubscriptionLevelConfiguration>ofNullable(this.donationLevels.get(level))\n        .orElse(this.backupLevels.get(level));\n  }\n\n  public Map<Long, SubscriptionLevelConfiguration.Donation> getDonationLevels() {\n    return donationLevels;\n  }\n\n  public Map<Long, SubscriptionLevelConfiguration.Backup> getBackupLevels() {\n    return backupLevels;\n  }\n\n  @JsonIgnore\n  @ValidationMethod(message = \"Backup levels and donation levels should not intersect\")\n  public boolean areLevelConstraintsSatisfied() {\n    // We have a tier for all configured backup levels\n    final boolean backupLevelsMatch = backupLevels.keySet()\n        .stream()\n        .allMatch(SubscriptionConfiguration::isValidBackupLevel);\n\n    // None of the donation levels correspond to backup levels\n    final boolean donationLevelsDontMatch = donationLevels.keySet().stream()\n        .allMatch(Predicate.not(SubscriptionConfiguration::isValidBackupLevel));\n\n    // The configured donation and backup levels don't intersect\n    final boolean levelsDontIntersect = Sets.intersection(backupLevels.keySet(), donationLevels.keySet()).isEmpty();\n\n    return backupLevelsMatch && donationLevelsDontMatch && levelsDontIntersect;\n  }\n\n  @JsonIgnore\n  @ValidationMethod(message = \"has a mismatch between the levels supported currencies\")\n  public boolean isCurrencyListSameAcrossAllLevels() {\n    return isCurrencyListSameAccrossLevelConfigurations(donationLevels)\n        && isCurrencyListSameAccrossLevelConfigurations(backupLevels);\n  }\n\n  private static boolean isCurrencyListSameAccrossLevelConfigurations(\n      Map<Long, ? extends SubscriptionLevelConfiguration> subscriptionLevels) {\n    Optional<? extends SubscriptionLevelConfiguration> any = subscriptionLevels.values().stream().findAny();\n    if (any.isEmpty()) {\n      return true;\n    }\n\n    Set<String> currencies = any.get().prices().keySet();\n    return subscriptionLevels.values().stream().allMatch(level -> currencies.equals(level.prices().keySet()));\n  }\n\n  public Duration getbackupFreeTierMediaDuration() {\n    return backupFreeTierMediaDuration;\n  }\n\n  private static boolean isValidBackupLevel(final long receiptLevel) {\n    try {\n      BackupLevelUtil.fromReceiptLevel(receiptLevel);\n      return true;\n    } catch (IllegalArgumentException e) {\n      return false;\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionLevelConfiguration.java",
    "content": "/*\n * Copyright 2021-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport java.util.Map;\n\npublic sealed interface SubscriptionLevelConfiguration permits\n    SubscriptionLevelConfiguration.Backup, SubscriptionLevelConfiguration.Donation {\n\n  Map<String, SubscriptionPriceConfiguration> prices();\n\n  enum Type {\n    DONATION,\n    BACKUP\n  }\n\n  default Type type() {\n    return switch (this) {\n      case Backup b -> Type.BACKUP;\n      case Donation d -> Type.DONATION;\n    };\n  }\n\n  record Backup(\n      @JsonProperty(\"playProductId\") @NotEmpty String playProductId,\n      @JsonProperty(\"mediaTtl\") @NotNull Duration mediaTtl,\n      @JsonProperty(\"prices\") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices)\n      implements SubscriptionLevelConfiguration {}\n\n  record Donation(\n      @JsonProperty(\"badge\") @NotEmpty String badge,\n      @JsonProperty(\"prices\") @Valid Map<@NotEmpty String, @NotNull @Valid SubscriptionPriceConfiguration> prices)\n      implements SubscriptionLevelConfiguration {}\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/SubscriptionPriceConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.DecimalMin;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.math.BigDecimal;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\n\npublic record SubscriptionPriceConfiguration(@Valid @NotEmpty Map<PaymentProvider, @NotBlank String> processorIds,\n                                             @NotNull @DecimalMin(\"0.01\") BigDecimal amount) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/TlsKeyStoreConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\n\npublic record TlsKeyStoreConfiguration(@NotNull SecretString password) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\npublic record TurnConfiguration(CloudflareTurnConfiguration cloudflare) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/TurnUriConfiguration.java",
    "content": "package org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.UUID;\n\npublic class TurnUriConfiguration {\n  @JsonProperty\n  @NotNull\n  private List<String> uris;\n\n  /**\n   * The weight of this entry for weighted random selection\n   */\n  @JsonProperty\n  @Min(0)\n  private long weight = 1;\n\n  /**\n   * Enrolled numbers will always get this uri list\n   */\n  @JsonProperty\n  private Set<UUID> enrolledAcis = Collections.emptySet();\n\n  public List<String> getUris() {\n    return uris;\n  }\n\n  public long getWeight() {\n    return weight;\n  }\n\n  public Set<UUID> getEnrolledAcis() {\n    return Collections.unmodifiableSet(enrolledAcis);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/URLSerializationConverter.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.databind.util.StdConverter;\nimport java.net.URL;\n\nfinal class URLSerializationConverter extends StdConverter<URL, String> {\n\n  @Override\n  public String convert(final URL value) {\n    return value.toString();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/UnidentifiedDeliveryConfiguration.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPrivateKey;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic record UnidentifiedDeliveryConfiguration(@NotNull @NotEmpty  byte[] certificate,\n                                                @ExactlySize(32) SecretBytes privateKey,\n                                                int expiresDays,\n                                                boolean embedSigner) {\n  public ECPrivateKey ecPrivateKey() throws InvalidKeyException {\n    return new ECPrivateKey(privateKey.value());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/VirtualThreadConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport java.time.Duration;\n\npublic record VirtualThreadConfiguration(\n    Duration pinEventThreshold,\n    Integer maxConcurrentThreadsPerExecutor) {\n\n  public VirtualThreadConfiguration() {\n    this(null, null);\n  }\n\n  public VirtualThreadConfiguration {\n    if (maxConcurrentThreadsPerExecutor == null) {\n      maxConcurrentThreadsPerExecutor = 1_000_000;\n    }\n    if (pinEventThreshold == null) {\n      pinEventThreshold = Duration.ofMillis(1);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/ZkConfig.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic record ZkConfig(@NotNull SecretBytes serverSecret,\n                       @NotEmpty byte[] serverPublic) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicBackupConfiguration.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport io.dropwizard.util.DataSize;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\n\n/**\n *\n * @param deletionConcurrency How many cdn object deletion requests can be outstanding at a time per backup deletion operation\n * @param copyConcurrency How many cdn object copy requests can be outstanding at a time per batch copy-to-backup operation\n * @param usageCheckpointCount When doing batch operations, how often persist usage deltas\n * @param maxQuotaStaleness The maximum age of a quota estimate that can be used to enforce a quota limit\n * @param maxTotalMediaSize The number of media bytes a paid-tier user may store\n */\npublic record DynamicBackupConfiguration(\n  @NotNull Integer deletionConcurrency,\n  @NotNull Integer copyConcurrency,\n  @NotNull Integer usageCheckpointCount,\n  @NotNull Duration maxQuotaStaleness,\n  @NotNull Long maxTotalMediaSize) {\n\n  public DynamicBackupConfiguration {\n    if (deletionConcurrency == null) {\n      deletionConcurrency = 10;\n    }\n    if (copyConcurrency == null) {\n      copyConcurrency = 10;\n    }\n    if (usageCheckpointCount == null) {\n      usageCheckpointCount = 10;\n    }\n    if (maxQuotaStaleness == null) {\n      maxQuotaStaleness = Duration.ofDays(1);\n    }\n    if (maxTotalMediaSize == null) {\n      maxTotalMediaSize = DataSize.gibibytes(100).toBytes();\n    }\n  }\n\n  public DynamicBackupConfiguration() {\n    this(null, null, null, null, null);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCaptchaConfiguration.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.annotations.VisibleForTesting;\nimport jakarta.validation.constraints.DecimalMax;\nimport jakarta.validation.constraints.DecimalMin;\nimport jakarta.validation.constraints.NotNull;\nimport java.math.BigDecimal;\nimport java.util.Collections;\nimport java.util.Map;\nimport java.util.Set;\nimport org.whispersystems.textsecuregcm.captcha.Action;\n\npublic class DynamicCaptchaConfiguration {\n\n  @JsonProperty\n  @DecimalMin(\"0\")\n  @DecimalMax(\"1\")\n  @NotNull\n  private BigDecimal scoreFloor;\n\n  @JsonProperty\n  private boolean allowHCaptcha = false;\n\n  @JsonProperty\n  @NotNull\n  private Map<Action, Set<String>> hCaptchaSiteKeys = Collections.emptyMap();\n\n  @JsonProperty\n  @NotNull\n  private Map<Action, BigDecimal> scoreFloorByAction = Collections.emptyMap();\n\n  public BigDecimal getScoreFloor() {\n    return scoreFloor;\n  }\n\n  public boolean isAllowHCaptcha() {\n    return allowHCaptcha;\n  }\n\n  public Map<Action, BigDecimal> getScoreFloorByAction() {\n    return scoreFloorByAction;\n  }\n\n  @VisibleForTesting\n  public void setAllowHCaptcha(final boolean allowHCaptcha) {\n    this.allowHCaptcha = allowHCaptcha;\n  }\n\n  @VisibleForTesting\n  public void setScoreFloor(final BigDecimal scoreFloor) {\n    this.scoreFloor = scoreFloor;\n  }\n\n  public Map<Action, Set<String>> getHCaptchaSiteKeys() {\n    return hCaptchaSiteKeys;\n  }\n\n  @VisibleForTesting\n  public void setHCaptchaSiteKeys(final Map<Action, Set<String>> hCaptchaSiteKeys) {\n    this.hCaptchaSiteKeys = hCaptchaSiteKeys;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicCarrierDataLookupConfiguration.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\n\npublic record DynamicCarrierDataLookupConfiguration(boolean enabled, @NotNull Duration maxCacheAge) {\n\n  public static Duration DEFAULT_MAX_CACHE_AGE = Duration.ofDays(7);\n\n  public DynamicCarrierDataLookupConfiguration() {\n    this(false, DEFAULT_MAX_CACHE_AGE);\n  }\n\n  public DynamicCarrierDataLookupConfiguration {\n    if (maxCacheAge == null) {\n      maxCacheAge = DEFAULT_MAX_CACHE_AGE;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.limits.RateLimiterConfig;\n\npublic class DynamicConfiguration {\n\n  @JsonProperty\n  @Valid\n  private Map<String, DynamicExperimentEnrollmentConfiguration> experiments = Collections.emptyMap();\n\n  @JsonProperty\n  @Valid\n  private Map<String, DynamicE164ExperimentEnrollmentConfiguration> e164Experiments = Collections.emptyMap();\n\n  @JsonProperty\n  @Valid\n  private Map<String, RateLimiterConfig> limits = new HashMap<>();\n\n  @JsonProperty\n  @Valid\n  private DynamicRemoteDeprecationConfiguration remoteDeprecation = new DynamicRemoteDeprecationConfiguration();\n\n  @JsonProperty\n  @Valid\n  private DynamicPaymentsConfiguration payments = new DynamicPaymentsConfiguration();\n\n  @JsonProperty\n  @Valid\n  private DynamicCaptchaConfiguration captcha = new DynamicCaptchaConfiguration();\n\n  @JsonProperty\n  @Valid\n  DynamicMessagePersisterConfiguration messagePersister = new DynamicMessagePersisterConfiguration();\n\n  @JsonProperty\n  @Valid\n  DynamicRegistrationConfiguration registrationConfiguration = new DynamicRegistrationConfiguration(false);\n\n  @JsonProperty\n  @Valid\n  DynamicMetricsConfiguration metricsConfiguration = new DynamicMetricsConfiguration(false, false);\n\n  @JsonProperty\n  @Valid\n  List<Integer> svr2StatusCodesToIgnoreForAccountDeletion = Collections.emptyList();\n\n  @JsonProperty\n  @Valid\n  List<Integer> svrbStatusCodesToIgnoreForAccountDeletion = Collections.emptyList();\n\n  @JsonProperty\n  @Valid\n  DynamicRestDeprecationConfiguration restDeprecation = new DynamicRestDeprecationConfiguration(Map.of());\n\n  @JsonProperty\n  @Valid\n  private DynamicBackupConfiguration backup = new DynamicBackupConfiguration();\n\n  @JsonProperty\n  @Valid\n  private DynamicCarrierDataLookupConfiguration carrierDataLookup = new DynamicCarrierDataLookupConfiguration();\n\n  @JsonProperty\n  @Valid\n  private DynamicGrpcAllowListConfiguration grpcAllowList = new DynamicGrpcAllowListConfiguration();\n\n  public Optional<DynamicExperimentEnrollmentConfiguration> getExperimentEnrollmentConfiguration(\n      final String experimentName) {\n    return Optional.ofNullable(experiments.get(experimentName));\n  }\n\n  public Optional<DynamicE164ExperimentEnrollmentConfiguration> getE164ExperimentEnrollmentConfiguration(\n      final String experimentName) {\n    return Optional.ofNullable(e164Experiments.get(experimentName));\n  }\n\n  public Map<String, RateLimiterConfig> getLimits() {\n    return limits;\n  }\n\n  public DynamicRemoteDeprecationConfiguration getRemoteDeprecationConfiguration() {\n    return remoteDeprecation;\n  }\n\n  public DynamicPaymentsConfiguration getPaymentsConfiguration() {\n    return payments;\n  }\n\n  public DynamicCaptchaConfiguration getCaptchaConfiguration() {\n    return captcha;\n  }\n\n  public DynamicMessagePersisterConfiguration getMessagePersisterConfiguration() {\n    return messagePersister;\n  }\n\n  public DynamicRegistrationConfiguration getRegistrationConfiguration() {\n    return registrationConfiguration;\n  }\n\n  public DynamicMetricsConfiguration getMetricsConfiguration() {\n    return metricsConfiguration;\n  }\n\n  public List<Integer> getSvr2StatusCodesToIgnoreForAccountDeletion() {\n    return svr2StatusCodesToIgnoreForAccountDeletion;\n  }\n\n  public List<Integer> getSvrbStatusCodesToIgnoreForAccountDeletion() {\n    return svrbStatusCodesToIgnoreForAccountDeletion;\n  }\n\n  public DynamicRestDeprecationConfiguration restDeprecation() {\n    return restDeprecation;\n  }\n\n  public DynamicBackupConfiguration getBackupConfiguration() {\n    return backup;\n  }\n\n  public DynamicCarrierDataLookupConfiguration getCarrierDataLookupConfiguration() {\n    return carrierDataLookup;\n  }\n\n  public DynamicGrpcAllowListConfiguration getGrpcAllowList() {\n    return grpcAllowList;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicE164ExperimentEnrollmentConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport java.util.Collections;\nimport java.util.Set;\n\npublic class DynamicE164ExperimentEnrollmentConfiguration {\n\n  @JsonProperty\n  @Valid\n  private Set<String> enrolledE164s = Collections.emptySet();\n\n  @JsonProperty\n  @Valid\n  private Set<String> excludedE164s = Collections.emptySet();\n\n  @JsonProperty\n  @Valid\n  private Set<String> includedCountryCodes = Collections.emptySet();\n\n  @JsonProperty\n  @Valid\n  private Set<String> excludedCountryCodes = Collections.emptySet();\n\n  @JsonProperty\n  @Valid\n  @Min(0)\n  @Max(100)\n  private int enrollmentPercentage = 0;\n\n  public Set<String> getEnrolledE164s() {\n    return enrolledE164s;\n  }\n\n  public Set<String> getExcludedE164s() {\n    return excludedE164s;\n  }\n\n  public Set<String> getIncludedCountryCodes() {\n    return includedCountryCodes;\n  }\n\n  public Set<String> getExcludedCountryCodes() {\n    return excludedCountryCodes;\n  }\n\n  public int getEnrollmentPercentage() {\n    return enrollmentPercentage;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicExperimentEnrollmentConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Collections;\nimport java.util.Set;\nimport java.util.UUID;\n\npublic class DynamicExperimentEnrollmentConfiguration {\n\n  public static class UuidSelector {\n\n    @JsonProperty\n    @Valid\n    @NotNull\n    private Set<UUID> uuids = Collections.emptySet();\n\n    /**\n     * What percentage of enrolled UUIDs should the experiment be enabled for.\n     * <p>\n     * Unlike {@link this#enrollmentPercentage}, this is not stable by UUID. The same UUID may be enrolled/unenrolled\n     * across calls.\n     */\n    @JsonProperty\n    @Valid\n    @Min(0)\n    @Max(100)\n    private int uuidEnrollmentPercentage = 100;\n\n    public Set<UUID> getUuids() {\n      return uuids;\n    }\n\n    public int getUuidEnrollmentPercentage() {\n      return uuidEnrollmentPercentage;\n    }\n\n  }\n\n  @Valid\n  @NotNull\n  private final UuidSelector uuidSelector = new UuidSelector();\n\n\n  /**\n   * UUIDs that the experiment should always be disabled for. This takes precedence over uuidSelector.\n   */\n  @Valid\n  @NotNull\n  private final Set<UUID> excludedUuids = Collections.emptySet();\n\n  /**\n   * If the UUID is not enrolled via {@link UuidSelector#uuids}, what is the percentage chance it should be enrolled.\n   * <p>\n   * This is stable by UUID, for a given configuration if a UUID is enrolled it will always be enrolled on every call.\n   */\n  @JsonProperty\n  @Valid\n  @Min(0)\n  @Max(100)\n  private int enrollmentPercentage = 0;\n\n  public int getEnrollmentPercentage() {\n    return enrollmentPercentage;\n  }\n\n  public UuidSelector getUuidSelector() {\n    return uuidSelector;\n  }\n\n  public Set<UUID> getExcludedUuids() {\n    return excludedUuids;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicGrpcAllowListConfiguration.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Set;\n\n/// Configure which gRPC methods are enabled\n///\n/// @param enableAll       enable all gRPC methods\n/// @param enabledServices A list of fully qualified service names for which all RPCs should be enabled. For example,\n///                        `org.signal.chat.account.AccountsAnonymous` would enable all RPCs on that service, regardless\n///                        of whether the RPCs on that service appear in `enabledMethods`\n/// @param enabledMethods  A list of fully qualified method names of RPCs that should be enabled. For example,\n///                        `org.signal.chat.account.AccountsAnonymous/LookupUsernameHash` would enable the\n///                        `LookupUsernameHash` RPC method\npublic record DynamicGrpcAllowListConfiguration(\n    boolean enableAll,\n    Set<String> enabledServices,\n    Set<String> enabledMethods) {\n\n  public DynamicGrpcAllowListConfiguration {\n    if (enabledServices == null) {\n      enabledServices = Collections.emptySet();\n    }\n    if (enabledMethods == null) {\n      enabledMethods = Collections.emptySet();\n    }\n  }\n\n  public DynamicGrpcAllowListConfiguration() {\n    // By default, no GRPC methods are accessible\n    this(false, Collections.emptySet(), Collections.emptySet());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicMessagePersisterConfiguration.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Duration;\n\npublic class DynamicMessagePersisterConfiguration {\n\n  @JsonProperty\n  private boolean persistenceEnabled = true;\n\n  /**\n   * If we have to trim a client's persisted queue to make room to persist from Redis to DynamoDB, how much extra room should we make\n   */\n  @JsonProperty\n  private double trimOversizedQueueExtraRoomRatio = 1.5;\n\n  @JsonProperty\n  private Duration nodeClaimTtl = Duration.ofMinutes(5);\n\n  @JsonProperty\n  private Duration sleepBetweenNodes = Duration.ofSeconds(5);\n\n  public DynamicMessagePersisterConfiguration() {}\n\n  @VisibleForTesting\n  public DynamicMessagePersisterConfiguration(final boolean persistenceEnabled,\n      final double trimOversizedQueueExtraRoomRatio,\n      final Duration nodeClaimTtl,\n      final Duration sleepBetweenNodes) {\n\n    this.persistenceEnabled = persistenceEnabled;\n    this.trimOversizedQueueExtraRoomRatio = trimOversizedQueueExtraRoomRatio;\n    this.nodeClaimTtl = nodeClaimTtl;\n    this.sleepBetweenNodes = sleepBetweenNodes;\n  }\n\n  public boolean isPersistenceEnabled() {\n    return persistenceEnabled;\n  }\n\n  public double getTrimOversizedQueueExtraRoomRatio() {\n    return trimOversizedQueueExtraRoomRatio;\n  }\n\n  public Duration getNodeClaimTtl() {\n    return nodeClaimTtl;\n  }\n\n  public Duration getSleepBetweenNodes() {\n    return sleepBetweenNodes;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicMetricsConfiguration.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\n/**\n * @param enableLettuceRemoteTag whether the `remote` tag should be added. Note: although this is dynamic, meters are\n *                               cached after creation, so changes will only affect servers launched after the change.\n * @param enableAwsSdkMetrics whether to record AWS SDK metrics. Note: although this is dynamic, meters are cached after\n *                            creation, so changes will only affect servers launched after the change.\n */\npublic record DynamicMetricsConfiguration(boolean enableLettuceRemoteTag, boolean enableAwsSdkMetrics) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicPaymentsConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport java.util.Collections;\nimport java.util.List;\n\npublic class DynamicPaymentsConfiguration {\n\n  @JsonProperty\n  private List<String> disallowedPrefixes = Collections.emptyList();\n\n  public List<String> getDisallowedPrefixes() {\n    return disallowedPrefixes;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRegistrationConfiguration.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\npublic record DynamicRegistrationConfiguration(boolean squashDeclinedAttemptErrors) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRemoteDeprecationConfiguration.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.vdurmont.semver4j.Semver;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\nimport java.util.Collections;\nimport java.util.Map;\nimport java.util.Set;\n\npublic class DynamicRemoteDeprecationConfiguration {\n\n    @JsonProperty\n    private Map<ClientPlatform, Semver> minimumVersions = Collections.emptyMap();\n\n    @JsonProperty\n    private Map<ClientPlatform, Semver> versionsPendingDeprecation = Collections.emptyMap();\n\n    @JsonProperty\n    private Map<ClientPlatform, Set<Semver>> blockedVersions = Collections.emptyMap();\n\n    @JsonProperty\n    private Map<ClientPlatform, Set<Semver>> versionsPendingBlock = Collections.emptyMap();\n\n    @JsonProperty\n    private boolean unrecognizedUserAgentAllowed = true;\n\n    @VisibleForTesting\n    public void setMinimumVersions(final Map<ClientPlatform, Semver> minimumVersions) {\n        this.minimumVersions = minimumVersions;\n    }\n\n    public Map<ClientPlatform, Semver> getMinimumVersions() {\n        return minimumVersions;\n    }\n\n    @VisibleForTesting\n    public void setVersionsPendingDeprecation(final Map<ClientPlatform, Semver> versionsPendingDeprecation) {\n        this.versionsPendingDeprecation = versionsPendingDeprecation;\n    }\n\n    public Map<ClientPlatform, Semver> getVersionsPendingDeprecation() {\n        return versionsPendingDeprecation;\n    }\n\n    @VisibleForTesting\n    public void setUnrecognizedUserAgentAllowed(final boolean allowUnrecognizedUserAgents) {\n        this.unrecognizedUserAgentAllowed = allowUnrecognizedUserAgents;\n    }\n\n    public boolean isUnrecognizedUserAgentAllowed() {\n        return unrecognizedUserAgentAllowed;\n    }\n\n    @VisibleForTesting\n    public void setBlockedVersions(final Map<ClientPlatform, Set<Semver>> blockedVersions) {\n        this.blockedVersions = blockedVersions;\n    }\n\n    public Map<ClientPlatform, Set<Semver>> getBlockedVersions() {\n        return blockedVersions;\n    }\n\n    @VisibleForTesting\n    public void setVersionsPendingBlock(final Map<ClientPlatform, Set<Semver>> versionsPendingBlock) {\n        this.versionsPendingBlock = versionsPendingBlock;\n    }\n\n    public Map<ClientPlatform, Set<Semver>> getVersionsPendingBlock() {\n        return versionsPendingBlock;\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicRestDeprecationConfiguration.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport com.vdurmont.semver4j.Semver;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\npublic record DynamicRestDeprecationConfiguration(Map<ClientPlatform, PlatformConfiguration> platforms) {\n  public record PlatformConfiguration(Semver minimumRestFreeVersion, int universalRolloutPercent) {}\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/BaseSecretValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport static java.util.Objects.requireNonNull;\n\nimport jakarta.validation.ConstraintValidator;\nimport jakarta.validation.ConstraintValidatorContext;\nimport java.lang.annotation.Annotation;\n\npublic abstract class BaseSecretValidator<A extends Annotation, T, S extends Secret<? extends T>> implements ConstraintValidator<A, S> {\n\n  private final ConstraintValidator<A, T> validator;\n\n\n  protected BaseSecretValidator(final ConstraintValidator<A, T> validator) {\n    this.validator = requireNonNull(validator);\n  }\n\n  @Override\n  public void initialize(final A constraintAnnotation) {\n    validator.initialize(constraintAnnotation);\n  }\n\n  @Override\n  public boolean isValid(final S value, final ConstraintValidatorContext context) {\n    return validator.isValid(value.value(), context);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/Secret.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\npublic class Secret<T> {\n\n  private final T value;\n\n\n  public Secret(final T value) {\n    this.value = value;\n  }\n\n  public T value() {\n    return value;\n  }\n\n  @Override\n  public String toString() {\n    return \"[REDACTED]\";\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretBytes.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport org.apache.commons.lang3.Validate;\n\npublic class SecretBytes extends Secret<byte[]> {\n\n  public SecretBytes(final byte[] value) {\n    super(requireNotEmpty(value));\n  }\n\n  private static byte[] requireNotEmpty(final byte[] value) {\n    Validate.isTrue(value.length > 0, \"SecretBytes value must not be empty\");\n    return value;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretBytesList.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport com.google.common.collect.ImmutableList;\nimport jakarta.validation.constraints.NotEmpty;\nimport java.util.Collection;\nimport java.util.List;\nimport org.hibernate.validator.internal.constraintvalidators.bv.notempty.NotEmptyValidatorForCollection;\n\npublic class SecretBytesList extends Secret<List<byte[]>> {\n\n  @SuppressWarnings(\"rawtypes\")\n  public static class ValidatorNotEmpty extends BaseSecretValidator<NotEmpty, Collection, SecretBytesList> {\n    public ValidatorNotEmpty() {\n      super(new NotEmptyValidatorForCollection());\n    }\n  }\n\n  public SecretBytesList(final List<byte[]> value) {\n    super(ImmutableList.copyOf(value));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.annotations.VisibleForTesting;\nimport java.io.File;\nimport java.io.IOException;\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class SecretStore {\n\n  private final Map<String, Secret<?>> secrets;\n\n\n  public static SecretStore fromYamlFileSecretsBundle(final String filename) {\n    try {\n      @SuppressWarnings(\"unchecked\")\n      final Map<String, Object> secretsBundle = SystemMapper.yamlMapper().readValue(new File(filename), Map.class);\n      return fromSecretsBundle(secretsBundle);\n    } catch (JsonProcessingException e) {\n      throw new RuntimeException(\"Failed to parse YAML file [%s]\".formatted(filename), e);\n    } catch (IOException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public SecretStore(final Map<String, Secret<?>> secrets) {\n    this.secrets = Map.copyOf(secrets);\n  }\n\n  public SecretString secretString(final String reference) {\n    return fromStore(reference, SecretString.class);\n  }\n\n  public SecretBytes secretBytesFromBase64String(final String reference) {\n    final SecretString secret = fromStore(reference, SecretString.class);\n    return new SecretBytes(decodeBase64(secret.value()));\n  }\n\n  public SecretStringList secretStringList(final String reference) {\n    return fromStore(reference, SecretStringList.class);\n  }\n\n  public SecretBytesList secretBytesListFromBase64Strings(final String reference) {\n    final List<String> secrets = secretStringList(reference).value();\n    final List<byte[]> byteSecrets = secrets.stream().map(SecretStore::decodeBase64).toList();\n    return new SecretBytesList(byteSecrets);\n  }\n\n  private <T extends Secret<?>> T fromStore(final String name, final Class<T> expected) {\n    final Secret<?> secret = secrets.get(name);\n    if (secret == null) {\n      throw new IllegalArgumentException(\"Secret [%s] is not present in the secrets bundle\".formatted(name));\n    }\n    if (!expected.isInstance(secret)) {\n      throw new IllegalArgumentException(\"Secret [%s] is of type [%s] but caller expects type [%s]\".formatted(\n          name, secret.getClass().getSimpleName(), expected.getSimpleName()));\n    }\n    return expected.cast(secret);\n  }\n\n  @VisibleForTesting\n  public static SecretStore fromYamlStringSecretsBundle(final String secretsBundleYaml) {\n    try {\n      @SuppressWarnings(\"unchecked\")\n      final Map<String, Object> secretsBundle = SystemMapper.yamlMapper().readValue(secretsBundleYaml, Map.class);\n      return fromSecretsBundle(secretsBundle);\n    } catch (JsonProcessingException e) {\n      throw new RuntimeException(\"Failed to parse JSON\", e);\n    }\n  }\n\n  private static SecretStore fromSecretsBundle(final Map<String, Object> secretsBundle) {\n    final Map<String, Secret<?>> store = new HashMap<>();\n    secretsBundle.forEach((k, v) -> {\n      if (v instanceof final String str) {\n        store.put(k, new SecretString(str));\n        return;\n      }\n      if (v instanceof final List<?> list) {\n        final List<String> secrets = list.stream().map(o -> {\n          if (o instanceof final String s) {\n            return s;\n          }\n          throw new IllegalArgumentException(\"Secrets bundle JSON object is only supposed to have values of types String and list of Strings\");\n        }).toList();\n        store.put(k, new SecretStringList(secrets));\n        return;\n      }\n      throw new IllegalArgumentException(\"Secrets bundle JSON object is only supposed to have values of types String and list of Strings\");\n    });\n    return new SecretStore(store);\n  }\n\n  private static byte[] decodeBase64(final String str) {\n    return Base64.getDecoder().decode(str);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretString.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport org.apache.commons.lang3.Validate;\n\npublic class SecretString extends Secret<String> {\n  public SecretString(final String value) {\n    super(Validate.notBlank(value, \"SecretString value must not be blank\"));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretStringList.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport com.google.common.collect.ImmutableList;\nimport jakarta.validation.constraints.NotEmpty;\nimport java.util.Collection;\nimport java.util.List;\nimport org.hibernate.validator.internal.constraintvalidators.bv.notempty.NotEmptyValidatorForCollection;\n\npublic class SecretStringList extends Secret<List<String>> {\n\n  @SuppressWarnings(\"rawtypes\")\n  public static class ValidatorNotEmpty extends BaseSecretValidator<NotEmpty, Collection, SecretStringList> {\n    public ValidatorNotEmpty() {\n      super(new NotEmptyValidatorForCollection());\n    }\n  }\n\n  public SecretStringList(final List<String> value) {\n    super(ImmutableList.copyOf(value));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretsModule.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.fasterxml.jackson.core.JacksonException;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.module.SimpleModule;\nimport java.io.IOException;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.BiFunction;\n\npublic class SecretsModule extends SimpleModule {\n\n  public static final SecretsModule INSTANCE = new SecretsModule();\n\n  public static final String PREFIX = \"secret://\";\n\n  private final AtomicReference<SecretStore> secretStoreHolder = new AtomicReference<>(null);\n\n\n  private SecretsModule() {\n    addDeserializer(SecretString.class, createDeserializer(SecretStore::secretString));\n    addDeserializer(SecretBytes.class, createDeserializer(SecretStore::secretBytesFromBase64String));\n    addDeserializer(SecretStringList.class, createDeserializer(SecretStore::secretStringList));\n    addDeserializer(SecretBytesList.class, createDeserializer(SecretStore::secretBytesListFromBase64Strings));\n  }\n\n  public void setSecretStore(final SecretStore secretStore) {\n    this.secretStoreHolder.set(requireNonNull(secretStore));\n  }\n\n  private <T> JsonDeserializer<T> createDeserializer(final BiFunction<SecretStore, String, T> constructor) {\n    return new JsonDeserializer<>() {\n      @Override\n      public T deserialize(final JsonParser p, final DeserializationContext ctxt) throws IOException, JacksonException {\n        final SecretStore secretStore = secretStoreHolder.get();\n        if (secretStore == null) {\n          throw new IllegalStateException(\n              \"An instance of a SecretStore must be set for the SecretsModule via setSecretStore() method\");\n        }\n        final String reference = p.getValueAsString();\n        if (!reference.startsWith(PREFIX) || reference.length() <= PREFIX.length()) {\n          throw new IllegalArgumentException(\n              \"Value of a secret field must start with a [%s] prefix and refer to an entry in a secrets bundle\".formatted(PREFIX));\n        }\n        return constructor.apply(secretStore, reference.substring(PREFIX.length()));\n      }\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.DELETE;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HEAD;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.util.Base64;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.libsignal.usernames.BaseUsernameException;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.ApnRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;\nimport org.whispersystems.textsecuregcm.entities.DeviceName;\nimport org.whispersystems.textsecuregcm.entities.EncryptedUsername;\nimport org.whispersystems.textsecuregcm.entities.GcmRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.RegistrationLock;\nimport org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;\nimport org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;\nimport org.whispersystems.textsecuregcm.entities.UsernameHashResponse;\nimport org.whispersystems.textsecuregcm.entities.UsernameLinkHandle;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimitedByIp;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;\nimport org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n@Path(\"/v1/accounts\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Account\")\npublic class AccountController {\n  public static final int MAXIMUM_USERNAME_HASHES_LIST_LENGTH = 20;\n  public static final int USERNAME_HASH_LENGTH = 32;\n  public static final int MAXIMUM_USERNAME_CIPHERTEXT_LENGTH = 128;\n\n  private static final String RECOVERY_PASSWORD_SET_COUNTER_NAME =\n      name(AccountController.class, \"recoveryPasswordSet\");\n\n\n  private final AccountsManager accounts;\n  private final RateLimiters rateLimiters;\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n  private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;\n\n  public AccountController(\n      AccountsManager accounts,\n      RateLimiters rateLimiters,\n      RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n      UsernameHashZkProofVerifier usernameHashZkProofVerifier) {\n    this.accounts = accounts;\n    this.rateLimiters = rateLimiters;\n    this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;\n    this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;\n  }\n\n  @PUT\n  @Path(\"/gcm/\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  public void setGcmRegistrationId(@Auth AuthenticatedDevice auth,\n      @NotNull @Valid GcmRegistrationId registrationId) {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final Device device = account.getDevice(auth.deviceId())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    if (Objects.equals(device.getGcmId(), registrationId.gcmRegistrationId())) {\n      return;\n    }\n\n    accounts.updateDevice(account, device.getId(), d -> {\n      d.setApnId(null);\n      d.setGcmId(registrationId.gcmRegistrationId());\n      d.setFetchesMessages(false);\n    });\n  }\n\n  @DELETE\n  @Path(\"/gcm/\")\n  public void deleteGcmRegistrationId(@Auth AuthenticatedDevice auth) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final Device device = account.getDevice(auth.deviceId())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    accounts.updateDevice(account, device.getId(), d -> {\n      d.setGcmId(null);\n      d.setFetchesMessages(false);\n      d.setUserAgent(\"OWA\");\n    });\n  }\n\n  @PUT\n  @Path(\"/apn/\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  public void setApnRegistrationId(@Auth AuthenticatedDevice auth,\n      @NotNull @Valid ApnRegistrationId registrationId) {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final Device device = account.getDevice(auth.deviceId())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    // Unlike FCM tokens, we need current \"last updated\" timestamps for APNs tokens and so update device records\n    // unconditionally\n    accounts.updateDevice(account, device.getId(), d -> {\n      d.setApnId(registrationId.apnRegistrationId());\n      d.setGcmId(null);\n      d.setFetchesMessages(false);\n    });\n  }\n\n  @DELETE\n  @Path(\"/apn/\")\n  public void deleteApnRegistrationId(@Auth AuthenticatedDevice auth) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final Device device = account.getDevice(auth.deviceId())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    accounts.updateDevice(account, device.getId(), d -> {\n      d.setApnId(null);\n      d.setFetchesMessages(false);\n      if (d.getId() == 1) {\n        d.setUserAgent(\"OWI\");\n      } else {\n        d.setUserAgent(\"OWP\");\n      }\n    });\n  }\n\n  @PUT\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/registration_lock\")\n  public void setRegistrationLock(@Auth AuthenticatedDevice auth, @NotNull @Valid RegistrationLock accountLock) {\n    final SaltedTokenHash credentials = SaltedTokenHash.generateFor(accountLock.registrationLock());\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    accounts.update(account,\n        a -> a.setRegistrationLock(credentials.hash(), credentials.salt()));\n  }\n\n  @DELETE\n  @Path(\"/registration_lock\")\n  public void removeRegistrationLock(@Auth AuthenticatedDevice auth) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    accounts.update(account, a -> a.setRegistrationLock(null, null));\n  }\n\n  @PUT\n  @Path(\"/name/\")\n  @Operation(summary = \"Set a device's encrypted name\",\n  description = \"\"\"\n      Sets the encrypted name for the specified device. Primary devices may change the name of any device associated\n      with their account, but linked devices may only change their own name. If no device ID is specified, then the\n      authenticated device's ID will be used.\n      \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"Device name changed successfully\")\n  @ApiResponse(responseCode = \"404\", description = \"No device found with the given ID\")\n  @ApiResponse(responseCode = \"403\", description = \"Not authorized to change the name of the device with the given ID\")\n  public void setName(@Auth final AuthenticatedDevice auth,\n      @NotNull @Valid final DeviceName deviceName,\n\n      @Nullable\n      @QueryParam(\"deviceId\")\n      @Schema(description = \"The ID of the device for which to set a name; if omitted, the authenticated device will be targeted for a name change\",\n          requiredMode = Schema.RequiredMode.NOT_REQUIRED)\n      final Byte deviceId) {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final byte targetDeviceId = deviceId == null ? auth.deviceId() : deviceId;\n\n    if (account.getDevice(targetDeviceId).isEmpty()) {\n      throw new NotFoundException();\n    }\n\n    final boolean mayChangeName = auth.deviceId() == Device.PRIMARY_ID || auth.deviceId() == targetDeviceId;\n\n    if (!mayChangeName) {\n      throw new ForbiddenException();\n    }\n\n    accounts.updateDevice(account, targetDeviceId, d -> d.setName(deviceName.deviceName()));\n  }\n\n  @PUT\n  @Path(\"/attributes/\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  public void setAccountAttributes(\n      @Auth AuthenticatedDevice auth,\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent,\n      @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) String signalAgent,\n      @NotNull @Valid AccountAttributes attributes) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final Account updatedAccount = accounts.update(account, a -> {\n      a.getDevice(auth.deviceId()).ifPresent(d -> {\n        d.setFetchesMessages(attributes.getFetchesMessages());\n        d.setName(attributes.getName());\n        d.setLastSeen(Util.todayInMillis());\n        d.setCapabilities(attributes.getCapabilities());\n        if (StringUtils.isNotBlank(signalAgent)) {\n          d.setUserAgent(signalAgent);\n        }\n      });\n\n      a.setRegistrationLockFromAttributes(attributes);\n      a.setUnidentifiedAccessKey(attributes.getUnidentifiedAccessKey());\n      a.setUnrestrictedUnidentifiedAccess(attributes.isUnrestrictedUnidentifiedAccess());\n      a.setDiscoverableByPhoneNumber(attributes.isDiscoverableByPhoneNumber());\n    });\n\n    // if registration recovery password was sent to us, store it (or refresh its expiration)\n    attributes.recoveryPassword().ifPresent(registrationRecoveryPassword -> {\n      final boolean rrpCreated = registrationRecoveryPasswordsManager\n          .store(updatedAccount.getIdentifier(IdentityType.PNI), registrationRecoveryPassword)\n          .join();\n      Metrics.counter(RECOVERY_PASSWORD_SET_COUNTER_NAME, Tags.of(\n              UserAgentTagUtil.getPlatformTag(userAgent),\n              Tag.of(\"outcome\", rrpCreated ? \"created\" : \"updated\")))\n          .increment();\n    });\n  }\n\n  @GET\n  @Path(\"/whoami\")\n  @Produces(MediaType.APPLICATION_JSON)\n  public AccountIdentityResponse whoAmI(@Auth final AuthenticatedDevice auth) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    return AccountIdentityResponseBuilder.fromAccount(account);\n  }\n\n  @DELETE\n  @Path(\"/username_hash\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Delete username hash\",\n      description = \"\"\"\n          Authenticated endpoint. Deletes previously stored username for the account.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"204\", description = \"Username successfully deleted.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  public void deleteUsernameHash(@Auth final AuthenticatedDevice auth) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    accounts.clearUsernameHash(account);\n  }\n\n  @PUT\n  @Path(\"/username_hash/reserve\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Reserve username hash\",\n      description = \"\"\"\n          Authenticated endpoint. Takes in a list of hashes of potential username hashes, finds one that is not taken,\n          and reserves it for the current account.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Username hash reserved successfully.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"409\", description = \"All username hashes from the list are taken.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  public ReserveUsernameHashResponse reserveUsernameHash(\n      @Auth final AuthenticatedDevice auth,\n      @NotNull @Valid final ReserveUsernameHashRequest usernameRequest) throws RateLimitExceededException {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    rateLimiters.getUsernameReserveLimiter().validate(auth.accountIdentifier());\n\n    for (final byte[] hash : usernameRequest.usernameHashes()) {\n      if (hash.length != USERNAME_HASH_LENGTH) {\n        throw new WebApplicationException(Response.status(422).build());\n      }\n    }\n\n    try {\n      final AccountsManager.UsernameReservation reservation =\n          accounts.reserveUsernameHash(account, usernameRequest.usernameHashes());\n\n      return new ReserveUsernameHashResponse(reservation.reservedUsernameHash());\n    } catch (final UsernameHashNotAvailableException e) {\n      throw new WebApplicationException(Status.CONFLICT);\n    }\n  }\n\n  @PUT\n  @Path(\"/username_hash/confirm\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Confirm username hash\",\n      description = \"\"\"\n          Authenticated endpoint. For a previously reserved username hash, confirm that this username hash is now taken\n          by this account.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Username hash confirmed successfully.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"409\", description = \"Given username hash doesn't match the reserved one or no reservation found.\")\n  @ApiResponse(responseCode = \"410\", description = \"Username hash not available (username can't be used).\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  public UsernameHashResponse confirmUsernameHash(\n      @Auth final AuthenticatedDevice auth,\n      @NotNull @Valid final ConfirmUsernameHashRequest confirmRequest) throws RateLimitExceededException {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    try {\n      usernameHashZkProofVerifier.verifyProof(confirmRequest.zkProof(), confirmRequest.usernameHash());\n    } catch (final BaseUsernameException e) {\n      throw new WebApplicationException(Response.status(422).build());\n    }\n\n    rateLimiters.getUsernameSetLimiter().validate(account.getUuid());\n\n    try {\n      final Account updatedAccount = accounts.confirmReservedUsernameHash(\n          account,\n          confirmRequest.usernameHash(),\n          confirmRequest.encryptedUsername());\n\n      return new UsernameHashResponse(updatedAccount.getUsernameHash()\n          .orElseThrow(() -> new IllegalStateException(\"Could not get username after setting\")),\n          updatedAccount.getUsernameLinkHandle());\n    } catch (final UsernameReservationNotFoundException e) {\n      throw new WebApplicationException(Status.CONFLICT);\n    } catch (UsernameHashNotAvailableException e) {\n      throw new WebApplicationException(Status.GONE);\n    }\n  }\n\n  @GET\n  @Path(\"/username_hash/{usernameHash}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @RateLimitedByIp(RateLimiters.For.USERNAME_LOOKUP)\n  @Operation(\n      summary = \"Lookup username hash\",\n      description = \"\"\"\n          Forced unauthenticated endpoint. For the given username hash, look up a user ID.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Account found for the given username.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Request must not be authenticated.\")\n  @ApiResponse(responseCode = \"404\", description = \"Account not found for the given username.\")\n  public CompletableFuture<AccountIdentifierResponse> lookupUsernameHash(\n      @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,\n      @PathParam(\"usernameHash\") final String usernameHash) {\n\n    requireNotAuthenticated(maybeAuthenticatedAccount);\n    final byte[] hash;\n    try {\n      hash = Base64.getUrlDecoder().decode(usernameHash);\n    } catch (IllegalArgumentException | AssertionError e) {\n      throw new WebApplicationException(Response.status(422).build());\n    }\n\n    if (hash.length != USERNAME_HASH_LENGTH) {\n      throw new WebApplicationException(Response.status(422).build());\n    }\n\n    return accounts.getByUsernameHash(hash).thenApply(maybeAccount -> maybeAccount.map(Account::getUuid)\n        .map(AciServiceIdentifier::new)\n        .map(AccountIdentifierResponse::new)\n        .orElseThrow(() -> new WebApplicationException(Status.NOT_FOUND)));\n  }\n\n  @PUT\n  @Path(\"/username_link\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Set username link\",\n      description = \"\"\"\n          Authenticated endpoint. For the given encrypted username generates a username link handle.\n          The username link handle can be used to lookup the encrypted username.\n          An account can only have one username link at a time; this endpoint overwrites the previous encrypted username if there was one.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Username Link updated successfully.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"409\", description = \"Username is not set for the account.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  public UsernameLinkHandle updateUsernameLink(\n      @Auth final AuthenticatedDevice auth,\n      @NotNull @Valid final EncryptedUsername encryptedUsername) throws RateLimitExceededException {\n\n    // check ratelimiter for username link operations\n    rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.accountIdentifier());\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    // check if username hash is set for the account\n    if (account.getUsernameHash().isEmpty()) {\n      throw new WebApplicationException(Status.CONFLICT);\n    }\n\n    final UUID usernameLinkHandle;\n    if (encryptedUsername.keepLinkHandle() && account.getUsernameLinkHandle() != null) {\n      usernameLinkHandle = account.getUsernameLinkHandle();\n    } else {\n      usernameLinkHandle = UUID.randomUUID();\n    }\n    updateUsernameLink(account, usernameLinkHandle, encryptedUsername.usernameLinkEncryptedValue());\n    return new UsernameLinkHandle(usernameLinkHandle);\n  }\n\n  @DELETE\n  @Path(\"/username_link\")\n  @Operation(\n      summary = \"Delete username link\",\n      description = \"\"\"\n          Authenticated endpoint. Deletes username link for the given account: previously store encrypted username is deleted\n          and username link handle is deactivated.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"204\", description = \"Username Link successfully deleted.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  public void deleteUsernameLink(@Auth final AuthenticatedDevice auth) throws RateLimitExceededException {\n    // check ratelimiter for username link operations\n    rateLimiters.forDescriptor(RateLimiters.For.USERNAME_LINK_OPERATION).validate(auth.accountIdentifier());\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    clearUsernameLink(account);\n  }\n\n  @GET\n  @Path(\"/username_link/{uuid}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @RateLimitedByIp(RateLimiters.For.USERNAME_LINK_LOOKUP_PER_IP)\n  @Operation(\n      summary = \"Lookup username link\",\n      description = \"\"\"\n          Enforced unauthenticated endpoint. For the given username link handle, looks up the database for an associated encrypted username.\n          If found, encrypted username is returned, otherwise responds with 404 Not Found.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Username link with the given handle was found.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Request must not be authenticated.\")\n  @ApiResponse(responseCode = \"404\", description = \"Username link was not found for the given handle.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  public CompletableFuture<EncryptedUsername> lookupUsernameLink(\n      @Auth final Optional<AuthenticatedDevice> maybeAuthenticatedAccount,\n      @PathParam(\"uuid\") final UUID usernameLinkHandle) {\n\n    requireNotAuthenticated(maybeAuthenticatedAccount);\n\n    return accounts.getByUsernameLinkHandle(usernameLinkHandle)\n        .thenApply(maybeAccount -> maybeAccount.flatMap(Account::getEncryptedUsername)\n            .map(EncryptedUsername::new)\n            .orElseThrow(NotFoundException::new));\n  }\n\n  @Operation(\n      summary = \"Check whether an account exists\",\n      description = \"\"\"\n          Enforced unauthenticated endpoint. Checks whether an account with a given identifier exists.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"An account with the given identifier was found.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Request must not be authenticated.\")\n  @ApiResponse(responseCode = \"404\", description = \"An account was not found for the given identifier.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited.\")\n  @HEAD\n  @Path(\"/account/{identifier}\")\n  @RateLimitedByIp(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE)\n  public Response accountExists(\n      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,\n\n      @Parameter(description = \"An ACI or PNI account identifier to check\")\n      @PathParam(\"identifier\") final ServiceIdentifier accountIdentifier) {\n\n    // Disallow clients from making authenticated requests to this endpoint\n    requireNotAuthenticated(authenticatedAccount);\n\n    final Optional<Account> maybeAccount = accounts.getByServiceIdentifier(accountIdentifier);\n\n    return Response.status(maybeAccount.map(ignored -> Status.OK).orElse(Status.NOT_FOUND)).build();\n  }\n\n  @DELETE\n  @Path(\"/me\")\n  public void deleteAccount(@Auth AuthenticatedDevice auth) {\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    accounts.delete(account, AccountsManager.DeletionReason.USER_REQUEST);\n  }\n\n  private void clearUsernameLink(final Account account) {\n    updateUsernameLink(account, null, null);\n  }\n\n  private void updateUsernameLink(\n      final Account account,\n      @Nullable final UUID usernameLinkHandle,\n      @Nullable final byte[] encryptedUsername) {\n    if ((encryptedUsername == null) ^ (usernameLinkHandle == null)) {\n      throw new IllegalStateException(\"Both or neither arguments must be null\");\n    }\n    accounts.update(account, a -> a.setUsernameLinkDetails(usernameLinkHandle, encryptedUsername));\n  }\n\n  private void requireNotAuthenticated(final Optional<AuthenticatedDevice> authenticatedAccount) {\n    if (authenticatedAccount.isPresent()) {\n      throw new BadRequestException(\"Operation requires unauthenticated access\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ChangesPhoneNumber;\nimport org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;\nimport org.whispersystems.textsecuregcm.entities.AccountDataReportResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;\nimport org.whispersystems.textsecuregcm.entities.MismatchedDevicesResponse;\nimport org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;\nimport org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;\nimport org.whispersystems.textsecuregcm.entities.StaleDevicesResponse;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ChangeNumberManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\n@Path(\"/v2/accounts\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Account\")\npublic class AccountControllerV2 {\n\n  private static final String CHANGE_NUMBER_COUNTER_NAME = name(AccountControllerV2.class, \"changeNumber\");\n  private static final String VERIFICATION_TYPE_TAG_NAME = \"verification\";\n\n  private final AccountsManager accountsManager;\n  private final ChangeNumberManager changeNumberManager;\n  private final PhoneVerificationTokenManager phoneVerificationTokenManager;\n  private final RegistrationLockVerificationManager registrationLockVerificationManager;\n  private final RateLimiters rateLimiters;\n\n  public AccountControllerV2(final AccountsManager accountsManager,\n      final ChangeNumberManager changeNumberManager,\n      final PhoneVerificationTokenManager phoneVerificationTokenManager,\n      final RegistrationLockVerificationManager registrationLockVerificationManager,\n      final RateLimiters rateLimiters) {\n\n    this.accountsManager = accountsManager;\n    this.changeNumberManager = changeNumberManager;\n    this.phoneVerificationTokenManager = phoneVerificationTokenManager;\n    this.registrationLockVerificationManager = registrationLockVerificationManager;\n    this.rateLimiters = rateLimiters;\n  }\n\n  @PUT\n  @Path(\"/number\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ChangesPhoneNumber\n  @Operation(summary = \"Change number\", description = \"Changes a phone number for an existing account.\")\n  @ApiResponse(responseCode = \"200\", description = \"The phone number associated with the authenticated account was changed successfully\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"403\", description = \"Verification failed for the provided Registration Recovery Password\")\n  @ApiResponse(responseCode = \"409\", description = \"Mismatched number of devices or device ids in 'devices to notify' list\", content = @Content(schema = @Schema(implementation = MismatchedDevicesResponse.class)))\n  @ApiResponse(responseCode = \"410\", description = \"Mismatched registration ids in 'devices to notify' list\", content = @Content(schema = @Schema(implementation = StaleDevicesResponse.class)))\n  @ApiResponse(responseCode = \"413\", description = \"One or more device messages was too large\")\n  @ApiResponse(responseCode = \"422\", description = \"The request did not pass validation\")\n  @ApiResponse(responseCode = \"423\", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public AccountIdentityResponse changeNumber(@Auth final AuthenticatedDevice authenticatedDevice,\n      @NotNull @Valid final ChangeNumberRequest request,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,\n      @Context final ContainerRequestContext requestContext) throws RateLimitExceededException, InterruptedException {\n\n    if (authenticatedDevice.deviceId() != Device.PRIMARY_ID) {\n      throw new ForbiddenException();\n    }\n\n    if (!request.isSignatureValidOnEachSignedPreKey(userAgentString)) {\n      throw new WebApplicationException(\"Invalid signature\", 422);\n    }\n\n    final String number = request.number();\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    // Only verify and check reglock if there's a data change to be made...\n    if (!account.getNumber().equals(number)) {\n\n      rateLimiters.getRegistrationLimiter().validate(number);\n\n      final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(\n          requestContext, number, request);\n\n      final Optional<Account> existingAccount = accountsManager.getByE164(number);\n\n      if (existingAccount.isPresent()) {\n        registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(), request.registrationLock(),\n            userAgentString, RegistrationLockVerificationManager.Flow.CHANGE_NUMBER, verificationType);\n      }\n\n      Metrics.counter(CHANGE_NUMBER_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgentString),\n              Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))\n          .increment();\n    }\n\n    // ...but always attempt to make the change in case a client retries and needs to re-send messages\n    try {\n      final Account updatedAccount = changeNumberManager.changeNumber(\n          account,\n          request.number(),\n          request.pniIdentityKey(),\n          request.devicePniSignedPrekeys(),\n          request.devicePniPqLastResortPrekeys(),\n          request.deviceMessages(),\n          request.pniRegistrationIds(),\n          userAgentString);\n\n      return AccountIdentityResponseBuilder.fromAccount(updatedAccount);\n    } catch (MismatchedDevicesException e) {\n      if (!e.getMismatchedDevices().staleDeviceIds().isEmpty()) {\n        throw new WebApplicationException(Response.status(410)\n            .type(MediaType.APPLICATION_JSON)\n            .entity(new StaleDevicesResponse(e.getMismatchedDevices().staleDeviceIds()))\n            .build());\n      } else {\n        throw new WebApplicationException(Response.status(409)\n            .type(MediaType.APPLICATION_JSON_TYPE)\n            .entity(new MismatchedDevicesResponse(e.getMismatchedDevices().missingDeviceIds(),\n                e.getMismatchedDevices().extraDeviceIds()))\n            .build());\n      }\n    } catch (IllegalArgumentException e) {\n      throw new BadRequestException(e);\n    } catch (MessageTooLargeException e) {\n      throw new WebApplicationException(Response.Status.REQUEST_ENTITY_TOO_LARGE);\n    }\n  }\n\n  @PUT\n  @Path(\"/phone_number_discoverability\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Sets whether the account should be discoverable by phone number in the directory.\")\n  @ApiResponse(responseCode = \"204\", description = \"The setting was successfully updated.\")\n  public void setPhoneNumberDiscoverability(\n      @Auth AuthenticatedDevice auth,\n      @NotNull @Valid PhoneNumberDiscoverabilityRequest phoneNumberDiscoverability) {\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    accountsManager.update(account, a -> a.setDiscoverableByPhoneNumber(\n        phoneNumberDiscoverability.discoverableByPhoneNumber()));\n  }\n\n  @GET\n  @Path(\"/data_report\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Produces a report of non-ephemeral account data stored by the service\")\n  @ApiResponse(responseCode = \"200\",\n      description = \"Response with data report. A plain text representation is a field in the response.\",\n      useReturnTypeSchema = true)\n  public AccountDataReportResponse getAccountDataReport(@Auth final AuthenticatedDevice auth) {\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    return new AccountDataReportResponse(UUID.randomUUID(), Instant.now(),\n        new AccountDataReportResponse.AccountAndDevicesDataReport(\n            new AccountDataReportResponse.AccountDataReport(\n                account.getNumber(),\n                account.getBadges().stream().map(AccountDataReportResponse.BadgeDataReport::new).toList(),\n                account.isUnrestrictedUnidentifiedAccess(),\n                account.isDiscoverableByPhoneNumber()),\n            account.getDevices().stream().map(device ->\n                new AccountDataReportResponse.DeviceDataReport(\n                    device.getId(),\n                    Instant.ofEpochMilli(device.getLastSeen()),\n                    Instant.ofEpochMilli(device.getCreated()),\n                    device.getUserAgent())).toList()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilder.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport java.time.Clock;\nimport java.util.List;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.Entitlements;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\n\npublic class AccountIdentityResponseBuilder {\n\n  private final Account account;\n  private boolean storageCapable;\n  private Clock clock;\n\n  public AccountIdentityResponseBuilder(Account account) {\n    this.account = account;\n    this.storageCapable = account.hasCapability(DeviceCapability.STORAGE);\n    this.clock = Clock.systemUTC();\n  }\n\n  public AccountIdentityResponseBuilder storageCapable(boolean storageCapable) {\n    this.storageCapable = storageCapable;\n    return this;\n  }\n\n  public AccountIdentityResponseBuilder clock(Clock clock) {\n    this.clock = clock;\n    return this;\n  }\n\n  public AccountIdentityResponse build() {\n    final List<Entitlements.BadgeEntitlement> badges = account.getBadges()\n        .stream()\n        .filter(bv -> bv.expiration().isAfter(clock.instant()))\n        .map(badge -> new Entitlements.BadgeEntitlement(badge.id(), badge.expiration(), badge.visible()))\n        .toList();\n\n    final Entitlements.BackupEntitlement backupEntitlement = Optional\n        .ofNullable(account.getBackupVoucher())\n        .filter(bv -> bv.expiration().isAfter(clock.instant()))\n        .map(bv -> new Entitlements.BackupEntitlement(bv.receiptLevel(), bv.expiration()))\n        .orElse(null);\n\n    return new AccountIdentityResponse(account.getUuid(),\n        account.getNumber(),\n        account.getPhoneNumberIdentifier(),\n        account.getUsernameHash().filter(h -> h.length > 0).orElse(null),\n        account.getUsernameLinkHandle(),\n        storageCapable,\n        new Entitlements(badges, backupEntitlement));\n  }\n\n  public static AccountIdentityResponse fromAccount(final Account account) {\n    return new AccountIdentityResponseBuilder(account).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/ArchiveController.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Tag;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.PositiveOrZero;\nimport jakarta.validation.constraints.Size;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.ClientErrorException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.DELETE;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.backup.BackupBadReceiptException;\nimport org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;\nimport org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;\nimport org.whispersystems.textsecuregcm.backup.BackupNotFoundException;\nimport org.whispersystems.textsecuregcm.backup.BackupPermissionException;\nimport org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;\nimport org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;\nimport org.whispersystems.textsecuregcm.backup.CopyParameters;\nimport org.whispersystems.textsecuregcm.backup.CopyResult;\nimport org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachment;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.BackupAuthCredentialAdapter;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\n@Path(\"/v1/archives\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Archive\")\npublic class ArchiveController {\n\n  public final static String X_SIGNAL_ZK_AUTH = \"X-Signal-ZK-Auth\";\n  public final static String X_SIGNAL_ZK_AUTH_SIGNATURE = \"X-Signal-ZK-Auth-Signature\";\n\n  private final AccountsManager accountsManager;\n  private final BackupAuthManager backupAuthManager;\n  private final BackupManager backupManager;\n  private final BackupMetrics backupMetrics;\n\n  public ArchiveController(\n      final AccountsManager accountsManager,\n      final BackupAuthManager backupAuthManager,\n      final BackupManager backupManager,\n      final BackupMetrics backupMetrics) {\n\n    this.accountsManager = accountsManager;\n    this.backupAuthManager = backupAuthManager;\n    this.backupManager = backupManager;\n    this.backupMetrics = backupMetrics;\n  }\n\n  public record SetBackupIdRequest(\n      @Schema(description = \"\"\"\n          A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64.\n          This backup-id should be used for message backups only, and must have the message backup type set on the\n          credential. If absent, the message credential request will not be updated.\n          \"\"\", implementation = String.class)\n      @JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)\n      @JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)\n      BackupAuthCredentialRequest messagesBackupAuthCredentialRequest,\n\n      @Schema(description = \"\"\"\n          A BackupAuthCredentialRequest containing a blinded encrypted backup-id, encoded in standard padded base64.\n          This backup-id should be used for media only, and must have the media type set on the credential. If absent,\n          only the media credential request will not be updated.\n          \"\"\", implementation = String.class)\n      @JsonDeserialize(using = BackupAuthCredentialAdapter.CredentialRequestDeserializer.class)\n      @JsonSerialize(using = BackupAuthCredentialAdapter.CredentialRequestSerializer.class)\n      BackupAuthCredentialRequest mediaBackupAuthCredentialRequest) {}\n\n\n  @PUT\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/backupid\")\n  @Operation(\n      summary = \"Set backup id\",\n      description = \"\"\"\n          Set (blinded) backup-id(s) for the account. Each account may have a single active backup-id for each\n          credential type that can be used to store and retrieve backups. Once the backup-id is set,\n          BackupAuthCredentials can be generated using /v1/archives/auth.\n\n          The blinded backup-id and the key-pair used to blind it should be derived from a recoverable secret.\n          \n          At least one of `messagesBackupAuthCredentialRequest`, `mediaBackupAuthCredentialRequest` must be set.\n          \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"The backup-id was set\")\n  @ApiResponse(responseCode = \"400\", description = \"The provided backup auth credential request was invalid\")\n  @ApiResponse(responseCode = \"403\", description = \"The device did not have permission to set the backup-id. Only the primary device can set the backup-id for an account\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited. Too many attempts to change the backup-id have been made\")\n  @ManagedAsync\n  public void setBackupId(\n      @Auth final AuthenticatedDevice authenticatedDevice,\n      @Valid @NotNull final SetBackupIdRequest setBackupIdRequest)\n      throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n    final Device device = account.getDevice(authenticatedDevice.deviceId())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    backupAuthManager\n        .commitBackupId(account, device,\n            Optional.ofNullable(setBackupIdRequest.messagesBackupAuthCredentialRequest),\n            Optional.ofNullable(setBackupIdRequest.mediaBackupAuthCredentialRequest));\n  }\n\n\n  public record BackupIdLimitResponse(\n      @Schema(description = \"If true, a call to PUT /v1/archive/backupid may succeed without waiting\")\n      boolean hasPermitsRemaining,\n      @Schema(description = \"How long to wait before a permit becomes available, in seconds\")\n      long retryAfterSeconds) {}\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/backupid/limits\")\n  @Operation(\n      summary = \"Retrieve limits\",\n      description = \"\"\"\n          Determine whether the backup-id can currently be rotated\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Successfully retrieved backup-id rotation limits\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"403\", description = \"Invalid account authentication\")\n  @ManagedAsync\n  public BackupIdLimitResponse checkLimits(@Auth final AuthenticatedDevice authenticatedDevice) {\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    final BackupAuthManager.BackupIdRotationLimit limit = backupAuthManager.checkBackupIdRotationLimit(account);\n    return new BackupIdLimitResponse(limit.hasPermitsRemaining(), limit.nextPermitAvailable().getSeconds());\n  }\n\n  public record RedeemBackupReceiptRequest(\n      @Schema(description = \"Presentation of a ZK receipt encoded in standard padded base64\", implementation = String.class)\n      @JsonDeserialize(using = Deserializer.class)\n      @NotNull\n      ReceiptCredentialPresentation receiptCredentialPresentation) {\n\n    public static class Deserializer extends JsonDeserializer<ReceiptCredentialPresentation> {\n\n      @Override\n      public ReceiptCredentialPresentation deserialize(JsonParser jsonParser,\n          DeserializationContext deserializationContext) throws IOException {\n        try {\n          return new ReceiptCredentialPresentation(Base64.getDecoder().decode(jsonParser.getValueAsString()));\n        } catch (InvalidInputException e) {\n          throw new IllegalArgumentException(e);\n        }\n      }\n    }\n  }\n\n  @POST\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/redeem-receipt\")\n  @Operation(\n      summary = \"Redeem receipt\",\n      description = \"\"\"\n          Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials to mark the account as\n          eligible for the paid backup tier.\n\n          After successful redemption, subsequent requests to /v1/archive/auth will return credentials with the level on\n          the provided receipt until the expiration time on the receipt.\n\n          Accounts must have an existing backup credential request in order to redeem a receipt. This request will fail\n          if the account has not already set a backup credential request via PUT `/v1/archives/backupid`.\n          \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"The receipt was redeemed\")\n  @ApiResponse(responseCode = \"400\", description = \"The provided presentation or receipt was invalid\")\n  @ApiResponse(responseCode = \"409\", description = \"The target account does not have a backup-id commitment\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ManagedAsync\n  public Response redeemReceipt(\n      @Auth final AuthenticatedDevice authenticatedDevice,\n      @Valid @NotNull final RedeemBackupReceiptRequest redeemBackupReceiptRequest)\n      throws BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    backupAuthManager.redeemReceipt(account, redeemBackupReceiptRequest.receiptCredentialPresentation());\n    return Response.noContent().build();\n  }\n\n  public record BackupAuthCredentialsResponse(\n      @Schema(description = \"A map of credential types to lists of BackupAuthCredentials and their validity periods\")\n      Map<CredentialType, List<BackupAuthCredential>> credentials) {\n\n    public enum CredentialType {\n      MESSAGES,\n      MEDIA;\n\n      @JsonValue\n      public String toValue() {\n        return this.name().toLowerCase(Locale.ROOT);\n      }\n\n      @JsonCreator\n      public static CredentialType fromValue(String v) {\n        return v == null ? null : CredentialType.valueOf(v.toUpperCase(Locale.ROOT));\n      }\n\n      @VisibleForTesting\n      static CredentialType fromLibsignalType(BackupCredentialType backupCredentialType) {\n        return switch (backupCredentialType) {\n          case MESSAGES -> BackupAuthCredentialsResponse.CredentialType.MESSAGES;\n          case MEDIA -> BackupAuthCredentialsResponse.CredentialType.MEDIA;\n        };\n      }\n    }\n\n    public record BackupAuthCredential(\n        @Schema(description = \"A BackupAuthCredential, encoded in standard padded base64\")\n        byte[] credential,\n        @Schema(description = \"The day on which this credential is valid. Seconds since epoch truncated to day boundary\")\n        long redemptionTime) {}\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/auth\")\n  @Operation(\n      summary = \"Fetch ZK credentials \",\n      description = \"\"\"\n          After setting a blinded backup-id with PUT /v1/archives/, this fetches credentials that can be used to perform\n          operations against that backup-id. Clients may (and should) request up to 7 days of credentials at a time.\n\n          The redemptionStart and redemptionEnd seconds must be UTC day aligned, and must not span more than 7 days.\n\n          Each credential contains a receipt level which indicates the backup level the credential is good for. If the\n          account has paid backup access that expires at some point in the provided redemption window, credentials with\n          redemption times after the expiration may be on a lower backup level.\n\n          Clients must validate the receipt level on the credential matches a known receipt level before using it.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = BackupAuthCredentialsResponse.class)))\n  @ApiResponse(responseCode = \"400\", description = \"The start/end did not meet alignment/duration requirements\")\n  @ApiResponse(responseCode = \"404\", description = \"Could not find an existing blinded backup id\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ManagedAsync\n  public BackupAuthCredentialsResponse getBackupZKCredentials(\n      @Auth AuthenticatedDevice authenticatedDevice,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @NotNull @QueryParam(\"redemptionStartSeconds\") Long startSeconds,\n      @NotNull @QueryParam(\"redemptionEndSeconds\") Long endSeconds) throws BackupNotFoundException {\n\n    final RedemptionRange redemptionRange;\n    try {\n      redemptionRange = RedemptionRange.inclusive(Clock.systemUTC(), Instant.ofEpochSecond(startSeconds), Instant.ofEpochSecond(endSeconds));\n    } catch (IllegalArgumentException e) {\n      throw new BadRequestException(e.getMessage());\n    }\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    final Map<BackupCredentialType, List<BackupAuthManager.Credential>> credentials =\n        backupAuthManager.getBackupAuthCredentials(account, redemptionRange);\n\n    final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);\n    credentials.forEach((type, credentialList) ->\n        backupMetrics.updateGetCredentialCounter(platformTag, type, credentialList.size()));\n\n    return new BackupAuthCredentialsResponse(credentials.entrySet().stream().collect(Collectors.toMap(\n            e -> BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(e.getKey()),\n            e -> e.getValue().stream()\n                .map(credential -> new BackupAuthCredentialsResponse.BackupAuthCredential(\n                    credential.credential().serialize(),\n                    credential.redemptionTime().getEpochSecond()))\n                .toList())));\n  }\n\n\n  /**\n   * API annotation for endpoints that take anonymous auth. All anonymous endpoints\n   * <li> 400 if regular auth is used by accident </li>\n   * <li> 401 if the anonymous auth invalid </li>\n   * <li> 403 if the anonymous credential does not have sufficient permissions </li>\n   */\n  @Target(ElementType.METHOD)\n  @Retention(RetentionPolicy.RUNTIME)\n  @ApiResponse(\n      responseCode = \"403\",\n      description = \"Forbidden. The request had insufficient permissions to perform the requested action\")\n  @ApiResponse(responseCode = \"401\", description = \"\"\"\n      The provided backup auth credential presentation could not be verified or\n      The public key signature was invalid or\n      There is no backup associated with the backup-id in the presentation or\n      The credential was of the wrong type (messages/media)\"\"\")\n  @ApiResponse(responseCode = \"400\", description = \"Bad arguments. The request may have been made on an authenticated channel\")\n  @interface ApiResponseZkAuth {}\n\n  public record BackupAuthCredentialPresentationHeader(BackupAuthCredentialPresentation presentation) {\n\n    private static final String DESCRIPTION = \"Presentation of a ZK backup auth credential acquired from /v1/archives/auth, encoded in standard padded base64\";\n\n    public BackupAuthCredentialPresentationHeader(final String header) {\n      this(deserialize(header));\n    }\n\n    private static BackupAuthCredentialPresentation deserialize(final String base64Presentation) {\n      byte[] bytes = Base64.getDecoder().decode(base64Presentation);\n      try {\n        return new BackupAuthCredentialPresentation(bytes);\n      } catch (InvalidInputException e) {\n        throw new IllegalArgumentException(e);\n      }\n    }\n  }\n\n  public record BackupAuthCredentialPresentationSignature(byte[] signature) {\n\n    private static final String DESCRIPTION = \"Signature of the ZK auth credential's presentation, encoded in standard padded base64\";\n\n    public BackupAuthCredentialPresentationSignature(final String header) {\n      this(Base64.getDecoder().decode(header));\n    }\n  }\n\n  public record ReadAuthResponse(\n      @Schema(description = \"Auth headers to include with cdn read requests\") Map<String, String> headers) {}\n\n  @GET\n  @Path(\"/auth/read\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Get CDN read credentials\",\n      description = \"Retrieve credentials used to read objects stored on the backup cdn\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = ReadAuthResponse.class)))\n  @ApiResponse(responseCode = \"400\", description = \"Bad arguments. The request may have been made on an authenticated channel, or an invalid cdn number was provided\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public ReadAuthResponse readAuth(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @NotNull @Parameter(description = \"The number of the CDN to get credentials for\") @QueryParam(\"cdn\") final Integer cdn)\n      throws BackupFailedZkAuthenticationException, BackupInvalidArgumentException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    return new ReadAuthResponse(backupManager.generateReadAuth(backupUser, cdn));\n  }\n\n  @GET\n  @Path(\"/auth/svrb\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Generate credentials for SVRB\",\n      description = \"\"\"\n          Generate SVRB service credentials. Generated credentials have an expiration time of 1 day (subject to change)\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with generated credentials.\", useReturnTypeSchema = true)\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public ExternalServiceCredentials svrbAuth(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)\n      throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    return backupManager.generateSvrbAuth(backupUser);\n  }\n\n  public record BackupInfoResponse(\n      @Schema(description = \"The CDN type where the message backup is stored. Media may be stored elsewhere.\")\n      int cdn,\n\n      @Schema(description = \"\"\"\n          The base directory of your backup data on the cdn. The message backup can be found in the returned cdn at\n          /backupDir/backupName and stored media can be found at /backupDir/mediaDir/mediaId\n          \"\"\")\n      String backupDir,\n\n      @Schema(description = \"\"\"\n          The prefix path component for media objects on a cdn. Stored media for mediaId can be found at\n          /backupDir/mediaDir/mediaId.\n          \"\"\")\n      String mediaDir,\n\n      @Schema(description = \"The name of the most recent message backup on the cdn. The backup is at /backupDir/backupName\")\n      String backupName,\n\n      @Schema(description = \"The amount of space used to store media\")\n      long usedSpace) {}\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Fetch backup info\",\n      description = \"Retrieve information about the currently stored backup\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = BackupInfoResponse.class)))\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public BackupInfoResponse backupInfo(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)\n      throws BackupFailedZkAuthenticationException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    final BackupManager.BackupInfo backupInfo = backupManager.backupInfo(backupUser);\n    return new BackupInfoResponse(\n            backupInfo.cdn(),\n            backupInfo.backupSubdir(),\n            backupInfo.mediaSubdir(),\n            backupInfo.messageBackupKey(),\n            backupInfo.mediaUsedSpace().orElse(0L));\n  }\n\n  public record SetPublicKeyRequest(\n      @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class)\n      @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class)\n      @NotNull\n      @Schema(type = \"string\", description = \"The public key, serialized in libsignal's elliptic-curve public key format, and encoded in standard padded base64.\")\n      ECPublicKey backupIdPublicKey) {}\n\n  @PUT\n  @Path(\"/keys\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Set public key\",\n      description = \"\"\"\n          Permanently set the public key of an ED25519 key-pair for the backup-id. All requests that provide a anonymous\n          BackupAuthCredentialPresentation (including this one!) must also sign the presentation with the private key\n          corresponding to the provided public key.\n          \"\"\")\n  @ApiResponseZkAuth\n  @ApiResponse(responseCode = \"204\", description = \"The public key was set\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ManagedAsync\n  public void setPublicKey(\n      @Auth final Optional<AuthenticatedDevice> account,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @Valid @NotNull SetPublicKeyRequest setPublicKeyRequest)\n      throws BackupFailedZkAuthenticationException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    backupManager.setPublicKey(presentation.presentation, signature.signature, setPublicKeyRequest.backupIdPublicKey);\n  }\n\n\n  public record UploadDescriptorResponse(\n      @Schema(description = \"Indicates the CDN type. 3 indicates resumable uploads using TUS\")\n      int cdn,\n      @Schema(description = \"The location within the specified cdn where the finished upload can be found.\")\n      String key,\n      @Schema(description = \"A map of headers to include with all upload requests. Potentially contains time-limited upload credentials\")\n      Map<String, String> headers,\n      @Schema(description = \"The URL to upload to with the appropriate protocol\")\n      String signedUploadLocation) {}\n\n  @GET\n  @Path(\"/upload/form\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Fetch message backup upload form\",\n      description = \"Retrieve an upload form that can be used to perform a resumable upload of a message backup.\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = UploadDescriptorResponse.class)))\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponse(responseCode = \"413\", description = \"The provided uploadLength is larger than the maximum supported upload size. The maximum upload size is subject to change.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public UploadDescriptorResponse backup(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @Parameter(description = \"The size of the message backup to upload in bytes\")\n      @QueryParam(\"uploadLength\") final Optional<Long> uploadLength)\n      throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n\n    final boolean oversize = uploadLength\n        .map(length -> length > BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE)\n        .orElse(false);\n\n    backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, uploadLength);\n    if (oversize) {\n      throw new ClientErrorException(\"exceeded maximum uploadLength\", Response.Status.REQUEST_ENTITY_TOO_LARGE);\n    }\n    final BackupUploadDescriptor uploadDescriptor =\n        backupManager.createMessageBackupUploadDescriptor(backupUser);\n    return new UploadDescriptorResponse(\n        uploadDescriptor.cdn(),\n        uploadDescriptor.key(),\n        uploadDescriptor.headers(),\n        uploadDescriptor.signedUploadLocation());\n  }\n\n  @GET\n  @Path(\"/media/upload/form\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Fetch media attachment upload form\",\n      description = \"\"\"\n          Retrieve an upload form that can be used to perform a resumable upload of an attachment. After uploading, the\n          attachment can be copied into the backup at PUT /archives/media/.\n\n          Like the account authenticated version at /attachments, the uploaded object is only temporary.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = UploadDescriptorResponse.class)))\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public UploadDescriptorResponse uploadTemporaryAttachment(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)\n      throws RateLimitExceededException, BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    final BackupUploadDescriptor uploadDescriptor =\n        backupManager.createTemporaryAttachmentUploadDescriptor(backupUser);\n    return new UploadDescriptorResponse(\n        uploadDescriptor.cdn(),\n        uploadDescriptor.key(),\n        uploadDescriptor.headers(),\n        uploadDescriptor.signedUploadLocation());\n  }\n\n  public record CopyMediaRequest(\n      @Schema(description = \"The object on the attachment CDN to copy\")\n      @NotNull\n      @Valid\n      RemoteAttachment sourceAttachment,\n\n      @Schema(description = \"The length of the source attachment before the encryption applied by the copy operation\")\n      @NotNull\n      @PositiveOrZero\n      int objectLength,\n\n      @Schema(description = \"mediaId to copy on to the backup CDN, encoded in URL-safe padded base64\", implementation = String.class)\n      @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n      @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(15)\n      byte[] mediaId,\n\n      @Schema(description = \"A 32-byte key for the MAC, encoded in standard padded base64\", implementation = String.class)\n      @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(32)\n      byte[] hmacKey,\n\n      @Schema(description = \"A 32-byte encryption key for AES, encoded in standard padded base64\", implementation = String.class)\n      @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(32)\n      byte[] encryptionKey) {\n\n    CopyParameters toCopyParameters() {\n      return new CopyParameters(\n          sourceAttachment.cdn(), sourceAttachment.key(),\n          objectLength,\n          new MediaEncryptionParameters(encryptionKey, hmacKey),\n          mediaId);\n    }\n  }\n\n  public record CopyMediaResponse(\n      @Schema(description = \"The backup cdn where this media object is stored\")\n      @NotNull\n      Integer cdn) {}\n\n  @PUT\n  @Path(\"/media/\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Backup media\",\n      description = \"\"\"\n          Copy and re-encrypt media from the attachments cdn into the backup cdn.\n\n          The original, already encrypted, attachment will be encrypted with the provided key material before being copied.\n\n          A particular destination media id should not be reused with a different source media id or different encryption\n          parameters.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = CopyMediaResponse.class)))\n  @ApiResponse(responseCode = \"400\", description = \"The provided object length was incorrect\")\n  @ApiResponse(responseCode = \"413\", description = \"All media capacity has been consumed. Free some space to continue.\")\n  @ApiResponse(responseCode = \"410\", description = \"The source object was not found.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public CopyMediaResponse copyMedia(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @NotNull\n      @Valid final ArchiveController.CopyMediaRequest copyMediaRequest)\n      throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    final BackupManager.CopyQuota copyQuota =\n        backupManager.getCopyQuota(backupUser, List.of(copyMediaRequest.toCopyParameters()));\n    final CopyResult copyResult = backupManager.copyToBackup(copyQuota).next()\n            .blockOptional()\n            .orElseThrow(() -> new IllegalStateException(\"Non empty copy request must return result\"));\n    backupMetrics.updateCopyCounter(copyResult, UserAgentTagUtil.getPlatformTag(userAgent));\n    return switch (copyResult.outcome()) {\n      case SUCCESS -> new CopyMediaResponse(copyResult.cdn());\n      case SOURCE_WRONG_LENGTH -> throw new BadRequestException(\"Invalid length\");\n      case SOURCE_NOT_FOUND -> throw new ClientErrorException(\"Source object not found\", Response.Status.GONE);\n      case OUT_OF_QUOTA ->\n          throw new ClientErrorException(\"Media quota exhausted\", Response.Status.REQUEST_ENTITY_TOO_LARGE);\n    };\n  }\n\n  public record CopyMediaBatchRequest(\n      @Schema(description = \"A list of media objects to copy from the attachments CDN to the backup CDN\")\n      @NotNull\n      @Size(min = 1, max = 1000)\n      List<@Valid CopyMediaRequest> items) {}\n\n  public record CopyMediaBatchResponse(\n\n      @Schema(description = \"Detailed outcome information for each copy request in the batch\")\n      List<Entry> responses) {\n\n    public record Entry(\n        @Schema(description = \"\"\"\n            The outcome of the copy attempt.\n            A 200 indicates the object was successfully copied.\n            A 400 indicates an invalid argument in the request\n            A 410 indicates that the source object was not found\n            A 413 indicates that the media quota was exhausted\n            \"\"\")\n        int status,\n\n        @Schema(description = \"On a copy failure, a detailed failure reason\")\n        String failureReason,\n\n        @Schema(description = \"The backup cdn where this media object is stored\")\n        Integer cdn,\n\n        @Schema(description = \"The mediaId of the object, encoded in URL-safe padded base64\", implementation = String.class)\n        @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n        @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n        @NotNull\n        @ExactlySize(15)\n        byte[] mediaId) {\n\n      static Entry fromCopyResult(final CopyResult copyResult) {\n        return switch (copyResult.outcome()) {\n          case SUCCESS -> new Entry(200, null, copyResult.cdn(), copyResult.mediaId());\n          case SOURCE_WRONG_LENGTH -> new Entry(400, \"Invalid source length\", null, copyResult.mediaId());\n          case SOURCE_NOT_FOUND -> new Entry(410, \"Source not found\", null, copyResult.mediaId());\n          case OUT_OF_QUOTA -> new Entry(413, \"Media quota exhausted\", null, copyResult.mediaId());\n        };\n      }\n    }\n  }\n\n  @PUT\n  @Path(\"/media/batch\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Batched backup media\",\n      description = \"\"\"\n          Copy and re-encrypt media from the attachments cdn into the backup cdn.\n\n          The original already encrypted attachment will be encrypted with the provided key material before being copied\n\n          If the batch request is processed at all, a 207 will be returned and the outcome of each constituent copy will\n          be provided as a separate entry in the response.\n          \"\"\")\n  @ApiResponse(responseCode = \"207\", description = \"\"\"\n      The request was processed and each operation's outcome must be inspected individually. This does NOT necessarily\n      indicate the operation was a success.\n      \"\"\", content = @Content(schema = @Schema(implementation = CopyMediaBatchResponse.class)))\n  @ApiResponse(responseCode = \"413\", description = \"All media capacity has been consumed. Free some space to continue.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public Response copyMedia(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final ArchiveController.BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @NotNull\n      @Valid final ArchiveController.CopyMediaBatchRequest copyMediaRequest)\n      throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {\n\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final Stream<CopyParameters> copyParams = copyMediaRequest.items().stream().map(CopyMediaRequest::toCopyParameters);\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    final BackupManager.CopyQuota copyQuota = backupManager.getCopyQuota(backupUser, copyParams.toList());\n    final List<CopyMediaBatchResponse.Entry> copyResults = backupManager.copyToBackup(copyQuota)\n        .doOnNext(result -> backupMetrics.updateCopyCounter(result, UserAgentTagUtil.getPlatformTag(userAgent)))\n        .map(CopyMediaBatchResponse.Entry::fromCopyResult)\n        .collectList().block();\n    return Response.status(207).entity(new CopyMediaBatchResponse(copyResults)).build();\n  }\n\n  @POST\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Refresh backup\",\n      description = \"\"\"\n          Indicate that this backup is still active. Clients must periodically upload new backups or perform a refresh\n          via a POST request. If a backup is not refreshed, after 30 days it may be deleted.\n          \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"The backup was successfully refreshed\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public void refresh(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)\n      throws BackupFailedZkAuthenticationException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    backupManager.ttlRefresh(backupUser);\n  }\n\n  record StoredMediaObject(\n\n      @Schema(description = \"The backup cdn where this media object is stored\")\n      @NotNull\n      Integer cdn,\n\n      @Schema(description = \"The mediaId of the object in URL-safe base64\", implementation = String.class)\n      @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n      @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(15)\n      byte[] mediaId,\n\n      @Schema(description = \"The length of the object in bytes\")\n      @NotNull\n      Long objectLength) {}\n\n  public record ListResponse(\n      @Schema(description = \"A page of media objects stored for this backup ID\")\n      List<StoredMediaObject> storedMediaObjects,\n\n      @Schema(description = \"\"\"\n          The base directory of your backup data on the cdn. The stored media can be found at /backupDir/mediaDir/mediaId\n          \"\"\")\n      String backupDir,\n\n      @Schema(description = \"\"\"\n          The prefix path component for the media objects. The stored media for mediaId can be found at /backupDir/mediaDir/mediaId.\n          \"\"\")\n      String mediaDir,\n      @Schema(description = \"If set, the cursor value to pass to the next list request to continue listing. If absent, all objects have been listed\")\n      String cursor) {}\n\n  @GET\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/media\")\n  @Operation(summary = \"List media objects\",\n      description = \"\"\"\n          Retrieve a list of media objects stored for this backup-id. A client may have previously stored media objects\n          that are no longer referenced in their current backup. To reclaim storage space used by these orphaned\n          objects, perform a list operation and remove any unreferenced media objects via DELETE /v1/backups/<mediaId>.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", content = @Content(schema = @Schema(implementation = ListResponse.class)))\n  @ApiResponse(responseCode = \"400\", description = \"Invalid cursor or limit\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public ListResponse listMedia(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @Parameter(description = \"A cursor returned by a previous call\")\n      @QueryParam(\"cursor\") final Optional<String> cursor,\n\n      @Parameter(description = \"The number of entries to return per call\")\n      @QueryParam(\"limit\") final Optional<@Min(1) @Max(10_000) Integer> limit)\n      throws BackupPermissionException, BackupFailedZkAuthenticationException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    final BackupManager.ListMediaResult listResult =\n        backupManager.list(backupUser, cursor, limit.orElse(1000));\n    return new ListResponse(listResult.media()\n            .stream().map(entry -> new StoredMediaObject(entry.cdn(), entry.key(), entry.length()))\n            .toList(),\n        backupUser.backupDir(),\n        backupUser.mediaDir(),\n        listResult.cursor().orElse(null));\n  }\n\n  public record DeleteMedia(@Size(min = 1, max = 1000) List<@Valid MediaToDelete> mediaToDelete) {\n\n    public record MediaToDelete(\n        @Schema(description = \"The backup cdn where this media object is stored\")\n        @NotNull\n        Integer cdn,\n\n        @Schema(description = \"The mediaId of the object in URL-safe base64\", implementation = String.class)\n        @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n        @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n        @NotNull\n        @ExactlySize(15)\n        byte[] mediaId\n    ) {}\n  }\n\n  @POST\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/media/delete\")\n  @Operation(summary = \"Delete media objects\",\n      description = \"Delete media objects stored with this backup-id\")\n  @ApiResponse(responseCode = \"204\", description = \"The provided objects were successfully deleted or they do not exist\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public void deleteMedia(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature,\n\n      @Valid @NotNull DeleteMedia deleteMedia)\n      throws BackupFailedZkAuthenticationException, BackupWrongCredentialTypeException, BackupPermissionException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n\n    final List<BackupManager.StorageDescriptor> toDelete = deleteMedia.mediaToDelete().stream()\n        .map(media -> new BackupManager.StorageDescriptor(media.cdn(), media.mediaId))\n        .toList();\n\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n    backupManager.deleteMedia(backupUser, toDelete).then().block();\n  }\n\n  @DELETE\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Delete entire backup\", description = \"\"\"\n      Delete all backup metadata, objects, and stored public key. To use backups again, a public key must be resupplied.\n      \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"The backup has been successfully removed\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ApiResponseZkAuth\n  @ManagedAsync\n  public void deleteBackup(\n      @Auth final Optional<AuthenticatedDevice> account,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description = BackupAuthCredentialPresentationHeader.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH) final BackupAuthCredentialPresentationHeader presentation,\n\n      @Parameter(description = BackupAuthCredentialPresentationSignature.DESCRIPTION, schema = @Schema(implementation = String.class))\n      @NotNull\n      @HeaderParam(X_SIGNAL_ZK_AUTH_SIGNATURE) final BackupAuthCredentialPresentationSignature signature)\n      throws BackupPermissionException, BackupFailedZkAuthenticationException {\n    if (account.isPresent()) {\n      throw new BadRequestException(\"must not use authenticated connection for anonymous operations\");\n    }\n    final AuthenticatedBackupUser backupUser =\n        backupManager.authenticateBackupUser(presentation.presentation, signature.signature, userAgent);\n\n    backupManager.deleteEntireBackup(backupUser);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV4.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport java.security.SecureRandom;\nimport java.util.Map;\nimport javax.annotation.Nonnull;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentUtil;\n\n\n/**\n * The attachment controller generates \"upload forms\" for authenticated users that permit them to upload files\n * (message attachments) to a remote storage location. The location may be selected by the server at runtime.\n */\n@Path(\"/v4/attachments\")\n@Tag(name = \"Attachments\")\npublic class AttachmentControllerV4 {\n\n  private final ExperimentEnrollmentManager experimentEnrollmentManager;\n  private final RateLimiter rateLimiter;\n\n  private final Map<Integer, AttachmentGenerator> attachmentGenerators;\n\n  @Nonnull\n  private final SecureRandom secureRandom;\n\n  public AttachmentControllerV4(\n      final RateLimiters rateLimiters,\n      final GcsAttachmentGenerator gcsAttachmentGenerator,\n      final TusAttachmentGenerator tusAttachmentGenerator,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) {\n    this.rateLimiter = rateLimiters.getAttachmentLimiter();\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n    this.secureRandom = new SecureRandom();\n    this.attachmentGenerators = Map.of(\n        2, gcsAttachmentGenerator,\n        3, tusAttachmentGenerator\n    );\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/form/upload\")\n  @Operation(\n      summary = \"Get an upload form\",\n      description = \"\"\"\n          Retrieve an upload form that can be used to perform a resumable upload. The response will include a cdn number\n          indicating what protocol should be used to perform the upload.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Success, response body includes upload form\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public AttachmentDescriptorV3 getAttachmentUploadForm(@Auth AuthenticatedDevice auth)\n      throws RateLimitExceededException {\n    rateLimiter.validate(auth.accountIdentifier());\n    final String key = AttachmentUtil.generateAttachmentKey(secureRandom);\n    final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.accountIdentifier(), AttachmentUtil.CDN3_EXPERIMENT_NAME);\n    int cdn = useCdn3 ? 3 : 2;\n    final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key);\n    return new AttachmentDescriptorV3(cdn, key, descriptor.headers(), descriptor.signedUploadLocation());\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallLinkController.java",
    "content": "package org.whispersystems.textsecuregcm.controllers;\n\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequest;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.CreateCallLinkCredential;\nimport org.whispersystems.textsecuregcm.entities.GetCreateCallLinkCredentialsRequest;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\n\n@Path(\"/v1/call-link\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"CallLink\")\npublic class CallLinkController {\n  private final RateLimiters rateLimiters;\n  private final GenericServerSecretParams genericServerSecretParams;\n\n  public CallLinkController(\n      final RateLimiters rateLimiters,\n      final GenericServerSecretParams genericServerSecretParams\n  ) {\n    this.rateLimiters = rateLimiters;\n    this.genericServerSecretParams = genericServerSecretParams;\n  }\n\n  @POST\n  @Path(\"/create-auth\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Generate a credential for creating call links\",\n      description = \"\"\"\n          Generate a credential over a truncated timestamp, room ID, and account UUID. With zero knowledge\n          group infrastructure, the server does not know the room ID.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with generated credentials.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid create call link credential request.\")\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  public CreateCallLinkCredential getCreateAuth(\n      final @Auth AuthenticatedDevice auth,\n      final @NotNull @Valid GetCreateCallLinkCredentialsRequest request\n  ) throws RateLimitExceededException {\n\n    rateLimiters.getCreateCallLinkLimiter().validate(auth.accountIdentifier());\n\n    final Instant truncatedDayTimestamp = Instant.now().truncatedTo(ChronoUnit.DAYS);\n\n    CreateCallLinkCredentialRequest createCallLinkCredentialRequest;\n    try {\n      createCallLinkCredentialRequest = new CreateCallLinkCredentialRequest(request.createCallLinkCredentialRequest());\n    } catch (InvalidInputException e) {\n      throw new BadRequestException(\"Invalid create call link credential request\", e);\n    }\n\n    return new CreateCallLinkCredential(\n        createCallLinkCredentialRequest.issueCredential(new ServiceId.Aci(auth.accountIdentifier()), truncatedDayTimestamp, genericServerSecretParams).serialize(),\n        truncatedDayTimestamp.getEpochSecond()\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallQualitySurveyController.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.common.net.HttpHeaders;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.parameters.RequestBody;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport java.util.Optional;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.limits.RateLimitedByIp;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.CallQualityInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager;\n\n@Path(\"/v1/call_quality_survey\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Call quality survey\")\npublic class CallQualitySurveyController {\n\n  private final CallQualitySurveyManager callQualitySurveyManager;\n\n  public CallQualitySurveyController(final CallQualitySurveyManager callQualitySurveyManager) {\n    this.callQualitySurveyManager = callQualitySurveyManager;\n  }\n\n  @PUT\n  @Consumes(MediaType.APPLICATION_OCTET_STREAM)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Submit survey response\", description = \"Submits a call quality survey response\")\n  @ApiResponse(responseCode = \"204\", description = \"The survey response was submitted successfully\")\n  @ApiResponse(responseCode = \"422\", description = \"The survey response could not be parsed\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @RateLimitedByIp(RateLimiters.For.SUBMIT_CALL_QUALITY_SURVEY)\n  public void submitCallQualitySurvey(@Auth final Optional<AuthenticatedDevice> authenticatedDevice,\n      @RequestBody(description = \"A serialized survey response protobuf entity\")\n      @NotNull final byte[] surveyResponse,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgentString,\n      @Context final ContainerRequestContext requestContext) {\n\n    if (authenticatedDevice.isPresent()) {\n      throw new ForbiddenException(\"must not use authenticated connection for call quality survey submissions\");\n    }\n\n    final SubmitCallQualitySurveyRequest submitCallQualitySurveyRequest;\n\n    try {\n      submitCallQualitySurveyRequest = SubmitCallQualitySurveyRequest.parseFrom(surveyResponse);\n    } catch (final InvalidProtocolBufferException e) {\n      throw new WebApplicationException(\"Invalid protobuf entity\", 422);\n    }\n\n    final String remoteAddress = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n    try {\n      callQualitySurveyManager.submitCallQualitySurvey(submitCallQualitySurveyRequest, remoteAddress, userAgentString);\n    } catch (final CallQualityInvalidArgumentsException e) {\n      throw new WebApplicationException(e.getMessage(), 422);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport java.io.IOException;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\n\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Calling\")\n@Path(\"/v2/calling\")\npublic class CallRoutingControllerV2 {\n\n  private final RateLimiters rateLimiters;\n  private final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;\n\n  private static final Counter CLOUDFLARE_TURN_ERROR_COUNTER =\n      Metrics.counter(name(CallRoutingControllerV2.class, \"cloudflareTurnError\"));\n\n  public CallRoutingControllerV2(\n      final RateLimiters rateLimiters,\n      final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager) {\n\n    this.rateLimiters = rateLimiters;\n    this.cloudflareTurnCredentialsManager = cloudflareTurnCredentialsManager;\n  }\n\n  @GET\n  @Path(\"/relays\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Get 1:1 calling relay options for the client\",\n      description = \"\"\"\n        Get 1:1 relay addresses in IpV4, Ipv6, and URL formats.\n        \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with call endpoints.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid get call endpoint request.\")\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  public GetCallingRelaysResponse getCallingRelays(final @Auth AuthenticatedDevice auth)\n      throws RateLimitExceededException, IOException {\n\n    rateLimiters.getCallEndpointLimiter().validate(auth.accountIdentifier());\n\n    try {\n      return new GetCallingRelaysResponse(List.of(cloudflareTurnCredentialsManager.retrieveFromCloudflare()));\n    } catch (final Exception e) {\n      CLOUDFLARE_TURN_ERROR_COUNTER.increment();\n      throw e;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/CertificateController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.DefaultValue;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.security.InvalidKeyException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\nimport javax.annotation.Nonnull;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;\nimport org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;\nimport org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.CertificateGenerator;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.entities.DeliveryCertificate;\nimport org.whispersystems.textsecuregcm.entities.GroupCredentials;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n@Path(\"/v1/certificate\")\n@Tag(name = \"Certificate\")\npublic class CertificateController {\n\n  private final AccountsManager accountsManager;\n  private final CertificateGenerator certificateGenerator;\n  private final ServerZkAuthOperations serverZkAuthOperations;\n  private final GenericServerSecretParams genericServerSecretParams;\n  private final Clock clock;\n\n  @VisibleForTesting\n  public static final Duration MAX_REDEMPTION_DURATION = Duration.ofDays(7);\n  private static final String GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME = name(CertificateController.class, \"generateCertificate\");\n  private static final String INCLUDE_E164_TAG_NAME = \"includeE164\";\n\n  public CertificateController(\n      final AccountsManager accountsManager,\n      @Nonnull CertificateGenerator certificateGenerator,\n      @Nonnull ServerZkAuthOperations serverZkAuthOperations,\n      @Nonnull GenericServerSecretParams genericServerSecretParams,\n      @Nonnull Clock clock) {\n\n    this.accountsManager = accountsManager;\n    this.certificateGenerator = Objects.requireNonNull(certificateGenerator);\n    this.serverZkAuthOperations = Objects.requireNonNull(serverZkAuthOperations);\n    this.genericServerSecretParams = genericServerSecretParams;\n    this.clock = Objects.requireNonNull(clock);\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/delivery\")\n  public DeliveryCertificate getDeliveryCertificate(@Auth AuthenticatedDevice auth,\n      @QueryParam(\"includeE164\") @DefaultValue(\"true\") boolean includeE164)\n      throws InvalidKeyException {\n\n    Metrics.counter(GENERATE_DELIVERY_CERTIFICATE_COUNTER_NAME, INCLUDE_E164_TAG_NAME, String.valueOf(includeE164))\n        .increment();\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    return new DeliveryCertificate(\n        certificateGenerator.createFor(account, auth.deviceId(), includeE164));\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/auth/group\")\n  public GroupCredentials getGroupAuthenticationCredentials(\n      @Auth AuthenticatedDevice auth,\n      @QueryParam(\"redemptionStartSeconds\") long startSeconds,\n      @QueryParam(\"redemptionEndSeconds\") long endSeconds) {\n\n    final RedemptionRange redemptionRange;\n    try {\n      final Instant redemptionStart = Instant.ofEpochSecond(startSeconds);\n      final Instant redemptionEnd = Instant.ofEpochSecond(endSeconds);\n      redemptionRange = RedemptionRange.inclusive(clock, redemptionStart, redemptionEnd);\n    } catch (IllegalArgumentException e) {\n      throw new BadRequestException(e.getCause());\n    }\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    final List<GroupCredentials.GroupCredential> credentials = new ArrayList<>();\n    final List<GroupCredentials.CallLinkAuthCredential> callLinkAuthCredentials = new ArrayList<>();\n\n    final ServiceId.Aci aci = new ServiceId.Aci(account.getIdentifier(IdentityType.ACI));\n    final ServiceId.Pni pni = new ServiceId.Pni(account.getIdentifier(IdentityType.PNI));\n\n    for (Instant redemption : redemptionRange) {\n      AuthCredentialWithPniResponse authCredentialWithPni = serverZkAuthOperations.issueAuthCredentialWithPniZkc(aci, pni, redemption);\n      credentials.add(new GroupCredentials.GroupCredential(\n          authCredentialWithPni.serialize(),\n          (int) redemption.getEpochSecond()));\n\n      callLinkAuthCredentials.add(new GroupCredentials.CallLinkAuthCredential(\n          CallLinkAuthCredentialResponse.issueCredential(aci, redemption, genericServerSecretParams).serialize(),\n          redemption.getEpochSecond()));\n    }\n\n    return new GroupCredentials(credentials, callLinkAuthCredentials, pni.getRawUUID());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/ChallengeController.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.parameters.RequestBody;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.AnswerCaptchaChallengeRequest;\nimport org.whispersystems.textsecuregcm.entities.AnswerChallengeRequest;\nimport org.whispersystems.textsecuregcm.entities.AnswerPushChallengeRequest;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.NotPushRegisteredException;\nimport org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;\nimport org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker.ChallengeConstraints;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\n\n@Path(\"/v1/challenge\")\n@Tag(name = \"Challenge\")\npublic class ChallengeController {\n\n  private final AccountsManager accountsManager;\n  private final RateLimitChallengeManager rateLimitChallengeManager;\n  private final ChallengeConstraintChecker challengeConstraintChecker;\n\n  private static final Logger logger = LoggerFactory.getLogger(ChallengeController.class);\n\n  private static final String CHALLENGE_RESPONSE_COUNTER_NAME = name(ChallengeController.class, \"challengeResponse\");\n  private static final String CHALLENGE_TYPE_TAG = \"type\";\n\n  public ChallengeController(\n      final AccountsManager accountsManager,\n      final RateLimitChallengeManager rateLimitChallengeManager,\n      final ChallengeConstraintChecker challengeConstraintChecker) {\n    this.accountsManager = accountsManager;\n    this.rateLimitChallengeManager = rateLimitChallengeManager;\n    this.challengeConstraintChecker = challengeConstraintChecker;\n  }\n\n  @PUT\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Submit proof of a challenge completion\",\n      description = \"\"\"\n          Some server endpoints (the \"send message\" endpoint, for example) may return a 428 response indicating the client must complete a challenge before continuing.\n          Clients may use this endpoint to provide proof of a completed challenge. If successful, the client may then\n          continue their original operation.\n          \"\"\",\n      requestBody = @RequestBody(content = {@Content(schema = @Schema(oneOf = {AnswerPushChallengeRequest.class,\n          AnswerCaptchaChallengeRequest.class}))})\n  )\n  @ApiResponse(responseCode = \"200\", description = \"Indicates the challenge proof was accepted\")\n  @ApiResponse(responseCode = \"400\", description = \"The request was invalid\")\n  @ApiResponse(responseCode = \"428\", description = \"Submitted captcha token was not accepted\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public Response handleChallengeResponse(@Auth final AuthenticatedDevice auth,\n      @Valid final AnswerChallengeRequest answerRequest,\n      @Context final ContainerRequestContext requestContext,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) throws RateLimitExceededException {\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent));\n\n    final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(\n        requestContext, account);\n    try {\n      if (answerRequest instanceof final AnswerPushChallengeRequest pushChallengeRequest) {\n        tags = tags.and(CHALLENGE_TYPE_TAG, \"push\");\n\n        if (!constraints.pushPermitted()) {\n          return Response.status(429).build();\n        }\n        rateLimitChallengeManager.answerPushChallenge(account, pushChallengeRequest.getChallenge());\n      } else if (answerRequest instanceof final AnswerCaptchaChallengeRequest captchaChallengeRequest) {\n        tags = tags.and(CHALLENGE_TYPE_TAG, \"captcha\");\n\n        final String remoteAddress = (String) requestContext.getProperty(\n            RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n        final boolean success = rateLimitChallengeManager.answerCaptchaChallenge(\n            account,\n            captchaChallengeRequest.getCaptcha(),\n            remoteAddress,\n            userAgent,\n            constraints.captchaScoreThreshold());\n\n        if (!success) {\n          return Response.status(428).build();\n        }\n\n      } else {\n        tags = tags.and(CHALLENGE_TYPE_TAG, \"unrecognized\");\n      }\n    } catch (final IOException e) {\n      logger.error(\"error assessing captcha during challenge response handling\", e);\n      return Response.status(Response.Status.SERVICE_UNAVAILABLE).build();\n    } finally {\n      Metrics.counter(CHALLENGE_RESPONSE_COUNTER_NAME, tags).increment();\n    }\n\n    return Response.status(200).build();\n  }\n\n  @POST\n  @Path(\"/push\")\n  @Operation(\n      summary = \"Request a push challenge\",\n      description = \"\"\"\n          Clients may proactively request a push challenge by making an empty POST request. Push challenges will only be\n          sent to the requesting account’s main device. When the push is received it may be provided as proof of completed\n          challenge to /v1/challenge.\n          APNs challenge payloads will be formatted as follows:\n          ```\n          {\n              \"aps\": {\n                  \"sound\": \"default\",\n                  \"alert\": {\n                      \"loc-key\": \"APN_Message\"\n                  }\n              },\n              \"rateLimitChallenge\": \"{CHALLENGE_TOKEN}\"\n          }\n          ```\n          FCM challenge payloads will be formatted as follows:\n          ```\n          {\"rateLimitChallenge\": \"{CHALLENGE_TOKEN}\"}\n          ```\n\n          Clients may retry the PUT in the event of an HTTP/5xx response (except HTTP/508) from the server, but must\n          implement an exponential back-off system and limit the total number of retries.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"\"\"\n      Indicates a payload to the account's primary device has been attempted. When clients receive a challenge push\n      notification, they may issue a PUT request to /v1/challenge.\n      \"\"\")\n  @ApiResponse(responseCode = \"404\", description = \"\"\"\n      The server does not have a push notification token for the authenticated account’s main device; clients may add a push\n      token and try again\n      \"\"\")\n  @ApiResponse(responseCode = \"413\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public Response requestPushChallenge(@Auth final AuthenticatedDevice auth,\n      @Context ContainerRequestContext requestContext) {\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    final ChallengeConstraints constraints = challengeConstraintChecker.challengeConstraints(requestContext, account);\n    if (!constraints.pushPermitted()) {\n      return Response.status(429).build();\n    }\n    try {\n      rateLimitChallengeManager.sendPushChallenge(account);\n      return Response.status(200).build();\n    } catch (final NotPushRegisteredException e) {\n      return Response.status(404).build();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckController.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.parameters.RequestBody;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Base64;\nimport java.util.Locale;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckVerificationFailedException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n/**\n * Process platform device attestations.\n * <p>\n * Device attestations allow clients that can prove that they are running a signed signal build on valid Apple hardware.\n * Currently, this is only used to allow beta builds to access backup functionality, since in-app purchases are not\n * available iOS TestFlight builds.\n */\n@Path(\"/v1/devicecheck\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"DeviceCheck\")\npublic class DeviceCheckController {\n\n  private final Clock clock;\n  private final AccountsManager accountsManager;\n  private final BackupAuthManager backupAuthManager;\n  private final AppleDeviceCheckManager deviceCheckManager;\n  private final RateLimiters rateLimiters;\n  private final long backupRedemptionLevel;\n  private final Duration backupRedemptionDuration;\n\n  public DeviceCheckController(\n      final Clock clock,\n      final AccountsManager accountsManager,\n      final BackupAuthManager backupAuthManager,\n      final AppleDeviceCheckManager deviceCheckManager,\n      final RateLimiters rateLimiters,\n      final long backupRedemptionLevel,\n      final Duration backupRedemptionDuration) {\n    this.clock = clock;\n    this.accountsManager = accountsManager;\n    this.backupAuthManager = backupAuthManager;\n    this.deviceCheckManager = deviceCheckManager;\n    this.backupRedemptionLevel = backupRedemptionLevel;\n    this.backupRedemptionDuration = backupRedemptionDuration;\n    this.rateLimiters = rateLimiters;\n  }\n\n  public record ChallengeResponse(\n      @Schema(description = \"A challenge to use when generating attestations or assertions\")\n      String challenge) {}\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/attest\")\n  @Operation(summary = \"Fetch an attest challenge\", description = \"\"\"\n      Retrieve a challenge to use in an attestation, which should be provided at `PUT /v1/devicecheck/attest`. To\n      produce the clientDataHash for [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))\n      take the SHA256 of the UTF-8 bytes of the returned challenge.\n      \n      Repeat calls to retrieve a challenge may return the same challenge until it is used in a `PUT`. Callers should\n      have a single outstanding challenge at any given time.\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The response body includes a challenge\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  @ManagedAsync\n  public ChallengeResponse attestChallenge(@Auth AuthenticatedDevice authenticatedDevice)\n      throws RateLimitExceededException {\n    rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)\n        .validate(authenticatedDevice.accountIdentifier());\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    return new ChallengeResponse(deviceCheckManager.createChallenge(\n        AppleDeviceCheckManager.ChallengeType.ATTEST,\n        account));\n  }\n\n  @PUT\n  @Consumes(MediaType.APPLICATION_OCTET_STREAM)\n  @Path(\"/attest\")\n  @Operation(summary = \"Register a keyId\", description = \"\"\"\n      Register a keyId with an attestation, which can be used to generate assertions from this account.\n      \n      The attestation should use the SHA-256 of a challenge retrieved at `GET /v1/devicecheck/attest` as the\n      `clientDataHash`\n      \n      Registration is idempotent, and you should retry network errors with the same challenge as suggested by [device\n      check](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:)#discussion),\n      as long as your challenge has not expired (410). Even if your challenge is expired, you may continue to retry with\n      your original keyId (and a fresh challenge).\n      \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"The keyId was successfully added to the account\")\n  @ApiResponse(responseCode = \"410\", description = \"There was no challenge associated with the account. It may have expired.\")\n  @ApiResponse(responseCode = \"401\", description = \"The attestation could not be verified\")\n  @ApiResponse(responseCode = \"413\", description = \"There are too many unique keyIds associated with this account. This is an unrecoverable error.\")\n  @ApiResponse(responseCode = \"409\", description = \"The provided keyId has already been registered to a different account\")\n  @ManagedAsync\n  public void attest(\n      @Auth final AuthenticatedDevice authenticatedDevice,\n\n      @Valid\n      @NotNull\n      @Parameter(description = \"The keyId, encoded with padded url-safe base64\")\n      @QueryParam(\"keyId\") final String keyId,\n\n      @RequestBody(description = \"The attestation data, created by [attestKey](https://developer.apple.com/documentation/devicecheck/dcappattestservice/attestkey(_:clientdatahash:completionhandler:))\")\n      @NotNull final byte[] attestation) {\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    try {\n      deviceCheckManager.registerAttestation(account, parseKeyId(keyId), attestation);\n    } catch (TooManyKeysException e) {\n      throw new WebApplicationException(Response.status(413).build());\n    } catch (ChallengeNotFoundException e) {\n      throw new WebApplicationException(Response.status(410).build());\n    } catch (DeviceCheckVerificationFailedException e) {\n      throw new WebApplicationException(e.getMessage(), Response.status(401).build());\n    } catch (DuplicatePublicKeyException e) {\n      throw new WebApplicationException(Response.status(409).build());\n    }\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/assert\")\n  @Operation(summary = \"Fetch an assert challenge\", description = \"\"\"\n      Retrieve a challenge to use in an attestation, which must be provided at `POST /v1/devicecheck/assert`. To produce\n      the `clientDataHash` for [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)),\n      construct the request you intend to `POST` and include the returned challenge as the \"challenge\"\n      field. Serialize the request as JSON and take the SHA256 of the request, as described [here](https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity#Assert-your-apps-validity-as-necessary).\n      Note that the JSON body provided to the PUT must exactly match the input to the `clientDataHash` (field order,\n      whitespace, etc matters)\n      \n      Repeat calls to retrieve a challenge may return the same challenge until it is used in a `POST`. Callers should\n      attempt to only have a single outstanding challenge at any given time.\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The response body includes a challenge\")\n  @ApiResponse(responseCode = \"429\", description = \"Ratelimited.\")\n  @ManagedAsync\n  public ChallengeResponse assertChallenge(\n      @Auth AuthenticatedDevice authenticatedDevice,\n\n      @Parameter(schema = @Schema(description = \"The type of action you will make an assertion for\",\n          allowableValues = {\"backup\"},\n          implementation = String.class))\n      @QueryParam(\"action\") Action action) throws RateLimitExceededException {\n    rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)\n        .validate(authenticatedDevice.accountIdentifier());\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    return new ChallengeResponse(deviceCheckManager.createChallenge(toChallengeType(action), account));\n  }\n\n  @POST\n  @Consumes(MediaType.APPLICATION_OCTET_STREAM)\n  @Path(\"/assert\")\n  @Operation(summary = \"Perform an attested action\", description = \"\"\"\n      Specify some action to take on the account via the request field. The request must exactly match the request you\n      provide when [generating the assertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:)).\n      The request must include a challenge previously retrieved from `GET /v1/devicecheck/assert`.\n      \n      Each assertion increments the counter associated with the client's device key. This method enforces that no\n      assertion with a counter lower than a counter we've already seen is allowed to execute. If a client issues\n      multiple requests concurrently, or if they retry a request that had an indeterminate outcome, it's possible that\n      the request will not be accepted because the server has already stored the updated counter. In this case the\n      request may return 401, and the client should generate a fresh assert for the request.\n      \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"The assertion was valid and the corresponding action was executed\")\n  @ApiResponse(responseCode = \"404\", description = \"The provided keyId was not found\")\n  @ApiResponse(responseCode = \"410\", description = \"There was no challenge associated with the account. It may have expired.\")\n  @ApiResponse(responseCode = \"401\", description = \"The assertion could not be verified\")\n  @ManagedAsync\n  public void assertion(\n      @Auth final AuthenticatedDevice authenticatedDevice,\n\n      @Valid\n      @NotNull\n      @Parameter(description = \"The keyId, encoded with padded url-safe base64\")\n      @QueryParam(\"keyId\") final String keyId,\n\n      @Valid\n      @NotNull\n      @Parameter(description = \"\"\"\n          The asserted JSON request data, encoded as a string in padded url-safe base64. This must exactly match the\n          request you use when generating the assertion (including field ordering, whitespace, etc).\n          \"\"\",\n          schema = @Schema(implementation = AssertionRequest.class))\n      @QueryParam(\"request\") final DeviceCheckController.AssertionRequestWrapper request,\n\n      @RequestBody(description = \"The assertion created by [generateAssertion](https://developer.apple.com/documentation/devicecheck/dcappattestservice/generateassertion(_:clientdatahash:completionhandler:))\")\n      @NotNull final byte[] assertion) {\n\n    final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    try {\n      deviceCheckManager.validateAssert(\n          account,\n          parseKeyId(keyId),\n          toChallengeType(request.assertionRequest().action()),\n          request.assertionRequest().challenge(),\n          request.rawJson(),\n          assertion);\n    } catch (ChallengeNotFoundException e) {\n      throw new WebApplicationException(Response.status(410).build());\n    } catch (DeviceCheckVerificationFailedException e) {\n      throw new WebApplicationException(e.getMessage(), Response.status(401).build());\n    } catch (DeviceCheckKeyIdNotFoundException | RequestReuseException e) {\n      throw new WebApplicationException(Response.status(404).build());\n    }\n\n    // The request assertion was validated, execute it\n    switch (request.assertionRequest().action()) {\n      case BACKUP -> backupAuthManager.extendBackupVoucher(\n              account,\n              new Account.BackupVoucher(backupRedemptionLevel, clock.instant().plus(backupRedemptionDuration)));\n    }\n  }\n\n  public enum Action {\n    BACKUP;\n\n    @JsonCreator\n    public static Action fromString(final String action) {\n      for (final Action a : Action.values()) {\n        if (a.name().toLowerCase(Locale.ROOT).equals(action)) {\n          return a;\n        }\n      }\n      throw new IllegalArgumentException(\"Invalid action: \" + action);\n    }\n  }\n\n  public record AssertionRequest(\n      @Schema(description = \"The challenge retrieved at `GET /v1/devicecheck/assert`\")\n      String challenge,\n      @Schema(description = \"The type of action you'd like to perform with this assert\",\n          allowableValues = {\"backup\"}, implementation = String.class)\n      Action action) {}\n\n  /*\n   * Parses the base64 encoded AssertionRequest, but preserves the rawJson as well\n   */\n  public record AssertionRequestWrapper(AssertionRequest assertionRequest, byte[] rawJson) {\n\n    public static AssertionRequestWrapper fromString(String requestBase64) throws IOException {\n      final byte[] requestJson = Base64.getUrlDecoder().decode(requestBase64);\n      final AssertionRequest requestData = SystemMapper.jsonMapper().readValue(requestJson, AssertionRequest.class);\n      return new AssertionRequestWrapper(requestData, requestJson);\n    }\n  }\n\n\n  private static AppleDeviceCheckManager.ChallengeType toChallengeType(final Action action) {\n    return switch (action) {\n      case BACKUP -> AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION;\n    };\n  }\n\n  private static byte[] parseKeyId(final String base64KeyId) {\n    try {\n      return Base64.getUrlDecoder().decode(base64KeyId);\n    } catch (IllegalArgumentException e) {\n      throw new WebApplicationException(Response.status(422).entity(e.getMessage()).build());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.DELETE;\nimport jakarta.ws.rs.DefaultValue;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.EnumMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;\nimport org.whispersystems.textsecuregcm.auth.ChangesLinkedDevices;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;\nimport org.whispersystems.textsecuregcm.entities.DeviceInfo;\nimport org.whispersystems.textsecuregcm.entities.DeviceInfoList;\nimport org.whispersystems.textsecuregcm.entities.LinkDeviceRequest;\nimport org.whispersystems.textsecuregcm.entities.LinkDeviceResponse;\nimport org.whispersystems.textsecuregcm.entities.PreKeySignatureValidator;\nimport org.whispersystems.textsecuregcm.entities.ProvisioningMessage;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachment;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachmentError;\nimport org.whispersystems.textsecuregcm.entities.RestoreAccountRequest;\nimport org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.RateLimitedByIp;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.DevicePlatformUtil;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.DeviceSpec;\nimport org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException;\nimport org.whispersystems.textsecuregcm.storage.PersistentTimer;\nimport org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;\nimport org.whispersystems.textsecuregcm.util.EnumMapUtil;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.LinkDeviceToken;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n@Path(\"/v1/devices\")\n@Tag(name = \"Devices\")\npublic class DeviceController {\n\n  static final int MAX_DEVICES = 6;\n\n  private final AccountsManager accounts;\n  private final RateLimiters rateLimiters;\n  private final PersistentTimer persistentTimer;\n  private final Map<String, Integer> maxDeviceConfiguration;\n\n  private final EnumMap<ClientPlatform, AtomicInteger> linkedDeviceListenersByPlatform;\n  private final AtomicInteger linkedDeviceListenersForUnrecognizedPlatforms;\n\n  private static final String LINKED_DEVICE_LISTENER_GAUGE_NAME =\n      MetricsUtil.name(DeviceController.class, \"linkedDeviceListeners\");\n\n  private static final String WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE = \"wait_for_linked_device\";\n  private static final String WAIT_FOR_LINKED_DEVICE_TIMER_NAME =\n      MetricsUtil.name(DeviceController.class, \"waitForLinkedDeviceDuration\");\n\n  private static final String WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE = \"wait_for_transfer_archive\";\n  private static final String WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME =\n      MetricsUtil.name(DeviceController.class, \"waitForTransferArchiveDuration\");\n\n  @VisibleForTesting\n  static final int MIN_TOKEN_IDENTIFIER_LENGTH = 32;\n\n  @VisibleForTesting\n  static final int MAX_TOKEN_IDENTIFIER_LENGTH = 64;\n\n  public DeviceController(final AccountsManager accounts,\n      final RateLimiters rateLimiters,\n      final PersistentTimer persistentTimer,\n      final Map<String, Integer> maxDeviceConfiguration) {\n\n    this.accounts = accounts;\n    this.rateLimiters = rateLimiters;\n    this.persistentTimer = persistentTimer;\n    this.maxDeviceConfiguration = maxDeviceConfiguration;\n\n    linkedDeviceListenersByPlatform =\n        EnumMapUtil.toEnumMap(ClientPlatform.class, clientPlatform -> buildGauge(clientPlatform.name().toLowerCase()));\n\n    linkedDeviceListenersForUnrecognizedPlatforms = buildGauge(\"unknown\");\n  }\n\n  private static AtomicInteger buildGauge(final String clientPlatformName) {\n    return Metrics.gauge(LINKED_DEVICE_LISTENER_GAUGE_NAME,\n        Tags.of(io.micrometer.core.instrument.Tag.of(UserAgentTagUtil.PLATFORM_TAG, clientPlatformName)),\n        new AtomicInteger(0));\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  public DeviceInfoList getDevices(@Auth AuthenticatedDevice auth) {\n    // Devices may change their own names (and primary devices may change the names of linked devices) and so the device\n    // state associated with the authenticated account may be stale. Fetch a fresh copy to compensate.\n    return accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .map(account -> new DeviceInfoList(account.getDevices().stream()\n            .map(DeviceInfo::forDevice)\n            .toList()))\n        .orElseThrow(ForbiddenException::new);\n  }\n\n  @DELETE\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/{device_id}\")\n  @ChangesLinkedDevices\n  public void removeDevice(@Auth AuthenticatedDevice auth, @PathParam(\"device_id\") byte deviceId) {\n    if (auth.deviceId() != Device.PRIMARY_ID && auth.deviceId() != deviceId) {\n      throw new WebApplicationException(Response.Status.UNAUTHORIZED);\n    }\n\n    if (deviceId == Device.PRIMARY_ID) {\n      throw new ForbiddenException();\n    }\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n            .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    accounts.removeDevice(account, deviceId);\n  }\n\n  /**\n   * Generates a signed device-linking token. Generally, primary devices will include the signed device-linking token in\n   * a provisioning message to a new device, and then the new device will include the token in its request to\n   * {@link #linkDevice(BasicAuthorizationHeader, String, LinkDeviceRequest)}.\n   *\n   * @param auth the authenticated account/device\n   *\n   * @return a signed device-linking token\n   *\n   * @throws RateLimitExceededException if the caller has made too many calls to this method in a set amount of time\n   * @throws DeviceLimitExceededException if the authenticated account has already reached the maximum number of linked\n   * devices\n   *\n   * @see ProvisioningController#sendProvisioningMessage(AuthenticatedDevice, String, ProvisioningMessage, String)\n   */\n  @GET\n  @Path(\"/provisioning/code\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Generate a signed device-linking token\",\n      description = \"\"\"\n          Generate a signed device-linking token for transmission to a pending linked device via a provisioning message.\n          \"\"\")\n  @ApiResponse(responseCode=\"200\", description=\"Token was generated successfully\", useReturnTypeSchema=true)\n  @ApiResponse(responseCode = \"411\", description = \"The authenticated account already has the maximum allowed number of linked devices\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public LinkDeviceToken createDeviceToken(@Auth AuthenticatedDevice auth)\n      throws RateLimitExceededException, DeviceLimitExceededException {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    rateLimiters.getAllocateDeviceLimiter().validate(account.getUuid());\n\n    int maxDeviceLimit = MAX_DEVICES;\n\n    if (maxDeviceConfiguration.containsKey(account.getNumber())) {\n      maxDeviceLimit = maxDeviceConfiguration.get(account.getNumber());\n    }\n\n    if (account.getDevices().size() >= maxDeviceLimit) {\n      throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit);\n    }\n\n    if (auth.deviceId() != Device.PRIMARY_ID) {\n      throw new WebApplicationException(Response.Status.UNAUTHORIZED);\n    }\n\n    final String token = accounts.generateLinkDeviceToken(account.getUuid());\n\n    return new LinkDeviceToken(token, AccountsManager.getLinkDeviceTokenIdentifier(token));\n  }\n\n  @PUT\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Path(\"/link\")\n  @ChangesLinkedDevices\n  @Operation(summary = \"Link a device to an account\",\n      description = \"\"\"\n          Links a device to an account identified by a given phone number.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The new device was linked to the calling account\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"403\", description = \"The given account was not found or the given verification code was incorrect\")\n  @ApiResponse(responseCode = \"409\", description = \"The new device is missing a capability supported by all other devices on the account\")\n  @ApiResponse(responseCode = \"411\", description = \"The given account already has its maximum number of linked devices\")\n  @ApiResponse(responseCode = \"422\", description = \"The request did not pass validation\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public LinkDeviceResponse linkDevice(@HeaderParam(HttpHeaders.AUTHORIZATION) @NotNull BasicAuthorizationHeader authorizationHeader,\n      @HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent,\n      @NotNull @Valid LinkDeviceRequest linkDeviceRequest)\n      throws RateLimitExceededException, DeviceLimitExceededException {\n\n    final Account account = accounts.checkDeviceLinkingToken(linkDeviceRequest.verificationCode())\n        .flatMap(accounts::getByAccountIdentifier)\n        .orElseThrow(ForbiddenException::new);\n\n    final DeviceActivationRequest deviceActivationRequest = linkDeviceRequest.deviceActivationRequest();\n    final AccountAttributes accountAttributes = linkDeviceRequest.accountAttributes();\n\n    rateLimiters.getVerifyDeviceLimiter().validate(account.getUuid());\n\n    final boolean allKeysValid =\n        PreKeySignatureValidator.validatePreKeySignatures(account.getIdentityKey(IdentityType.ACI),\n            List.of(deviceActivationRequest.aciSignedPreKey(), deviceActivationRequest.aciPqLastResortPreKey()),\n            userAgent,\n            \"link-device\")\n            && PreKeySignatureValidator.validatePreKeySignatures(account.getIdentityKey(IdentityType.PNI),\n            List.of(deviceActivationRequest.pniSignedPreKey(), deviceActivationRequest.pniPqLastResortPreKey()),\n            userAgent,\n            \"link-device\");\n\n    if (!allKeysValid) {\n      throw new WebApplicationException(Response.status(422).build());\n    }\n\n    final int maxDeviceLimit = maxDeviceConfiguration.getOrDefault(account.getNumber(), MAX_DEVICES);\n\n    if (account.getDevices().size() >= maxDeviceLimit) {\n      throw new DeviceLimitExceededException(account.getDevices().size(), maxDeviceLimit);\n    }\n\n    final Set<DeviceCapability> capabilities = accountAttributes.getCapabilities();\n\n    if (capabilities == null) {\n      throw new WebApplicationException(Response.status(422, \"Missing device capabilities\").build());\n    } else if (isCapabilityDowngrade(account, capabilities)) {\n      throw new WebApplicationException(Response.status(409).build());\n    }\n\n    final String signalAgent;\n\n    if (deviceActivationRequest.apnToken().isPresent()) {\n      signalAgent = \"OWP\";\n    } else if (deviceActivationRequest.gcmToken().isPresent()) {\n      signalAgent = \"OWA\";\n    } else {\n      signalAgent = \"OWD\";\n    }\n\n    try {\n      final Pair<Account, Device> accountAndDevice = accounts.addDevice(account, new DeviceSpec(accountAttributes.getName(),\n                  authorizationHeader.getPassword(),\n                  signalAgent,\n                  capabilities,\n                  accountAttributes.getRegistrationId(),\n                  accountAttributes.getPhoneNumberIdentityRegistrationId(),\n                  accountAttributes.getFetchesMessages(),\n                  deviceActivationRequest.apnToken(),\n                  deviceActivationRequest.gcmToken(),\n                  deviceActivationRequest.aciSignedPreKey(),\n                  deviceActivationRequest.pniSignedPreKey(),\n                  deviceActivationRequest.aciPqLastResortPreKey(),\n                  deviceActivationRequest.pniPqLastResortPreKey()),\n              linkDeviceRequest.verificationCode());\n\n      return new LinkDeviceResponse(\n          accountAndDevice.first().getIdentifier(IdentityType.ACI),\n          accountAndDevice.first().getIdentifier(IdentityType.PNI),\n          accountAndDevice.second().getId());\n    } catch (final LinkDeviceTokenAlreadyUsedException e) {\n      throw new ForbiddenException();\n    }\n  }\n\n  @GET\n  @Path(\"/wait_for_linked_device/{tokenIdentifier}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Wait for a new device to be linked to an account\",\n      description = \"\"\"\n          Waits for a new device to be linked to an account and returns basic information about the new device when\n          available.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"A device was linked to an account using the token associated with the given token identifier\",\n      content = @Content(schema = @Schema(implementation = DeviceInfo.class)))\n  @ApiResponse(responseCode = \"204\", description = \"No device was linked to the account before the call completed; clients may repeat the call to continue waiting\")\n  @ApiResponse(responseCode = \"400\", description = \"The given token identifier or timeout was invalid\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited; try again after the prescribed delay\")\n  public CompletionStage<Response> waitForLinkedDevice(\n      @Auth final AuthenticatedDevice authenticatedDevice,\n\n      @PathParam(\"tokenIdentifier\")\n      @Schema(description = \"A 'link device' token identifier provided by the 'create link device token' endpoint\")\n      @Size(min = MIN_TOKEN_IDENTIFIER_LENGTH, max = MAX_TOKEN_IDENTIFIER_LENGTH)\n      final String tokenIdentifier,\n\n      @QueryParam(\"timeout\")\n      @DefaultValue(\"30\")\n      @Min(1)\n      @Max(3600)\n      @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED,\n          minimum = \"1\",\n          maximum = \"3600\",\n          description = \"\"\"\n                The amount of time (in seconds) to wait for a response. If the expected device is not linked within the\n                given amount of time, this endpoint will return a status of HTTP/204.\n              \"\"\") final int timeoutSeconds,\n\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {\n    final AtomicInteger linkedDeviceListenerCounter = getCounterForLinkedDeviceListeners(userAgent);\n    linkedDeviceListenerCounter.incrementAndGet();\n\n    return rateLimiters.getWaitForLinkedDeviceLimiter().validateAsync(authenticatedDevice.accountIdentifier())\n        .thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))\n        .thenCompose(maybeAccount -> {\n          final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          return persistentTimer.start(WAIT_FOR_LINKED_DEVICE_TIMER_NAMESPACE, tokenIdentifier)\n              .thenApply(sample -> new Pair<>(account, sample));\n        })\n        .thenCompose(accountAndSample -> accounts.waitForNewLinkedDevice(\n                authenticatedDevice.accountIdentifier(),\n                accountAndSample.first().getDevice(authenticatedDevice.deviceId())\n                    .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),\n                tokenIdentifier,\n                Duration.ofSeconds(timeoutSeconds))\n            .thenApply(maybeDeviceInfo -> maybeDeviceInfo\n                .map(deviceInfo -> Response.status(Response.Status.OK).entity(deviceInfo).build())\n                .orElseGet(() -> Response.status(Response.Status.NO_CONTENT).build()))\n            .exceptionally(ExceptionUtils.exceptionallyHandler(IllegalArgumentException.class,\n                _ -> Response.status(Response.Status.BAD_REQUEST).build()))\n            .whenComplete((response, _) -> {\n              linkedDeviceListenerCounter.decrementAndGet();\n\n              if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {\n                accountAndSample.second().stop(Timer.builder(WAIT_FOR_LINKED_DEVICE_TIMER_NAME)\n                    .tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))\n                    .register(Metrics.globalRegistry));\n              }\n            }));\n  }\n\n  private AtomicInteger getCounterForLinkedDeviceListeners(final String userAgent) {\n    try {\n      return linkedDeviceListenersByPlatform.get(UserAgentUtil.parseUserAgentString(userAgent).platform());\n    } catch (final UnrecognizedUserAgentException ignored) {\n      return linkedDeviceListenersForUnrecognizedPlatforms;\n    }\n  }\n\n  @PUT\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/capabilities\")\n  public void setCapabilities(@Auth final AuthenticatedDevice auth,\n\n      @NotNull\n      final Map<String, Boolean> capabilities) {\n\n    final Account account = accounts.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    accounts.updateDevice(account, auth.deviceId(),\n        d -> d.setCapabilities(DeviceCapabilityAdapter.mapToSet(capabilities)));\n  }\n\n  private static boolean isCapabilityDowngrade(final Account account, final Set<DeviceCapability> capabilities) {\n    final Set<DeviceCapability> requiredCapabilities = Arrays.stream(DeviceCapability.values())\n        // `ALWAYS_CAPABLE` capabilities are always assumed to be present, so we don't require callers to specify them\n        .filter(capability -> capability.getAccountCapabilityMode() != DeviceCapability.AccountCapabilityMode.ALWAYS_CAPABLE)\n        .filter(DeviceCapability::preventDowngrade)\n        .filter(account::hasCapability)\n        .collect(Collectors.toSet());\n\n    return !capabilities.containsAll(requiredCapabilities);\n  }\n\n  @PUT\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/restore_account/{token}\")\n  @Operation(\n      summary = \"Signals that a new device is requesting restoration of account data by some method\",\n      description = \"\"\"\n          Signals that a new device is requesting restoration of account data by some method. Devices waiting via the\n          \"wait for 'restore account' request\" endpoint will be notified that the request has been issued.\n          \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"Success\")\n  @ApiResponse(responseCode = \"422\", description = \"The request object could not be parsed or was otherwise invalid\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited; try again after the prescribed delay\")\n  @RateLimitedByIp(RateLimiters.For.RECORD_DEVICE_TRANSFER_REQUEST)\n  public CompletionStage<Void> recordRestoreAccountRequest(\n      @PathParam(\"token\")\n      @NotBlank\n      @Size(max = 64)\n      @Schema(description = \"A randomly-generated token identifying the request for device-to-device transfer.\",\n          requiredMode = Schema.RequiredMode.REQUIRED,\n          maximum = \"64\") final String token,\n\n      @Valid\n      final RestoreAccountRequest restoreAccountRequest) {\n\n    return accounts.recordRestoreAccountRequest(token, restoreAccountRequest);\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/restore_account/{token}\")\n  @Operation(summary = \"Wait for 'restore account' request\")\n  @ApiResponse(responseCode = \"200\", description = \"A 'restore account' request was received for the given token\",\n      content = @Content(schema = @Schema(implementation = RestoreAccountRequest.class)))\n  @ApiResponse(responseCode = \"204\", description = \"No 'restore account' request for the given token was received before the call completed; clients may repeat the call to continue waiting\")\n  @ApiResponse(responseCode = \"400\", description = \"The given token or timeout was invalid\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited; try again after the prescribed delay\")\n  @RateLimitedByIp(RateLimiters.For.WAIT_FOR_DEVICE_TRANSFER_REQUEST)\n  public CompletionStage<Response> waitForDeviceTransferRequest(\n      @PathParam(\"token\")\n      @NotBlank\n      @Size(max = 64)\n      @Schema(description = \"A randomly-generated token identifying the request for device-to-device transfer.\",\n          requiredMode = Schema.RequiredMode.REQUIRED,\n          maximum = \"64\") final String token,\n\n      @QueryParam(\"timeout\")\n      @DefaultValue(\"30\")\n      @Min(1)\n      @Max(3600)\n      @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED,\n          minimum = \"1\",\n          maximum = \"3600\",\n          description = \"\"\"\n                The amount of time (in seconds) to wait for a response. If a transfer archive for the authenticated\n                device is not available within the given amount of time, this endpoint will return a status of HTTP/204.\n              \"\"\") final int timeoutSeconds) {\n\n    return accounts.waitForRestoreAccountRequest(token, Duration.ofSeconds(timeoutSeconds))\n        .thenApply(maybeRequestReceived -> maybeRequestReceived\n            .map(restoreAccountRequest -> Response.status(Response.Status.OK).entity(restoreAccountRequest).build())\n            .orElseGet(() -> Response.status(Response.Status.NO_CONTENT).build()));\n  }\n\n  @PUT\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/transfer_archive\")\n  @Operation(\n      summary = \"Signals that a transfer archive has been uploaded for a specific linked device\",\n      description = \"\"\"\n          Signals that a transfer archive has been uploaded or failed for a specific linked device. Devices waiting via\n          the \"wait for transfer archive\" endpoint will be notified that the new archive is available.\n\n          If the uploader cannot upload the transfer archive, they must signal an error.\n          \"\"\")\n  @ApiResponse(responseCode = \"204\", description = \"Success\")\n  @ApiResponse(responseCode = \"422\", description = \"The request object could not be parsed or was otherwise invalid\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited; try again after the prescribed delay\")\n  public CompletionStage<Void> recordTransferArchiveUploaded(@Auth final AuthenticatedDevice authenticatedDevice,\n      @NotNull @Valid final TransferArchiveUploadedRequest transferArchiveUploadedRequest) {\n    return rateLimiters.getUploadTransferArchiveLimiter()\n        .validateAsync(authenticatedDevice.accountIdentifier())\n        .thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))\n        .thenCompose(maybeAccount -> {\n\n          final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          return accounts.recordTransferArchiveUpload(account,\n              transferArchiveUploadedRequest.destinationDeviceId(),\n              transferArchiveUploadedRequest.destinationDeviceRegistrationId(),\n              transferArchiveUploadedRequest.transferArchive());\n        });\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/transfer_archive\")\n  @Operation(summary = \"Wait for a new transfer archive to be uploaded\",\n      description = \"\"\"\n          Waits for a new transfer archive to be uploaded for the authenticated device and returns the location of the\n          archive when available.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Either a new transfer archive was uploaded for the authenticated device, or the upload has failed\",\n      content = @Content(schema = @Schema(description = \"\"\"\n          The location of the transfer archive if the archive was successfully uploaded, otherwise a error indicating that\n           the upload has failed and the destination device should stop waiting\n          \"\"\", oneOf = {RemoteAttachment.class, RemoteAttachmentError.class})))\n  @ApiResponse(responseCode = \"204\", description = \"No transfer archive was uploaded before the call completed; clients may repeat the call to continue waiting\")\n  @ApiResponse(responseCode = \"400\", description = \"The given timeout was invalid\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited; try again after the prescribed delay\")\n  public CompletionStage<Response> waitForTransferArchive(@Auth final AuthenticatedDevice authenticatedDevice,\n\n      @QueryParam(\"timeout\")\n      @DefaultValue(\"30\")\n      @Min(1)\n      @Max(3600)\n      @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED,\n          minimum = \"1\",\n          maximum = \"3600\",\n          description = \"\"\"\n                The amount of time (in seconds) to wait for a response. If a transfer archive for the authenticated\n                device is not available within the given amount of time, this endpoint will return a status of HTTP/204.\n              \"\"\") final int timeoutSeconds,\n\n      @HeaderParam(HttpHeaders.USER_AGENT) @Nullable String userAgent) {\n\n\n    final String rateLimiterKey = authenticatedDevice.accountIdentifier() + \":\" + authenticatedDevice.deviceId();\n\n    return rateLimiters.getWaitForTransferArchiveLimiter().validateAsync(rateLimiterKey)\n        .thenCompose(ignored -> accounts.getByAccountIdentifierAsync(authenticatedDevice.accountIdentifier()))\n        .thenCompose(maybeAccount -> {\n          final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          return persistentTimer.start(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAMESPACE, rateLimiterKey)\n              .thenApply(sample -> new Pair<>(account, sample));\n        })\n        .thenCompose(accountAndSample -> accounts.waitForTransferArchive(accountAndSample.first(),\n                accountAndSample.first().getDevice(authenticatedDevice.deviceId())\n                    .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)),\n                Duration.ofSeconds(timeoutSeconds))\n            .thenApply(maybeTransferArchive -> maybeTransferArchive\n                .map(transferArchive -> Response.status(Response.Status.OK).entity(transferArchive).build())\n                .orElseGet(() -> Response.status(Response.Status.NO_CONTENT).build()))\n            .whenComplete((response, _) -> {\n              if (response != null && response.getStatus() == Response.Status.OK.getStatusCode()) {\n                accountAndSample.second().stop(Timer.builder(WAIT_FOR_TRANSFER_ARCHIVE_TIMER_NAME)\n                    .tags(Tags.of(\n                        UserAgentTagUtil.getPlatformTag(userAgent),\n                        primaryPlatformTag(accountAndSample.first())))\n                    .register(Metrics.globalRegistry));\n              }\n            }));\n  }\n\n  private static io.micrometer.core.instrument.Tag primaryPlatformTag(final Account account) {\n    return io.micrometer.core.instrument.Tag.of(\n        \"primaryPlatform\",\n        DevicePlatformUtil.getDevicePlatform(account.getPrimaryDevice())\n            .map(p -> p.name().toLowerCase(Locale.ROOT))\n            .orElse(\"unknown\"));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/DeviceLimitExceededException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\n\npublic class DeviceLimitExceededException extends Exception {\n\n  private final int currentDevices;\n  private final int maxDevices;\n\n  public DeviceLimitExceededException(int currentDevices, int maxDevices) {\n    this.currentDevices = currentDevices;\n    this.maxDevices     = maxDevices;\n  }\n\n  public int getCurrentDevices() {\n    return currentDevices;\n  }\n\n  public int getMaxDevices() {\n    return maxDevices;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/DirectoryV2Controller.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport java.time.Clock;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;\n\n@Path(\"/v2/directory\")\n@Tag(name = \"Directory\")\npublic class DirectoryV2Controller {\n\n  private final ExternalServiceCredentialsGenerator directoryServiceTokenGenerator;\n\n  @VisibleForTesting\n  public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryV2ClientConfiguration cfg,\n                                                                        final Clock clock) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .withUserDerivationKey(cfg.userIdTokenSharedSecret())\n        .prependUsername(false)\n        .withClock(clock)\n        .build();\n  }\n\n  public static ExternalServiceCredentialsGenerator credentialsGenerator(final DirectoryV2ClientConfiguration cfg) {\n    return credentialsGenerator(cfg, Clock.systemUTC());\n  }\n\n  public DirectoryV2Controller(final ExternalServiceCredentialsGenerator userTokenGenerator) {\n    this.directoryServiceTokenGenerator = userTokenGenerator;\n  }\n\n  @GET\n  @Path(\"/auth\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Generate credentials for Contact Discovery Service\",\n      description = \"\"\"\n          Generate Contact Discovery Service credentials. Generated credentials have an expiration time of 24 hours\\s\n          (however, the TTL is fully controlled by the server and may change even for already generated credentials).\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with generated credentials.\", useReturnTypeSchema = true)\n  public ExternalServiceCredentials getAuthToken(final @Auth AuthenticatedDevice auth) {\n    return directoryServiceTokenGenerator.generateForUuid(auth.accountIdentifier());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/DonationController.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.Objects;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.function.Function;\nimport javax.annotation.Nonnull;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;\n\n@Path(\"/v1/donation\")\n@Tag(name = \"Donations\")\npublic class DonationController {\n\n  public interface ReceiptCredentialPresentationFactory {\n    ReceiptCredentialPresentation build(byte[] bytes) throws InvalidInputException;\n  }\n\n  private final Clock clock;\n  private final ServerZkReceiptOperations serverZkReceiptOperations;\n  private final RedeemedReceiptsManager redeemedReceiptsManager;\n  private final AccountsManager accountsManager;\n  private final BadgesConfiguration badgesConfiguration;\n  private final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;\n\n  public DonationController(\n      @Nonnull final Clock clock,\n      @Nonnull final ServerZkReceiptOperations serverZkReceiptOperations,\n      @Nonnull final RedeemedReceiptsManager redeemedReceiptsManager,\n      @Nonnull final AccountsManager accountsManager,\n      @Nonnull final BadgesConfiguration badgesConfiguration,\n      @Nonnull final ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory) {\n    this.clock = Objects.requireNonNull(clock);\n    this.serverZkReceiptOperations = Objects.requireNonNull(serverZkReceiptOperations);\n    this.redeemedReceiptsManager = Objects.requireNonNull(redeemedReceiptsManager);\n    this.accountsManager = Objects.requireNonNull(accountsManager);\n    this.badgesConfiguration = Objects.requireNonNull(badgesConfiguration);\n    this.receiptCredentialPresentationFactory = Objects.requireNonNull(receiptCredentialPresentationFactory);\n  }\n\n  @POST\n  @Path(\"/redeem-receipt\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN})\n  @Operation(\n      summary = \"Redeem receipt\",\n      description = \"\"\"\n          Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials to add a badge to the\n          account. After successful redemption, profile responses will include the corresponding badge (if configured as\n          visible) until the expiration time on the receipt.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The receipt was redeemed\")\n  @ApiResponse(responseCode = \"400\", description = \"\"\"\n      The provided presentation or receipt was invalid, or the receipt was already redeemed for a different account. A\n      specific error message suitable for logging will be included as text/plain body\n      \"\"\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limited.\")\n  @ManagedAsync\n  public Response redeemReceipt(\n      @Auth final AuthenticatedDevice auth,\n      @NotNull @Valid final RedeemReceiptRequest request) {\n    ReceiptCredentialPresentation receiptCredentialPresentation;\n    try {\n      receiptCredentialPresentation = receiptCredentialPresentationFactory\n          .build(request.getReceiptCredentialPresentation());\n    } catch (InvalidInputException e) {\n      return Response.status(Status.BAD_REQUEST)\n          .entity(\"invalid receipt credential presentation\")\n          .type(MediaType.TEXT_PLAIN_TYPE)\n          .build();\n    }\n    try {\n      serverZkReceiptOperations.verifyReceiptCredentialPresentation(receiptCredentialPresentation);\n    } catch (VerificationFailedException e) {\n      return Response.status(Status.BAD_REQUEST)\n          .entity(\"receipt credential presentation verification failed\")\n          .type(MediaType.TEXT_PLAIN_TYPE)\n          .build();\n    }\n\n    final ReceiptSerial receiptSerial = receiptCredentialPresentation.getReceiptSerial();\n    final Instant receiptExpiration = Instant.ofEpochSecond(receiptCredentialPresentation.getReceiptExpirationTime());\n    final long receiptLevel = receiptCredentialPresentation.getReceiptLevel();\n    final String badgeId = badgesConfiguration.getReceiptLevels().get(receiptLevel);\n    if (badgeId == null) {\n      return Response.serverError()\n          .entity(\"server does not recognize the requested receipt level\")\n          .type(MediaType.TEXT_PLAIN_TYPE)\n          .build();\n    }\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n    final boolean receiptMatched = redeemedReceiptsManager.put(\n        receiptSerial, receiptExpiration.getEpochSecond(), receiptLevel, auth.accountIdentifier()).join();\n    if (!receiptMatched) {\n      return Response.status(Status.BAD_REQUEST)\n          .entity(\"receipt serial is already redeemed\")\n          .type(MediaType.TEXT_PLAIN_TYPE)\n          .build();\n    }\n\n    accountsManager.update(account, a -> {\n      a.addBadge(clock, new AccountBadge(badgeId, receiptExpiration, request.isVisible()));\n      if (request.isPrimary()) {\n        a.makeBadgePrimaryIfExists(clock, badgeId);\n      }\n    });\n    return Response.ok().build();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/GetCallingRelaysResponse.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.auth.TurnToken;\n\npublic record GetCallingRelaysResponse(List<TurnToken> relays) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeepAliveController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Optional;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.websocket.session.WebSocketSession;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\n\n@Path(\"/v1/keepalive\")\n@Tag(name = \"Keep Alive\")\npublic class KeepAliveController {\n\n  private final Logger logger = LoggerFactory.getLogger(KeepAliveController.class);\n\n  private final RedisMessageAvailabilityManager redisMessageAvailabilityManager;\n\n  private static final String CLOSED_CONNECTION_AGE_DISTRIBUTION_NAME = name(KeepAliveController.class,\n      \"closedConnectionAge\");\n\n\n  public KeepAliveController(final RedisMessageAvailabilityManager redisMessageAvailabilityManager) {\n    this.redisMessageAvailabilityManager = redisMessageAvailabilityManager;\n  }\n\n  @GET\n  public Response getKeepAlive(@Auth Optional<AuthenticatedDevice> maybeAuth,\n      @WebSocketSession WebSocketSessionContext context) {\n\n    maybeAuth.ifPresent(auth -> {\n      if (!redisMessageAvailabilityManager.isLocallyPresent(auth.accountIdentifier(), auth.deviceId())) {\n\n        final Duration age = Duration.between(context.getClient().getCreated(), Instant.now());\n\n        logger.debug(\"***** No local subscription found for {}::{}; age = {}ms, User-Agent = {}\",\n            auth.accountIdentifier(), auth.deviceId(), age.toMillis(),\n            context.getClient().getUserAgent());\n\n        context.getClient().close(1000, \"OK\");\n\n        Timer.builder(CLOSED_CONNECTION_AGE_DISTRIBUTION_NAME)\n            .tags(Tags.of(UserAgentTagUtil.getPlatformTag(context.getClient().getUserAgent())))\n            .register(Metrics.globalRegistry)\n            .record(age);\n      }\n    });\n\n    return Response.ok().build();\n  }\n\n  @GET\n  @Path(\"/provisioning\")\n  public Response getProvisioningKeepAlive() {\n    return Response.ok().build();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyController.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.auth.Auth;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Positive;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.ServerErrorException;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.util.Optional;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.keytransparency.client.AciMonitorRequest;\nimport org.signal.keytransparency.client.E164MonitorRequest;\nimport org.signal.keytransparency.client.E164SearchRequest;\nimport org.signal.keytransparency.client.UsernameHashMonitorRequest;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencyDistinguishedKeyResponse;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorRequest;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorResponse;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencySearchRequest;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencySearchResponse;\nimport org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;\nimport org.whispersystems.textsecuregcm.limits.RateLimitedByIp;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\n\n@Path(\"/v1/key-transparency\")\n@Tag(name = \"KeyTransparency\")\npublic class KeyTransparencyController {\n\n  private static final Logger LOGGER = LoggerFactory.getLogger(KeyTransparencyController.class);\n  private final KeyTransparencyServiceClient keyTransparencyServiceClient;\n\n  public KeyTransparencyController(\n      final KeyTransparencyServiceClient keyTransparencyServiceClient) {\n    this.keyTransparencyServiceClient = keyTransparencyServiceClient;\n  }\n\n  @Operation(\n      summary = \"Search for the given identifiers in the key transparency log\",\n      description = \"\"\"\n          Returns a response if the ACI exists in the transparency log and its mapped value matches the provided\n          ACI identity key.\n\n          The username hash search response field is populated if it is found in the log and its mapped value matches\n          the provided ACI. The E164 search response is populated similarly, with some additional requirements:\n          - The account associated with the provided ACI must be discoverable by phone number.\n          - The provided unidentified access key must match the one on the account.\n\n          Enforced unauthenticated endpoint.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"The ACI was found and its mapped value matched the provided ACI identity key\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid request. See response for any available details.\")\n  @ApiResponse(responseCode = \"403\", description = \"\"\"\n      The ACI was found but its mapped value did not match the provided ACI identity key\n      or the ACI was not found in the log.\n      \"\"\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited\")\n  @POST\n  @Path(\"/search\")\n  @RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public KeyTransparencySearchResponse search(\n      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid final KeyTransparencySearchRequest request) {\n\n    // Disallow clients from making authenticated requests to this endpoint\n    requireNotAuthenticated(authenticatedAccount);\n\n    try {\n      final Optional<E164SearchRequest> maybeE164SearchRequest =\n          request.e164().flatMap(e164 -> request.unidentifiedAccessKey().map(uak ->\n              E164SearchRequest.newBuilder()\n                  .setE164(e164)\n                  .setUnidentifiedAccessKey(ByteString.copyFrom(request.unidentifiedAccessKey().get()))\n                  .build()\n          ));\n\n      return new KeyTransparencySearchResponse(\n          keyTransparencyServiceClient.search(\n              ByteString.copyFrom(request.aci().toCompactByteArray()),\n              ByteString.copyFrom(request.aciIdentityKey().serialize()),\n              request.usernameHash().map(ByteString::copyFrom),\n              maybeE164SearchRequest,\n              request.lastTreeHeadSize(),\n              request.distinguishedTreeHeadSize())\n          .toByteArray());\n    } catch (final StatusRuntimeException exception) {\n      handleKeyTransparencyServiceError(exception);\n    }\n    // This is unreachable\n    return null;\n  }\n\n  @Operation(\n      summary = \"Monitor the given identifiers in the key transparency log\",\n      description = \"\"\"\n          Return proofs proving that the log tree has been constructed correctly in later entries for each of the given\n          identifiers. Enforced unauthenticated endpoint.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"All identifiers exist in the log\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid request. See response for any available details.\")\n  @ApiResponse(responseCode = \"403\", description = \"One or more of the provided commitment indexes did not match\")\n  @ApiResponse(responseCode = \"404\", description = \"At least one identifier was not found\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format\")\n  @POST\n  @Path(\"/monitor\")\n  @RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public KeyTransparencyMonitorResponse monitor(\n      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid final KeyTransparencyMonitorRequest request) {\n\n    // Disallow clients from making authenticated requests to this endpoint\n    requireNotAuthenticated(authenticatedAccount);\n\n    try {\n      final AciMonitorRequest aciMonitorRequest = AciMonitorRequest.newBuilder()\n          .setAci(ByteString.copyFrom(request.aci().value().toCompactByteArray()))\n          .setEntryPosition(request.aci().entryPosition())\n          .setCommitmentIndex(ByteString.copyFrom(request.aci().commitmentIndex()))\n          .build();\n\n      final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest = request.usernameHash().map(usernameHash ->\n          UsernameHashMonitorRequest.newBuilder()\n              .setUsernameHash(ByteString.copyFrom(usernameHash.value()))\n              .setEntryPosition(usernameHash.entryPosition())\n              .setCommitmentIndex(ByteString.copyFrom(usernameHash.commitmentIndex()))\n              .build());\n\n      final Optional<E164MonitorRequest> e164MonitorRequest = request.e164().map(e164 ->\n          E164MonitorRequest.newBuilder()\n              .setE164(e164.value())\n              .setEntryPosition(e164.entryPosition())\n              .setCommitmentIndex(ByteString.copyFrom(e164.commitmentIndex()))\n              .build());\n\n      return new KeyTransparencyMonitorResponse(keyTransparencyServiceClient.monitor(\n          aciMonitorRequest,\n          usernameHashMonitorRequest,\n          e164MonitorRequest,\n          request.lastNonDistinguishedTreeHeadSize(),\n          request.lastDistinguishedTreeHeadSize())\n          .toByteArray());\n    } catch (final StatusRuntimeException exception) {\n      handleKeyTransparencyServiceError(exception);\n    }\n    // This is unreachable\n    return null;\n  }\n\n  @Operation(\n      summary = \"Get the current value of the distinguished key\",\n      description = \"\"\"\n          The response contains the distinguished tree head to prove consistency\n          against for future calls to `/search`, `/monitor`, and `/distinguished`.\n          Enforced unauthenticated endpoint.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"The `distinguished` search key exists in the log\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid request. See response for any available details.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate-limited\")\n  @GET\n  @Path(\"/distinguished\")\n  @RateLimitedByIp(RateLimiters.For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public KeyTransparencyDistinguishedKeyResponse getDistinguishedKey(\n      @Auth final Optional<AuthenticatedDevice> authenticatedAccount,\n\n      @Parameter(description = \"The distinguished tree head size returned by a previously verified call\")\n      @QueryParam(\"lastTreeHeadSize\") @Valid final Optional<@Positive Long> lastTreeHeadSize) {\n\n    // Disallow clients from making authenticated requests to this endpoint\n    requireNotAuthenticated(authenticatedAccount);\n\n    try {\n      return new KeyTransparencyDistinguishedKeyResponse(\n          keyTransparencyServiceClient.getDistinguishedKey(lastTreeHeadSize)\n          .toByteArray());\n    } catch (final StatusRuntimeException exception) {\n      handleKeyTransparencyServiceError(exception);\n    }\n    // This is unreachable\n    return null;\n  }\n\n  private void handleKeyTransparencyServiceError(final StatusRuntimeException exception) {\n    final Status.Code code = exception.getStatus().getCode();\n    final String description = exception.getStatus().getDescription();\n    switch (code) {\n      case NOT_FOUND -> throw new NotFoundException(description);\n      case PERMISSION_DENIED -> throw new ForbiddenException(description);\n      case INVALID_ARGUMENT -> throw new WebApplicationException(description, 422);\n      default -> {\n        LOGGER.error(\"Unexpected error calling key transparency service\", exception);\n        throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, exception);\n      }\n    }\n  }\n\n  private void requireNotAuthenticated(final Optional<AuthenticatedDevice> authenticatedAccount) {\n    if (authenticatedAccount.isPresent()) {\n      throw new BadRequestException(\"Endpoint requires unauthenticated access\");\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/KeysController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.parameters.RequestBody;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.DefaultValue;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.NotAuthorizedException;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.ByteBuffer;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;\nimport org.whispersystems.textsecuregcm.auth.Anonymous;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;\nimport org.whispersystems.textsecuregcm.auth.OptionalAccess;\nimport org.whispersystems.textsecuregcm.entities.CheckKeysRequest;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.PreKeyCount;\nimport org.whispersystems.textsecuregcm.entities.PreKeyResponse;\nimport org.whispersystems.textsecuregcm.entities.PreKeyResponseItem;\nimport org.whispersystems.textsecuregcm.entities.PreKeySignatureValidator;\nimport org.whispersystems.textsecuregcm.entities.SetKeysRequest;\nimport org.whispersystems.textsecuregcm.entities.SignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n@Path(\"/v2/keys\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Keys\")\npublic class KeysController {\n\n  private final RateLimiters rateLimiters;\n  private final KeysManager keysManager;\n  private final AccountsManager accounts;\n  private final ServerSecretParams serverSecretParams;\n  private final Clock clock;\n\n  private static final String STORE_KEYS_COUNTER_NAME = MetricsUtil.name(KeysController.class, \"storeKeys\");\n  private static final String PRIMARY_DEVICE_TAG_NAME = \"isPrimary\";\n  private static final String IDENTITY_TYPE_TAG_NAME = \"identityType\";\n  private static final String KEY_TYPE_TAG_NAME = \"keyType\";\n\n  private static final CompletableFuture<?>[] EMPTY_FUTURE_ARRAY = new CompletableFuture[0];\n\n  public KeysController(RateLimiters rateLimiters, KeysManager keysManager, AccountsManager accounts, ServerSecretParams serverSecretParams, Clock clock) {\n    this.rateLimiters = rateLimiters;\n    this.keysManager = keysManager;\n    this.accounts = accounts;\n    this.serverSecretParams = serverSecretParams;\n    this.clock = clock;\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Get prekey count\",\n      description = \"Gets the number of one-time prekeys uploaded for this device and still available\")\n  @ApiResponse(responseCode = \"200\", description = \"Body contains the number of available one-time prekeys for the device.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  public CompletableFuture<PreKeyCount> getStatus(@Auth final AuthenticatedDevice auth,\n      @QueryParam(\"identity\") @DefaultValue(\"aci\") final IdentityType identityType) {\n\n    return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())\n        .thenCompose(maybeAccount -> {\n          final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          final CompletableFuture<Integer> ecCountFuture =\n              keysManager.getEcCount(account.getIdentifier(identityType), auth.deviceId());\n\n          final CompletableFuture<Integer> pqCountFuture =\n              keysManager.getPqCount(account.getIdentifier(identityType), auth.deviceId());\n\n          return ecCountFuture.thenCombine(pqCountFuture, PreKeyCount::new);\n        });\n  }\n\n  @PUT\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Upload new prekeys\", description = \"Upload new pre-keys for this device.\")\n  @ApiResponse(responseCode = \"200\", description = \"Indicates that new keys were successfully stored.\")\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"403\", description = \"Attempt to change identity key from a non-primary device.\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format.\")\n  public CompletableFuture<Response> setKeys(\n      @Auth final AuthenticatedDevice auth,\n      @RequestBody @NotNull @Valid final SetKeysRequest setKeysRequest,\n\n      @Parameter(allowEmptyValue=true)\n      @Schema(\n          allowableValues={\"aci\", \"pni\"},\n          defaultValue=\"aci\",\n          description=\"whether this operation applies to the account (aci) or phone-number (pni) identity\")\n      @QueryParam(\"identity\") @DefaultValue(\"aci\") final IdentityType identityType,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {\n\n    return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())\n        .thenCompose(maybeAccount -> {\n          final Account account = maybeAccount\n              .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          final Device device = account.getDevice(auth.deviceId())\n              .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          final UUID identifier = account.getIdentifier(identityType);\n\n          checkSignedPreKeySignatures(setKeysRequest, account.getIdentityKey(identityType), userAgent);\n\n          final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);\n          final Tag primaryDeviceTag = Tag.of(PRIMARY_DEVICE_TAG_NAME, String.valueOf(auth.deviceId() == Device.PRIMARY_ID));\n          final Tag identityTypeTag = Tag.of(IDENTITY_TYPE_TAG_NAME, identityType.name());\n\n          final List<CompletableFuture<Void>> storeFutures = new ArrayList<>(4);\n\n          if (!setKeysRequest.preKeys().isEmpty()) {\n            final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, \"ec\"));\n\n            Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();\n\n            storeFutures.add(keysManager.storeEcOneTimePreKeys(identifier, device.getId(), setKeysRequest.preKeys()));\n          }\n\n          if (setKeysRequest.signedPreKey() != null) {\n            Metrics.counter(STORE_KEYS_COUNTER_NAME,\n                    Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, \"ec-signed\")))\n                .increment();\n\n            storeFutures.add(keysManager.storeEcSignedPreKeys(identifier, device.getId(), setKeysRequest.signedPreKey()));\n          }\n\n          if (!setKeysRequest.pqPreKeys().isEmpty()) {\n            final Tags tags = Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, \"kyber\"));\n            Metrics.counter(STORE_KEYS_COUNTER_NAME, tags).increment();\n\n            storeFutures.add(keysManager.storeKemOneTimePreKeys(identifier, device.getId(), setKeysRequest.pqPreKeys()));\n          }\n\n          if (setKeysRequest.pqLastResortPreKey() != null) {\n            Metrics.counter(STORE_KEYS_COUNTER_NAME,\n                    Tags.of(platformTag, primaryDeviceTag, identityTypeTag, Tag.of(KEY_TYPE_TAG_NAME, \"kyber-last-resort\")))\n                .increment();\n\n            storeFutures.add(keysManager.storePqLastResort(identifier, device.getId(), setKeysRequest.pqLastResortPreKey()));\n          }\n\n          return CompletableFuture.allOf(storeFutures.toArray(EMPTY_FUTURE_ARRAY))\n              .thenApply(Util.ASYNC_EMPTY_RESPONSE);\n        });\n  }\n\n  private void checkSignedPreKeySignatures(final SetKeysRequest setKeysRequest,\n      final IdentityKey identityKey,\n      @Nullable final String userAgent) {\n\n    final List<SignedPreKey<?>> signedPreKeys = new ArrayList<>(setKeysRequest.pqPreKeys());\n\n    if (setKeysRequest.pqLastResortPreKey() != null) {\n      signedPreKeys.add(setKeysRequest.pqLastResortPreKey());\n    }\n\n    if (setKeysRequest.signedPreKey() != null) {\n      signedPreKeys.add(setKeysRequest.signedPreKey());\n    }\n\n    final boolean allSignaturesValid =\n        signedPreKeys.isEmpty() || PreKeySignatureValidator.validatePreKeySignatures(identityKey, signedPreKeys, userAgent, \"set-keys\");\n\n    if (!allSignaturesValid) {\n      throw new WebApplicationException(\"Invalid signature\", 422);\n    }\n  }\n\n  @POST\n  @Path(\"/check\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Check keys\", description = \"\"\"\n      Checks that client and server have consistent views of repeated-use keys. For a given identity type, clients\n      submit a digest of their repeated-use key material. The digest is calculated as:\n      \n      SHA256(identityKeyBytes || signedEcPreKeyId || signedEcPreKeyIdBytes || lastResortKeyId || lastResortKeyBytes)\n      \n      …where the elements of the hash are:\n      \n      - identityKeyBytes: the serialized form of the client's public identity key as produced by libsignal (i.e. one\n        version byte followed by 32 bytes of key material for a total of 33 bytes)\n      - signedEcPreKeyId: an 8-byte, big-endian representation of the ID of the client's signed EC pre-key\n      - signedEcPreKeyBytes: the serialized form of the client's signed EC pre-key as produced by libsignal (i.e. one\n        version byte followed by 32 bytes of key material for a total of 33 bytes)\n      - lastResortKeyId: an 8-byte, big-endian representation of the ID of the client's last-resort Kyber key\n      - lastResortKeyBytes: the serialized form of the client's last-resort Kyber key as produced by libsignal (i.e. one\n        version byte followed by 1568 bytes of key material for a total of 1569 bytes)\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Indicates that client and server have consistent views of repeated-use keys\")\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed\")\n  @ApiResponse(responseCode = \"409\", description = \"\"\"\n    Indicates that client and server have inconsistent views of repeated-use keys or one or more repeated-use keys could\n    not be found\n  \"\"\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format\")\n  public CompletableFuture<Response> checkKeys(\n      @Auth final AuthenticatedDevice auth,\n      @RequestBody @NotNull @Valid final CheckKeysRequest checkKeysRequest) {\n\n    return accounts.getByAccountIdentifierAsync(auth.accountIdentifier())\n        .thenCompose(maybeAccount -> {\n          final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n          final UUID identifier = account.getIdentifier(checkKeysRequest.identityType());\n          final byte deviceId = auth.deviceId();\n\n          final CompletableFuture<Optional<ECSignedPreKey>> ecSignedPreKeyFuture =\n              keysManager.getEcSignedPreKey(identifier, deviceId);\n\n          final CompletableFuture<Optional<KEMSignedPreKey>> lastResortKeyFuture =\n              keysManager.getLastResort(identifier, deviceId);\n\n          return CompletableFuture.allOf(ecSignedPreKeyFuture, lastResortKeyFuture)\n              .thenApply(ignored -> {\n                final Optional<ECSignedPreKey> maybeSignedPreKey = ecSignedPreKeyFuture.join();\n                final Optional<KEMSignedPreKey> maybeLastResortKey = lastResortKeyFuture.join();\n\n                final boolean digestsMatch;\n\n                if (maybeSignedPreKey.isPresent() && maybeLastResortKey.isPresent()) {\n                  final IdentityKey identityKey = account.getIdentityKey(checkKeysRequest.identityType());\n                  final ECSignedPreKey ecSignedPreKey = maybeSignedPreKey.get();\n                  final KEMSignedPreKey lastResortKey = maybeLastResortKey.get();\n\n                  final MessageDigest messageDigest;\n\n                  try {\n                    messageDigest = MessageDigest.getInstance(\"SHA-256\");\n                  } catch (final NoSuchAlgorithmException e) {\n                    throw new AssertionError(\"Every implementation of the Java platform is required to support SHA-256\", e);\n                  }\n\n                  messageDigest.update(identityKey.serialize());\n\n                  {\n                    final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);\n                    ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());\n                    ecSignedPreKeyIdBuffer.flip();\n\n                    messageDigest.update(ecSignedPreKeyIdBuffer);\n                    messageDigest.update(ecSignedPreKey.serializedPublicKey());\n                  }\n\n                  {\n                    final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);\n                    lastResortKeyIdBuffer.putLong(lastResortKey.keyId());\n                    lastResortKeyIdBuffer.flip();\n\n                    messageDigest.update(lastResortKeyIdBuffer);\n                    messageDigest.update(lastResortKey.serializedPublicKey());\n                  }\n\n                  digestsMatch = MessageDigest.isEqual(messageDigest.digest(), checkKeysRequest.digest());\n                } else {\n                  digestsMatch = false;\n                }\n\n                return Response.status(digestsMatch ? Response.Status.OK : Response.Status.CONFLICT).build();\n              });\n        });\n  }\n\n  @GET\n  @ManagedAsync\n  @Path(\"/{identifier}/{device_id}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Fetch public keys for another user\",\n      description = \"Retrieves the public identity key and available device prekeys for a specified account or phone-number identity\")\n  @ApiResponse(responseCode = \"200\", description = \"Indicates at least one prekey was available for at least one requested device.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"A group send endorsement and other authorization (account authentication or unidentified-access key) were both provided.\")\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed and unidentified-access key or group send endorsement token was not supplied or invalid.\")\n  @ApiResponse(responseCode = \"404\", description = \"Requested identity or device does not exist or device has no available prekeys.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limit exceeded.\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  public PreKeyResponse getDeviceKeys(\n      @Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,\n      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,\n      @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,\n\n      @Parameter(description=\"the account or phone-number identifier to retrieve keys for\")\n      @PathParam(\"identifier\") ServiceIdentifier targetIdentifier,\n\n      @Parameter(description=\"the device id of a single device to retrieve prekeys for, or `*` for all enabled devices\")\n      @PathParam(\"device_id\") String deviceId,\n\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent)\n      throws RateLimitExceededException {\n\n    if (maybeAuthenticatedDevice.isEmpty() && accessKey.isEmpty() && groupSendToken.isEmpty()) {\n      throw new WebApplicationException(Response.Status.UNAUTHORIZED);\n    }\n\n    final Optional<Account> account = maybeAuthenticatedDevice\n        .map(authenticatedDevice -> accounts.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n            .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));\n\n    final Optional<Account> maybeTarget = accounts.getByServiceIdentifier(targetIdentifier);\n\n    if (groupSendToken.isPresent()) {\n      if (maybeAuthenticatedDevice.isPresent() || accessKey.isPresent()) {\n        throw new BadRequestException();\n      }\n      try {\n        final GroupSendFullToken token = groupSendToken.get().token();\n        token.verify(List.of(targetIdentifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams));\n      } catch (VerificationFailedException e) {\n        throw new NotAuthorizedException(e);\n      }\n    } else {\n      OptionalAccess.verify(account, accessKey, maybeTarget, targetIdentifier, deviceId);\n    }\n    final Account target = maybeTarget.orElseThrow(NotFoundException::new);\n\n    if (account.isPresent()) {\n      rateLimiters.getPreKeysLimiter().validate(getPreKeysLimiterKey(account.get(), maybeAuthenticatedDevice.get(),\n          targetIdentifier, target, deviceId));\n    }\n\n    final List<Device> devices = parseDeviceId(deviceId, target);\n\n    final List<PreKeyResponseItem> responseItems = Flux.fromIterable(devices).flatMap(device -> Mono\n            .fromCompletionStage(keysManager.takeDevicePreKeys(device.getId(), targetIdentifier, userAgent))\n            .flatMap(Mono::justOrEmpty)\n            .map(devicePreKeys -> new PreKeyResponseItem(\n                device.getId(), device.getRegistrationId(targetIdentifier.identityType()),\n                devicePreKeys.ecSignedPreKey(),\n                devicePreKeys.ecPreKey().orElse(null),\n                devicePreKeys.kemSignedPreKey())))\n        .collectList()\n        .blockOptional()\n        .orElseGet(Collections::emptyList);\n\n    final IdentityKey identityKey = target.getIdentityKey(targetIdentifier.identityType());\n\n    if (responseItems.isEmpty()) {\n      throw new WebApplicationException(Response.Status.NOT_FOUND);\n    }\n\n    return new PreKeyResponse(identityKey, responseItems);\n  }\n\n  private List<Device> parseDeviceId(String deviceId, Account account) {\n    if (deviceId.equals(\"*\")) {\n      return account.getDevices();\n    }\n    try {\n      byte id = Byte.parseByte(deviceId);\n      return account.getDevice(id).map(List::of).orElse(List.of());\n    } catch (NumberFormatException e) {\n      throw new WebApplicationException(Response.status(422).build());\n    }\n  }\n\n  private String getPreKeysLimiterKey(\n      final Account account,\n      final AuthenticatedDevice authenticatedDevice,\n      final ServiceIdentifier targetIdentifier,\n      final Account targetAccount,\n      final String targetDeviceId) {\n    final String targetRegistrationId = targetDeviceId.equals(\"*\")\n        ? \"*\"\n        : String.valueOf(\n            parseDeviceId(targetDeviceId, targetAccount).getFirst().getRegistrationId(targetIdentifier.identityType()));\n\n    return String.format(\"%s.%s__%s.%s.%s\",\n        account.getUuid(),\n        authenticatedDevice.deviceId(),\n        targetIdentifier.uuid(),\n        targetDeviceId,\n        targetRegistrationId\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/MessageController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport io.micrometer.core.instrument.Timer.Sample;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.DefaultValue;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.InternalServerErrorException;\nimport jakarta.ws.rs.NotAuthorizedException;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CancellationException;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.Anonymous;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.CombinedUnidentifiedSenderAccessKeys;\nimport org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;\nimport org.whispersystems.textsecuregcm.auth.OptionalAccess;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.entities.AccountMismatchedDevices;\nimport org.whispersystems.textsecuregcm.entities.AccountStaleDevices;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessage;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessageList;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.entities.MismatchedDevicesResponse;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;\nimport org.whispersystems.textsecuregcm.entities.SendMessageResponse;\nimport org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;\nimport org.whispersystems.textsecuregcm.entities.SpamReport;\nimport org.whispersystems.textsecuregcm.entities.StaleDevicesResponse;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.push.MessageUtil;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.spam.MessageType;\nimport org.whispersystems.textsecuregcm.spam.SpamCheckResult;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageManager;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport org.whispersystems.websocket.WebsocketHeaders;\nimport reactor.core.scheduler.Scheduler;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n@Path(\"/v1/messages\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Messages\")\npublic class MessageController {\n\n  private static final Logger logger = LoggerFactory.getLogger(MessageController.class);\n\n  private final RateLimiters rateLimiters;\n  private final CardinalityEstimator messageByteLimitEstimator;\n  private final MessageSender messageSender;\n  private final AccountsManager accountsManager;\n  private final MessagesManager messagesManager;\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers;\n  private final PushNotificationManager pushNotificationManager;\n  private final PushNotificationScheduler pushNotificationScheduler;\n  private final ReportMessageManager reportMessageManager;\n  private final Scheduler messageDeliveryScheduler;\n  private final ClientReleaseManager clientReleaseManager;\n  private final ServerSecretParams serverSecretParams;\n  private final SpamChecker spamChecker;\n  private final MessageMetrics messageMetrics;\n  private final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor;\n  private final Clock clock;\n\n  private static final CompletableFuture<?>[] EMPTY_FUTURE_ARRAY = new CompletableFuture<?>[0];\n\n  private static final String OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME = name(MessageController.class, \"outgoingMessageListSizeBytes\");\n\n  private static final Timer INDIVIDUAL_MESSAGE_LATENCY_TIMER;\n  private static final Timer MULTI_RECIPIENT_MESSAGE_LATENCY_TIMER;\n\n  static {\n    final String timerName = MetricsUtil.name(MessageController.class, \"sendMessageLatency\");\n    final String multiRecipientTagName = \"multiRecipient\";\n\n    INDIVIDUAL_MESSAGE_LATENCY_TIMER = Timer.builder(timerName)\n        .tags(multiRecipientTagName, \"false\")\n        .register(Metrics.globalRegistry);\n\n    MULTI_RECIPIENT_MESSAGE_LATENCY_TIMER = Timer.builder(timerName)\n        .tags(multiRecipientTagName, \"true\")\n        .register(Metrics.globalRegistry);\n  }\n\n  private static final String INCORRECT_SYNC_MESSAGE_REGISTRATION_IDS_COUNTER_NAME =\n      MetricsUtil.name(MessageController.class, \"incorrectSyncMessageRegistrationIds\");\n\n  private static final String LEGACY_COMBINED_UAK_COUNTER_NAME =\n      MetricsUtil.name(MessageController.class, \"legacyCombinedUak\");\n\n  // The Signal desktop client (really, JavaScript in general) can handle message timestamps at most 100,000,000 days\n  // past the epoch; please see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date\n  // for additional details.\n  public static final long MAX_TIMESTAMP = 86_400_000L * 100_000_000L;\n\n  private static final Duration NOTIFY_FOR_REMAINING_MESSAGES_DELAY = Duration.ofMinutes(1);\n\n  private static final SendMultiRecipientMessageResponse SEND_STORY_RESPONSE =\n      new SendMultiRecipientMessageResponse(Collections.emptyList());\n\n  public MessageController(\n      RateLimiters rateLimiters,\n      CardinalityEstimator messageByteLimitEstimator,\n      MessageSender messageSender,\n      AccountsManager accountsManager,\n      MessagesManager messagesManager,\n      PhoneNumberIdentifiers phoneNumberIdentifiers,\n      PushNotificationManager pushNotificationManager,\n      PushNotificationScheduler pushNotificationScheduler,\n      ReportMessageManager reportMessageManager,\n      Scheduler messageDeliveryScheduler,\n      final ClientReleaseManager clientReleaseManager,\n      final ServerSecretParams serverSecretParams,\n      final SpamChecker spamChecker,\n      final MessageMetrics messageMetrics,\n      final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,\n      final Clock clock) {\n    this.rateLimiters = rateLimiters;\n    this.messageByteLimitEstimator = messageByteLimitEstimator;\n    this.messageSender = messageSender;\n    this.accountsManager = accountsManager;\n    this.messagesManager = messagesManager;\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n    this.pushNotificationManager = pushNotificationManager;\n    this.pushNotificationScheduler = pushNotificationScheduler;\n    this.reportMessageManager = reportMessageManager;\n    this.messageDeliveryScheduler = messageDeliveryScheduler;\n    this.clientReleaseManager = clientReleaseManager;\n    this.serverSecretParams = serverSecretParams;\n    this.spamChecker = spamChecker;\n    this.messageMetrics = messageMetrics;\n    this.messageDeliveryLoopMonitor = messageDeliveryLoopMonitor;\n    this.clock = clock;\n  }\n\n  @Path(\"/{destination}\")\n  @PUT\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  @Operation(\n      summary = \"Send a message\",\n      description = \"\"\"\n          Deliver a message to a single recipient. May be authenticated or unauthenticated; if unauthenticated,\n          an unidentifed-access key or group-send endorsement token must be provided, unless the message is a story.\n          \"\"\")\n  @ApiResponse(\n      responseCode=\"200\", \n      description=\"Message was successfully sent\",\n      content = @Content(schema = @Schema(implementation = SendMessageResponse.class)))\n  @ApiResponse(\n      responseCode=\"401\",\n      description=\"The message is not a story and the authorization, unauthorized access key, or group send endorsement token is missing or incorrect\")\n  @ApiResponse(\n      responseCode=\"404\",\n      description=\"The message is not a story and some the recipient service ID does not correspond to a registered Signal user\")\n  @ApiResponse(\n      responseCode = \"409\", description = \"Incorrect set of devices supplied for recipient\",\n      content = @Content(schema = @Schema(implementation = MismatchedDevicesResponse.class)))\n  @ApiResponse(\n      responseCode = \"410\", description = \"Mismatched registration ids supplied for some recipient devices\",\n      content = @Content(schema = @Schema(implementation = StaleDevicesResponse.class)))\n  @ApiResponse(\n      responseCode=\"428\",\n      description=\"The sender should complete a challenge before proceeding\")\n  public Response sendMessage(@Auth final Optional<AuthenticatedDevice> source,\n      @Parameter(description=\"The recipient's unidentified access key\")\n      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) final Optional<Anonymous> accessKey,\n\n      @Parameter(description=\"A group send endorsement token covering the recipient. Must not be combined with `Unidentified-Access-Key` or set on a story message.\")\n      @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN)\n      @Nullable final GroupSendTokenHeader groupSendToken,\n\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n\n      @Parameter(description=\"The recipient’s account or phone-number identifier\")\n      @PathParam(\"destination\") final ServiceIdentifier destinationIdentifier,\n\n      @Parameter(description=\"If true, the message is a story; access tokens are not checked and sending to nonexistent recipients is permitted\")\n      @QueryParam(\"story\") final boolean isStory,\n\n      @Parameter(description=\"The encrypted message payloads for each recipient device\")\n      @NotNull @Valid final IncomingMessageList messages,\n\n      @Context final ContainerRequestContext context) throws RateLimitExceededException {\n\n    if (groupSendToken != null) {\n      if (source.isPresent() || accessKey.isPresent()) {\n        throw new BadRequestException(\"Group send endorsement tokens should not be combined with other authentication\");\n      } else if (isStory) {\n        throw new BadRequestException(\"Group send endorsement tokens should not be sent for story messages\");\n      }\n    }\n\n    final Sample sample = Timer.start();\n    final boolean needsSync;\n\n    try {\n      if (isStory) {\n        needsSync = false;\n        sendStoryMessage(destinationIdentifier, messages, context);\n      } else if (source.isPresent()) {\n        final AuthenticatedDevice authenticatedDevice = source.get();\n        final Account account = accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n            .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n        if (account.isIdentifiedBy(destinationIdentifier)) {\n          needsSync = false;\n          sendSyncMessage(source.get(), account, destinationIdentifier, messages, context);\n        } else {\n          needsSync = account.getDevices().size() > 1;\n          sendIdentifiedSenderIndividualMessage(authenticatedDevice, destinationIdentifier, messages, context);\n        }\n      } else {\n        needsSync = false;\n        sendSealedSenderMessage(destinationIdentifier, messages, accessKey, groupSendToken != null ? groupSendToken.token() : null, context);\n      }\n    } finally {\n      sample.stop(INDIVIDUAL_MESSAGE_LATENCY_TIMER);\n    }\n\n    return Response.ok(new SendMessageResponse(needsSync)).build();\n  }\n\n  private void sendIdentifiedSenderIndividualMessage(final AuthenticatedDevice source,\n      final ServiceIdentifier destinationIdentifier,\n      final IncomingMessageList messages,\n      final ContainerRequestContext context)\n      throws RateLimitExceededException {\n\n    final Account destination =\n        accountsManager.getByServiceIdentifier(destinationIdentifier).orElseThrow(NotFoundException::new);\n\n    rateLimiters.getMessagesLimiter().validate(source.accountIdentifier(), destination.getUuid());\n\n    sendIndividualMessage(destination,\n        destinationIdentifier,\n        source,\n        messages,\n        false,\n        MessageType.INDIVIDUAL_IDENTIFIED_SENDER,\n        context);\n  }\n\n  private void sendSyncMessage(final AuthenticatedDevice source,\n      final Account sourceAccount,\n      final ServiceIdentifier destinationIdentifier,\n      final IncomingMessageList messages,\n      final ContainerRequestContext context)\n      throws RateLimitExceededException {\n\n    if (destinationIdentifier.identityType() == IdentityType.PNI) {\n      throw new WebApplicationException(Status.FORBIDDEN);\n    }\n\n    sendIndividualMessage(sourceAccount,\n        destinationIdentifier,\n        source,\n        messages,\n        false,\n        MessageType.SYNC,\n        context);\n  }\n\n  private void sendSealedSenderMessage(final ServiceIdentifier destinationIdentifier,\n      final IncomingMessageList messages,\n      final Optional<Anonymous> accessKey,\n      @Nullable final GroupSendFullToken groupSendToken,\n      final ContainerRequestContext context)\n      throws RateLimitExceededException {\n\n    if (accessKey.isEmpty() && groupSendToken == null) {\n      throw new WebApplicationException(Status.UNAUTHORIZED);\n    }\n\n    final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationIdentifier);\n\n    if (groupSendToken != null) {\n      checkGroupSendToken(List.of(destinationIdentifier.toLibsignal()), groupSendToken);\n    } else {\n      OptionalAccess.verify(Optional.empty(), accessKey, maybeDestination, destinationIdentifier);\n    }\n\n    final Account destination = maybeDestination.orElseThrow(NotFoundException::new);\n\n    sendIndividualMessage(destination,\n        destinationIdentifier,\n        null,\n        messages,\n        false,\n        MessageType.INDIVIDUAL_SEALED_SENDER,\n        context);\n  }\n\n  private void sendStoryMessage(final ServiceIdentifier destinationIdentifier,\n      final IncomingMessageList messages,\n      final ContainerRequestContext context)\n      throws RateLimitExceededException {\n\n    // We return 200 when stories are sent to a non-existent account. Since story sends bypass OptionalAccess.verify\n    // authentication is handled by the receiving client, we leak information about whether a destination UUID exists if\n    // we return any other code (e.g. 404) from these requests.\n    final Account destination = accountsManager.getByServiceIdentifier(destinationIdentifier).orElseThrow(() ->\n        new WebApplicationException(Response.ok(new SendMessageResponse(false)).build()));\n\n    rateLimiters.getStoriesLimiter().validate(destination.getUuid());\n\n    sendIndividualMessage(destination,\n        destinationIdentifier,\n        null,\n        messages,\n        true,\n        MessageType.INDIVIDUAL_STORY,\n        context);\n  }\n\n  private void sendIndividualMessage(final Account destination,\n      final ServiceIdentifier destinationIdentifier,\n      @Nullable final AuthenticatedDevice sender,\n      final IncomingMessageList messages,\n      final boolean isStory,\n      final MessageType messageType,\n      final ContainerRequestContext context) throws RateLimitExceededException {\n\n    final SpamCheckResult<Response> spamCheckResult =\n        spamChecker.checkForIndividualRecipientSpamHttp(messageType,\n            context,\n            Optional.ofNullable(sender),\n            Optional.of(destination),\n            destinationIdentifier);\n\n    spamCheckResult.response().ifPresent(response -> {\n      throw new WebApplicationException(response);\n    });\n\n    final String userAgent = context.getHeaderString(HttpHeaders.USER_AGENT);\n\n    try {\n      final int totalContentLength =\n          messages.messages().stream().mapToInt(message -> message.content().length).sum();\n\n      rateLimiters.getInboundMessageBytes().validate(destinationIdentifier.uuid(), totalContentLength);\n    } catch (final RateLimitExceededException e) {\n      messageByteLimitEstimator.add(destinationIdentifier.uuid().toString());\n      throw e;\n    }\n\n    final Map<Byte, Envelope> messagesByDeviceId = messages.messages().stream()\n        .collect(Collectors.toMap(IncomingMessage::destinationDeviceId, message -> {\n          try {\n            return message.toEnvelope(\n                destinationIdentifier,\n                sender != null ? new AciServiceIdentifier(sender.accountIdentifier()) : null,\n                sender != null ? sender.deviceId() : null,\n                messages.timestamp() == 0 ? System.currentTimeMillis() : messages.timestamp(),\n                isStory,\n                messages.online(),\n                messages.urgent(),\n                spamCheckResult.token().orElse(null),\n                clock);\n          } catch (final IllegalArgumentException e) {\n            logger.warn(\"Received bad envelope type {} from {}\", message.type(), userAgent);\n            throw new BadRequestException(e);\n          }\n        }));\n\n    final Map<Byte, Integer> registrationIdsByDeviceId = messages.messages().stream()\n        .collect(Collectors.toMap(IncomingMessage::destinationDeviceId, IncomingMessage::destinationRegistrationId));\n\n    final Optional<Byte> syncMessageSenderDeviceId = messageType == MessageType.SYNC\n        ? Optional.ofNullable(sender).map(AuthenticatedDevice::deviceId)\n        : Optional.empty();\n\n    try {\n      messageSender.sendMessages(destination,\n          destinationIdentifier,\n          messagesByDeviceId,\n          registrationIdsByDeviceId,\n          syncMessageSenderDeviceId,\n          userAgent);\n    } catch (final MismatchedDevicesException e) {\n      if (!e.getMismatchedDevices().staleDeviceIds().isEmpty()) {\n        if (messageType == MessageType.SYNC) {\n          Metrics.counter(INCORRECT_SYNC_MESSAGE_REGISTRATION_IDS_COUNTER_NAME,\n              Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))\n                  .increment();\n        }\n\n        throw new WebApplicationException(Response.status(410)\n            .type(MediaType.APPLICATION_JSON)\n            .entity(new StaleDevicesResponse(e.getMismatchedDevices().staleDeviceIds()))\n            .build());\n      } else {\n        throw new WebApplicationException(Response.status(409)\n            .type(MediaType.APPLICATION_JSON_TYPE)\n            .entity(new MismatchedDevicesResponse(e.getMismatchedDevices().missingDeviceIds(),\n                e.getMismatchedDevices().extraDeviceIds()))\n            .build());\n      }\n    } catch (final MessageTooLargeException e) {\n      throw new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE);\n    }\n  }\n\n  @Path(\"/multi_recipient\")\n  @PUT\n  @Consumes(MultiRecipientMessageProvider.MEDIA_TYPE)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Send multi-recipient sealed-sender message\",\n      description = \"\"\"\n          Deliver a common-payload message to multiple recipients.\n          An unidentifed-access key for all recipients must be provided, unless the message is a story.\n          \"\"\")\n  @ApiResponse(\n      responseCode=\"200\",\n      description=\"Message was successfully sent\",\n      content = @Content(schema = @Schema(implementation = SendMultiRecipientMessageResponse.class)))\n  @ApiResponse(responseCode=\"400\", description=\"The envelope specified delivery to the same recipient device multiple times\")\n  @ApiResponse(\n      responseCode=\"401\",\n      description=\"The message is not a story and the unauthorized access key or group send endorsement token is missing or incorrect\")\n  @ApiResponse(\n      responseCode=\"404\",\n      description=\"The message is not a story and some of the recipient service IDs do not correspond to registered Signal users\")\n  @ApiResponse(\n      responseCode = \"409\", description = \"Incorrect set of devices supplied for some recipients\",\n      content = @Content(schema = @Schema(implementation = AccountMismatchedDevices[].class)))\n  @ApiResponse(\n      responseCode = \"410\", description = \"Mismatched registration ids supplied for some recipient devices\",\n      content = @Content(schema = @Schema(implementation = AccountStaleDevices[].class)))\n  public Response sendMultiRecipientMessage(\n      @Deprecated\n      @Parameter(description=\"The bitwise xor of the unidentified access keys for every recipient of the message. Will be replaced with group send endorsements\")\n      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) @Nullable CombinedUnidentifiedSenderAccessKeys accessKeys,\n\n      @Parameter(description=\"A group send endorsement token covering recipients of this message. Must not be combined with `Unidentified-Access-Key` or set on a story message.\")\n      @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN)\n      @Nullable GroupSendTokenHeader groupSendToken,\n\n      @Parameter(description=\"If true, deliver the message only to recipients that are online when it is sent\")\n      @QueryParam(\"online\") boolean online,\n\n      @Parameter(description=\"The sender's timestamp for the envelope\")\n      @QueryParam(\"ts\") long timestamp,\n\n      @Parameter(description=\"If true, this message should cause push notifications to be sent to recipients\")\n      @QueryParam(\"urgent\") @DefaultValue(\"true\") final boolean isUrgent,\n\n      @Parameter(description=\"If true, the message is a story; access tokens are not checked and sending to nonexistent recipients is permitted\")\n      @QueryParam(\"story\") boolean isStory,\n      @Parameter(description=\"The sealed-sender multi-recipient message payload as serialized by libsignal\")\n      @NotNull SealedSenderMultiRecipientMessage multiRecipientMessage,\n\n      @Context ContainerRequestContext context) {\n\n    if (timestamp < 0 || timestamp > MAX_TIMESTAMP) {\n      throw new BadRequestException(\"Illegal timestamp\");\n    }\n\n    if (multiRecipientMessage.getRecipients().isEmpty()) {\n      throw new BadRequestException(\"Recipient list is empty\");\n    }\n\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      final SendMultiRecipientMessageResponse sendMultiRecipientMessageResponse;\n\n      if (isStory) {\n        if (groupSendToken != null) {\n          // Stories require no authentication. We fail requests that provide a groupSendToken, but for historical\n          // reasons we allow requests to set a combined access key, even though we ignore it\n          throw new BadRequestException(\"Group send token not allowed when sending stories\");\n        }\n\n        sendMultiRecipientMessageResponse =\n            sendMultiRecipientStoryMessage(multiRecipientMessage, timestamp, online, isUrgent, context);\n      } else {\n        sendMultiRecipientMessageResponse =\n            sendMultiRecipientMessage(multiRecipientMessage, timestamp, online, isUrgent, groupSendToken, accessKeys,\n                context);\n      }\n\n      return Response.ok(sendMultiRecipientMessageResponse).build();\n    } finally {\n      sample.stop(MULTI_RECIPIENT_MESSAGE_LATENCY_TIMER);\n    }\n  }\n\n  private SendMultiRecipientMessageResponse sendMultiRecipientMessage(final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final long timestamp,\n      final boolean ephemeral,\n      final boolean urgent,\n      @Nullable final GroupSendTokenHeader groupSendTokenHeader,\n      @Nullable final CombinedUnidentifiedSenderAccessKeys combinedUnidentifiedSenderAccessKeys,\n      final ContainerRequestContext context) {\n\n    // Perform fast, inexpensive checks before attempting to resolve recipients\n    if (MessageUtil.hasDuplicateDevices(multiRecipientMessage)) {\n      throw new BadRequestException(\"Multi-recipient message contains duplicate recipient\");\n    }\n\n    if (groupSendTokenHeader == null && combinedUnidentifiedSenderAccessKeys == null) {\n      throw new NotAuthorizedException(\"A group send endorsement token or unidentified access key is required for non-story messages\");\n    }\n\n    if (groupSendTokenHeader != null && combinedUnidentifiedSenderAccessKeys != null) {\n      throw new BadRequestException(\"Only one of group send endorsement token and unidentified access key may be provided\");\n    }\n\n    if (groupSendTokenHeader != null) {\n      // Group send endorsements are checked before we even attempt to resolve any accounts, since\n      // the lists of service IDs in the envelope are all that we need to check against\n      checkGroupSendToken(multiRecipientMessage.getRecipients().keySet(), groupSendTokenHeader);\n    } else {\n      Metrics.counter(LEGACY_COMBINED_UAK_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(context))).increment();\n    }\n\n    // At this point, the caller has at least superficially provided the information needed to send a multi-recipient\n    // message. Attempt to resolve the destination service identifiers to Signal accounts.\n    final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients =\n        MessageUtil.resolveRecipients(accountsManager, multiRecipientMessage);\n\n    final List<ServiceIdentifier> unresolvedRecipientServiceIdentifiers =\n        MessageUtil.getUnresolvedRecipients(multiRecipientMessage, resolvedRecipients);\n\n    if (groupSendTokenHeader == null && !unresolvedRecipientServiceIdentifiers.isEmpty()) {\n      throw new NotFoundException();\n    }\n\n    // Access keys are checked against the UAK in the resolved accounts, so we have to check after resolving accounts above.\n    // Group send endorsements are checked earlier; for stories, we don't check permissions at all because only clients check them\n    if (groupSendTokenHeader == null) {\n      checkAccessKeys(combinedUnidentifiedSenderAccessKeys, multiRecipientMessage, resolvedRecipients);\n    }\n\n    sendMultiRecipientMessage(multiRecipientMessage,\n        resolvedRecipients,\n        timestamp,\n        false,\n        ephemeral,\n        urgent,\n        context);\n\n    return new SendMultiRecipientMessageResponse(unresolvedRecipientServiceIdentifiers);\n  }\n\n  @SuppressWarnings(\"SameReturnValue\")\n  private SendMultiRecipientMessageResponse sendMultiRecipientStoryMessage(final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final long timestamp,\n      final boolean ephemeral,\n      final boolean urgent,\n      final ContainerRequestContext context) {\n\n    // Perform fast, inexpensive checks before attempting to resolve recipients\n    if (MessageUtil.hasDuplicateDevices(multiRecipientMessage)) {\n      throw new BadRequestException(\"Multi-recipient message contains duplicate recipient\");\n    }\n\n    // At this point, the caller has at least superficially provided the information needed to send a multi-recipient\n    // message. Attempt to resolve the destination service identifiers to Signal accounts.\n    final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients =\n        MessageUtil.resolveRecipients(accountsManager, multiRecipientMessage);\n\n    // We might filter out all the recipients of a story (if none exist).\n    // In this case there is no error so we should just return 200 now.\n    if (resolvedRecipients.isEmpty()) {\n      return SEND_STORY_RESPONSE;\n    }\n\n    CompletableFuture.allOf(resolvedRecipients.values()\n            .stream()\n            .map(account -> account.getIdentifier(IdentityType.ACI))\n            .map(accountIdentifier ->\n                rateLimiters.getStoriesLimiter().validateAsync(accountIdentifier).toCompletableFuture())\n            .toList()\n            .toArray(EMPTY_FUTURE_ARRAY))\n        .join();\n\n    sendMultiRecipientMessage(multiRecipientMessage,\n        resolvedRecipients,\n        timestamp,\n        true,\n        ephemeral,\n        urgent,\n        context);\n\n    return SEND_STORY_RESPONSE;\n  }\n\n  private void sendMultiRecipientMessage(final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients,\n      final long timestamp,\n      final boolean isStory,\n      final boolean ephemeral,\n      final boolean urgent,\n      final ContainerRequestContext context) {\n\n    final MessageType messageType =\n        isStory ? MessageType.MULTI_RECIPIENT_STORY : MessageType.MULTI_RECIPIENT_SEALED_SENDER;\n\n    spamChecker.checkForMultiRecipientSpamHttp(messageType, context).response().ifPresent(response -> {\n      throw new WebApplicationException(response);\n    });\n\n    try {\n      if (!resolvedRecipients.isEmpty()) {\n        messageSender.sendMultiRecipientMessage(multiRecipientMessage,\n            resolvedRecipients,\n            timestamp, isStory,\n            ephemeral,\n            urgent,\n            context.getHeaderString(HttpHeaders.USER_AGENT)).get();\n      }\n    } catch (final InterruptedException e) {\n      logger.error(\"interrupted while delivering multi-recipient messages\", e);\n      throw new InternalServerErrorException(\"interrupted during delivery\");\n    } catch (final CancellationException e) {\n      logger.error(\"cancelled while delivering multi-recipient messages\", e);\n      throw new InternalServerErrorException(\"delivery cancelled\");\n    } catch (final ExecutionException e) {\n      logger.error(\"partial failure while delivering multi-recipient messages\", e.getCause());\n      throw new InternalServerErrorException(\"failure during delivery\");\n    } catch (final MessageTooLargeException e) {\n      throw new WebApplicationException(Status.REQUEST_ENTITY_TOO_LARGE);\n    } catch (final MultiRecipientMismatchedDevicesException e) {\n      final List<AccountMismatchedDevices> accountMismatchedDevices =\n          e.getMismatchedDevicesByServiceIdentifier().entrySet().stream()\n              .filter(entry -> !entry.getValue().missingDeviceIds().isEmpty() || !entry.getValue().extraDeviceIds().isEmpty())\n              .map(entry -> new AccountMismatchedDevices(entry.getKey(),\n                  new MismatchedDevicesResponse(entry.getValue().missingDeviceIds(), entry.getValue().extraDeviceIds())))\n              .toList();\n\n      if (!accountMismatchedDevices.isEmpty()) {\n        throw new WebApplicationException(Response\n            .status(409)\n            .type(MediaType.APPLICATION_JSON_TYPE)\n            .entity(accountMismatchedDevices)\n            .build());\n      }\n\n      final List<AccountStaleDevices> accountStaleDevices =\n          e.getMismatchedDevicesByServiceIdentifier().entrySet().stream()\n              .filter(entry -> !entry.getValue().staleDeviceIds().isEmpty())\n              .map(entry -> new AccountStaleDevices(entry.getKey(),\n                  new StaleDevicesResponse(entry.getValue().staleDeviceIds())))\n              .toList();\n\n      throw new WebApplicationException(Response\n          .status(410)\n          .type(MediaType.APPLICATION_JSON)\n          .entity(accountStaleDevices)\n          .build());\n    }\n  }\n\n  private void checkGroupSendToken(final Collection<ServiceId> recipients, final GroupSendTokenHeader groupSendToken) {\n    checkGroupSendToken(recipients, groupSendToken.token());\n  }\n\n  private void checkGroupSendToken(final Collection<ServiceId> recipients, final GroupSendFullToken groupSendFullToken) {\n    try {\n      groupSendFullToken.verify(recipients,\n          clock.instant(),\n          GroupSendDerivedKeyPair.forExpiration(groupSendFullToken.getExpiration(), serverSecretParams));\n    } catch (final VerificationFailedException e) {\n      throw new NotAuthorizedException(e);\n    }\n  }\n\n  private void checkAccessKeys(\n      final @NotNull CombinedUnidentifiedSenderAccessKeys accessKeys,\n      final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients) {\n\n    if (multiRecipientMessage.getRecipients().keySet().stream()\n        .anyMatch(serviceId -> serviceId instanceof ServiceId.Pni)) {\n\n      throw new WebApplicationException(\"Multi-recipient messages must be addressed to ACI service IDs\",\n          Status.UNAUTHORIZED);\n    }\n\n    try {\n      if (!UnidentifiedAccessUtil.checkUnidentifiedAccess(resolvedRecipients.values(), accessKeys.getAccessKeys())) {\n        throw new WebApplicationException(Status.UNAUTHORIZED);\n      }\n    } catch (final IllegalArgumentException ignored) {\n      throw new WebApplicationException(Status.UNAUTHORIZED);\n    }\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  public CompletableFuture<OutgoingMessageEntityList> getPendingMessages(@Auth AuthenticatedDevice auth,\n      @HeaderParam(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES) String receiveStoriesHeader,\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent) {\n\n    return accountsManager.getByAccountIdentifierAsync(auth.accountIdentifier())\n        .thenCompose(maybeAccount -> {\n          final Account account = maybeAccount.orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n          final Device device = account.getDevice(auth.deviceId())\n              .orElseThrow(() -> new WebApplicationException(Status.UNAUTHORIZED));\n\n          final boolean shouldReceiveStories = WebsocketHeaders.parseReceiveStoriesHeader(receiveStoriesHeader);\n\n          pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);\n\n          return messagesManager.getMessagesForDevice(\n                  auth.accountIdentifier(),\n                  device,\n                  false)\n              .map(messagesAndHasMore -> {\n                Stream<Envelope> envelopes = messagesAndHasMore.first().stream();\n                if (!shouldReceiveStories) {\n                  envelopes = envelopes.filter(e -> !e.getStory());\n                }\n\n          final OutgoingMessageEntityList messages = new OutgoingMessageEntityList(envelopes\n              .map(OutgoingMessageEntity::fromEnvelope)\n              .peek(outgoingMessageEntity -> {\n                messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageEntity);\n                messageMetrics.measureOutgoingMessageLatency(outgoingMessageEntity.serverTimestamp(),\n                    \"rest\",\n                    auth.deviceId() == Device.PRIMARY_ID,\n                    outgoingMessageEntity.urgent(),\n                    // Messages fetched via this endpoint (as opposed to WebSocketConnection) are never ephemeral\n                    // because, by definition, the client doesn't have a \"live\" connection via which to receive\n                    // ephemeral messages.\n                    false,\n                    userAgent,\n                    clientReleaseManager);\n              })\n              .collect(Collectors.toList()),\n              messagesAndHasMore.second());\n\n                Metrics.summary(OUTGOING_MESSAGE_LIST_SIZE_BYTES_DISTRIBUTION_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent)))\n                    .record(estimateMessageListSizeBytes(messages));\n\n                if (!messages.messages().isEmpty()) {\n                  messageDeliveryLoopMonitor.recordDeliveryAttempt(auth.accountIdentifier(),\n                      auth.deviceId(),\n                      messages.messages().getFirst().guid(),\n                      userAgent,\n                      \"rest\");\n                }\n\n                if (messagesAndHasMore.second()) {\n                  pushNotificationScheduler.scheduleDelayedNotification(account, device, NOTIFY_FOR_REMAINING_MESSAGES_DELAY);\n                }\n\n                return messages;\n              })\n              .timeout(Duration.ofSeconds(5))\n              .subscribeOn(messageDeliveryScheduler)\n              .toFuture();\n        });\n  }\n\n  private static long estimateMessageListSizeBytes(final OutgoingMessageEntityList messageList) {\n    long size = 0;\n\n    for (final OutgoingMessageEntity message : messageList.messages()) {\n      size += message.content() == null ? 0 : message.content().length;\n      size += message.sourceUuid() == null ? 0 : 36;\n    }\n\n    return size;\n  }\n\n  @POST\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Path(\"/report/{source}/{messageGuid}\")\n  public Response reportSpamMessage(\n      @Auth AuthenticatedDevice auth,\n      @PathParam(\"source\") String source,\n      @PathParam(\"messageGuid\") UUID messageGuid,\n      @Nullable SpamReport spamReport,\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent\n  ) {\n    final Optional<String> sourceNumber;\n    final Optional<UUID> sourceAci;\n    final Optional<UUID> sourcePni;\n\n    if (source.startsWith(\"+\")) {\n      sourceNumber = Optional.of(source);\n      final Optional<Account> maybeAccount = accountsManager.getByE164(source);\n      if (maybeAccount.isPresent()) {\n        sourceAci = maybeAccount.map(Account::getUuid);\n        sourcePni = maybeAccount.map(Account::getPhoneNumberIdentifier);\n      } else {\n        sourcePni = Optional.ofNullable(phoneNumberIdentifiers.getPhoneNumberIdentifier(source).join());\n        sourceAci = sourcePni.flatMap(accountsManager::findRecentlyDeletedAccountIdentifier);\n      }\n    } else {\n      sourceAci = Optional.of(UUID.fromString(source));\n\n      final Optional<Account> sourceAccount = accountsManager.getByAccountIdentifier(sourceAci.get());\n\n      if (sourceAccount.isEmpty()) {\n        logger.warn(\"Could not find source: {}\", sourceAci.get());\n        sourcePni = accountsManager.findRecentlyDeletedPhoneNumberIdentifier(sourceAci.get());\n        sourceNumber = sourcePni.flatMap(pni ->\n            Util.getCanonicalNumber(phoneNumberIdentifiers.getPhoneNumber(pni).join()));\n      } else {\n        sourceNumber = sourceAccount.map(Account::getNumber);\n        sourcePni = sourceAccount.map(Account::getPhoneNumberIdentifier);\n      }\n    }\n\n    UUID spamReporterUuid = auth.accountIdentifier();\n\n    // spam report token is optional, but if provided ensure it is non-empty.\n    final Optional<byte[]> maybeSpamReportToken =\n        Optional.ofNullable(spamReport)\n            .flatMap(r -> Optional.ofNullable(r.token()))\n            .filter(t -> t.length > 0);\n\n    reportMessageManager.report(sourceNumber, sourceAci, sourcePni, messageGuid, spamReporterUuid, maybeSpamReportToken, userAgent);\n\n    return Response.status(Status.ACCEPTED)\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/MismatchedDevices.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport java.util.Set;\n\npublic record MismatchedDevices(Set<Byte> missingDeviceIds, Set<Byte> extraDeviceIds, Set<Byte> staleDeviceIds) {\n\n  public MismatchedDevices {\n    if (missingDeviceIds.isEmpty() && extraDeviceIds.isEmpty() && staleDeviceIds.isEmpty()) {\n      throw new IllegalArgumentException(\"At least one of missingDevices, extraDevices, or staleDevices must be non-empty\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/MismatchedDevicesException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\npublic class MismatchedDevicesException extends Exception {\n\n  private final MismatchedDevices mismatchedDevices;\n\n  public MismatchedDevicesException(final MismatchedDevices mismatchedDevices) {\n    this.mismatchedDevices = mismatchedDevices;\n  }\n\n  public MismatchedDevices getMismatchedDevices() {\n    return mismatchedDevices;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/MultiRecipientMismatchedDevicesException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\n\npublic class MultiRecipientMismatchedDevicesException extends Exception {\n\n  private final Map<ServiceIdentifier, MismatchedDevices> mismatchedDevicesByServiceIdentifier;\n\n  public MultiRecipientMismatchedDevicesException(\n      final Map<ServiceIdentifier, MismatchedDevices> mismatchedDevicesByServiceIdentifier) {\n\n    if (mismatchedDevicesByServiceIdentifier.isEmpty()) {\n      throw new IllegalArgumentException(\"Must provide non-empty map of service identifiers to mismatched devices\");\n    }\n\n    this.mismatchedDevicesByServiceIdentifier = mismatchedDevicesByServiceIdentifier;\n  }\n\n  public Map<ServiceIdentifier, MismatchedDevices> getMismatchedDevicesByServiceIdentifier() {\n    return mismatchedDevicesByServiceIdentifier;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationController.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.StringToClassMapItem;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.math.BigDecimal;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;\nimport org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;\nimport org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;\nimport org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;\nimport org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;\nimport org.whispersystems.textsecuregcm.subscriptions.StripeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n\n/**\n * Endpoints for making one-time donation payments (boost and gift)\n * <p>\n * Note that these siblings of the endpoints at /v1/subscription on {@link SubscriptionController}. One-time payments do\n * not require the subscription management methods on that controller, though the configuration at\n * /v1/subscription/configuration is shared between subscription and one-time payments.\n */\n@Path(\"/v1/subscription/boost\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"OneTimeDonations\")\npublic class OneTimeDonationController {\n\n  private static final Logger logger = LoggerFactory.getLogger(OneTimeDonationController.class);\n\n  private static final String EURO_CURRENCY_CODE = \"EUR\";\n\n  private final Clock clock;\n  private final OneTimeDonationConfiguration oneTimeDonationConfiguration;\n  private final StripeManager stripeManager;\n  private final BraintreeManager braintreeManager;\n  private final PayPalDonationsTranslator payPalDonationsTranslator;\n  private final ServerZkReceiptOperations zkReceiptOperations;\n  private final IssuedReceiptsManager issuedReceiptsManager;\n  private final OneTimeDonationsManager oneTimeDonationsManager;\n\n  public OneTimeDonationController(\n      @Nonnull Clock clock,\n      @Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,\n      @Nonnull StripeManager stripeManager,\n      @Nonnull BraintreeManager braintreeManager,\n      @Nonnull PayPalDonationsTranslator payPalDonationsTranslator,\n      @Nonnull ServerZkReceiptOperations zkReceiptOperations,\n      @Nonnull IssuedReceiptsManager issuedReceiptsManager,\n      @Nonnull OneTimeDonationsManager oneTimeDonationsManager) {\n    this.clock = Objects.requireNonNull(clock);\n    this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);\n    this.stripeManager = Objects.requireNonNull(stripeManager);\n    this.braintreeManager = Objects.requireNonNull(braintreeManager);\n    this.payPalDonationsTranslator = Objects.requireNonNull(payPalDonationsTranslator);\n    this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);\n    this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);\n    this.oneTimeDonationsManager = Objects.requireNonNull(oneTimeDonationsManager);\n  }\n\n  public static class CreateBoostRequest {\n\n    @Schema(required = true, maxLength = 3, minLength = 3)\n    @NotEmpty\n    @ExactlySize(3)\n    public String currency;\n\n    @Schema(required = true, minimum = \"1\", description = \"The amount to pay in the [currency's minor unit](https://docs.stripe.com/currencies#minor-units)\")\n    @Min(1)\n    public long amount;\n\n    @Schema(description = \"The level for the boost payment. Assumed to be the boost level if missing\")\n    public Long level;\n\n    @Schema(description = \"The payment method\", defaultValue = \"CARD\")\n    public PaymentMethod paymentMethod = PaymentMethod.CARD;\n  }\n\n  public record CreateBoostResponse(\n      @Schema(description = \"A client secret that can be used to complete a stripe PaymentIntent\")\n      String clientSecret) {}\n\n  @POST\n  @Path(\"/create\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Create a Stripe payment intent\", description = \"\"\"\n  Create a Stripe PaymentIntent and return a client secret that can be used to complete the payment.\n\n  Once the payment is complete, the paymentIntentId can be used at /v1/subscriptions/receipt_credentials\n  \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Payment Intent created\", content = @Content(schema = @Schema(implementation = CreateBoostResponse.class)))\n  @ApiResponse(responseCode = \"403\", description = \"The request was made on an authenticated channel\")\n  @ApiResponse(responseCode = \"400\", description = \"\"\"\n      Invalid argument. The response body may include an error code with more specific information. If the error code\n      is `amount_below_currency_minimum` the body will also include the `minimum` field indicating the minimum amount\n      for the currency. If the error code is `amount_above_sepa_limit` the body will also include the `maximum`\n      field indicating the maximum amount for a SEPA transaction.\n      \"\"\",\n      content = @Content(schema = @Schema(\n          type = \"object\",\n          properties = {\n              @StringToClassMapItem(key = \"error\", value = String.class)\n          })))\n  @ApiResponse(responseCode = \"409\", description = \"Provided level does not match the currency/amount combination\",\n      content = @Content(schema = @Schema(\n          type = \"object\",\n          properties = {\n              @StringToClassMapItem(key = \"error\", value = String.class)\n          })))\n  public CompletableFuture<Response> createBoostPaymentIntent(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid CreateBoostRequest request,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {\n\n    if (authenticatedAccount.isPresent()) {\n      throw new ForbiddenException(\"must not use authenticated connection for one-time donation operations\");\n    }\n\n    return CompletableFuture.runAsync(() -> {\n          if (request.level == null) {\n            request.level = oneTimeDonationConfiguration.boost().level();\n          }\n          BigDecimal amount = BigDecimal.valueOf(request.amount);\n          if (request.level == oneTimeDonationConfiguration.gift().level()) {\n            BigDecimal amountConfigured = oneTimeDonationConfiguration.currencies()\n                .get(request.currency.toLowerCase(Locale.ROOT)).gift();\n            if (amountConfigured == null ||\n                SubscriptionCurrencyUtil.convertConfiguredAmountToStripeAmount(request.currency, amountConfigured)\n                    .compareTo(amount) != 0) {\n              throw new WebApplicationException(\n                  Response.status(Response.Status.CONFLICT).entity(Map.of(\"error\", \"level_amount_mismatch\")).build());\n            }\n          }\n          validateRequestCurrencyAmount(request, amount, stripeManager);\n        })\n        .thenCompose(unused -> stripeManager.createPaymentIntent(request.currency, request.amount, request.level,\n            getClientPlatform(userAgent)))\n        .thenApply(paymentIntent -> Response.ok(new CreateBoostResponse(paymentIntent.getClientSecret())).build());\n  }\n\n  /**\n   * Validates that the currency is supported by the {@code manager} and {@code request.paymentMethod} and that the\n   * amount meets minimum and maximum constraints.\n   *\n   * @throws BadRequestException indicates validation failed. Inspect {@code response.error} for details\n   */\n  private void validateRequestCurrencyAmount(CreateBoostRequest request, BigDecimal amount,\n      CustomerAwareSubscriptionPaymentProcessor manager) {\n    if (!manager.getSupportedCurrenciesForPaymentMethod(request.paymentMethod)\n        .contains(request.currency.toLowerCase(Locale.ROOT))) {\n      throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)\n          .entity(Map.of(\"error\", \"unsupported_currency\")).build());\n    }\n\n    BigDecimal minCurrencyAmountMajorUnits = oneTimeDonationConfiguration.currencies()\n        .get(request.currency.toLowerCase(Locale.ROOT)).minimum();\n    BigDecimal minCurrencyAmountMinorUnits = SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(\n        request.currency,\n        minCurrencyAmountMajorUnits);\n    if (minCurrencyAmountMinorUnits.compareTo(amount) > 0) {\n      throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)\n          .entity(Map.of(\n              \"error\", \"amount_below_currency_minimum\",\n              \"minimum\", minCurrencyAmountMajorUnits.toString())).build());\n    }\n\n    if (request.paymentMethod == PaymentMethod.SEPA_DEBIT &&\n        amount.compareTo(SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(\n            EURO_CURRENCY_CODE,\n            oneTimeDonationConfiguration.sepaMaximumEuros())) > 0) {\n      throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)\n          .entity(Map.of(\n              \"error\", \"amount_above_sepa_limit\",\n              \"maximum\", oneTimeDonationConfiguration.sepaMaximumEuros().toString())).build());\n    }\n  }\n\n  public static class CreatePayPalBoostRequest extends CreateBoostRequest {\n\n    @NotEmpty\n    public String returnUrl;\n    @NotEmpty\n    public String cancelUrl;\n\n    public CreatePayPalBoostRequest() {\n      super.paymentMethod = PaymentMethod.PAYPAL;\n    }\n  }\n\n  record CreatePayPalBoostResponse(String approvalUrl, String paymentId) {}\n\n  @POST\n  @Path(\"/paypal/create\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  public CompletableFuture<Response> createPayPalBoost(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid CreatePayPalBoostRequest request,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @Context ContainerRequestContext containerRequestContext) {\n\n    if (authenticatedAccount.isPresent()) {\n      throw new ForbiddenException(\"must not use authenticated connection for one-time donation operations\");\n    }\n\n    return CompletableFuture.runAsync(() -> {\n          if (request.level == null) {\n            request.level = oneTimeDonationConfiguration.boost().level();\n          }\n\n          validateRequestCurrencyAmount(request, BigDecimal.valueOf(request.amount), braintreeManager);\n        })\n        .thenCompose(unused -> {\n          final List<Locale> acceptableLanguages =\n              HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);\n\n          // These two localizations are a best-effort, and it's possible that the first `locale` and the localized line\n          // item name will not match. We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>\n          // but that's a moving target, and we can hopefully have one of them be better for the user by selecting\n          // independently.\n          final Locale locale = acceptableLanguages.stream()\n              .filter(l -> !\"*\".equals(l.getLanguage()))\n              .findFirst()\n              .orElse(Locale.US);\n          final String localizedLineItemName = payPalDonationsTranslator.translate(acceptableLanguages,\n              PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY);\n\n          return braintreeManager.createOneTimePayment(request.currency.toUpperCase(Locale.ROOT), request.amount,\n              locale.toLanguageTag(),\n              request.returnUrl, request.cancelUrl, localizedLineItemName);\n        })\n        .thenApply(approvalDetails -> Response.ok(\n            new CreatePayPalBoostResponse(approvalDetails.approvalUrl(), approvalDetails.paymentId())).build());\n  }\n\n  public static class ConfirmPayPalBoostRequest extends CreateBoostRequest {\n\n    @NotEmpty\n    public String payerId;\n    @NotEmpty\n    public String paymentId; // PAYID-…\n    @NotEmpty\n    public String paymentToken; // EC-…\n  }\n\n  record ConfirmPayPalBoostResponse(String paymentId) {}\n\n  @POST\n  @Path(\"/paypal/confirm\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  public CompletableFuture<Response> confirmPayPalBoost(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid ConfirmPayPalBoostRequest request,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {\n\n    if (authenticatedAccount.isPresent()) {\n      throw new ForbiddenException(\"must not use authenticated connection for one-time donation operations\");\n    }\n\n    return CompletableFuture.runAsync(() -> {\n          if (request.level == null) {\n            request.level = oneTimeDonationConfiguration.boost().level();\n          }\n        })\n        .thenCompose(unused -> braintreeManager.captureOneTimePayment(request.payerId, request.paymentId,\n            request.paymentToken, request.currency, request.amount, request.level, getClientPlatform(userAgent)))\n        .thenCompose(\n            chargeSuccessDetails -> oneTimeDonationsManager.putPaidAt(chargeSuccessDetails.paymentId(), Instant.now()))\n        .thenApply(paymentId -> Response.ok(\n            new ConfirmPayPalBoostResponse(paymentId)).build());\n  }\n\n  public static class CreateBoostReceiptCredentialsRequest {\n\n    /**\n     * a payment ID from {@link #processor}\n     */\n    @NotNull\n    public String paymentIntentId;\n    @NotNull\n    public byte[] receiptCredentialRequest;\n\n    @NotNull\n    public PaymentProvider processor = PaymentProvider.STRIPE;\n  }\n\n  public record CreateBoostReceiptCredentialsSuccessResponse(byte[] receiptCredentialResponse) {\n  }\n\n  public record CreateBoostReceiptCredentialsErrorResponse(\n      @JsonInclude(JsonInclude.Include.NON_NULL) ChargeFailure chargeFailure) {}\n\n  @POST\n  @Path(\"/receipt_credentials\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  public CompletableFuture<Response> createBoostReceiptCredentials(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @NotNull @Valid final CreateBoostReceiptCredentialsRequest request,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent) {\n\n    if (authenticatedAccount.isPresent()) {\n      throw new ForbiddenException(\"must not use authenticated connection for one-time donation operations\");\n    }\n\n    final CompletableFuture<PaymentDetails> paymentDetailsFut = switch (request.processor) {\n      case STRIPE -> stripeManager.getPaymentDetails(request.paymentIntentId);\n      case BRAINTREE -> braintreeManager.getPaymentDetails(request.paymentIntentId);\n      case GOOGLE_PLAY_BILLING -> throw new BadRequestException(\"cannot use play billing for one-time donations\");\n      case APPLE_APP_STORE -> throw new BadRequestException(\"cannot use app store purchases for one-time donations\");\n    };\n\n    return paymentDetailsFut.thenCompose(paymentDetails -> {\n      if (paymentDetails == null) {\n        throw new WebApplicationException(Response.Status.NOT_FOUND);\n      } else if (paymentDetails.status() == PaymentStatus.PROCESSING) {\n        return CompletableFuture.completedFuture(Response.noContent().build());\n      } else if (paymentDetails.status() != PaymentStatus.SUCCEEDED) {\n        throw new WebApplicationException(Response.status(Response.Status.PAYMENT_REQUIRED)\n            .entity(new CreateBoostReceiptCredentialsErrorResponse(paymentDetails.chargeFailure())).build());\n      }\n\n      // The payment was successful, try to issue the receipt credential\n\n      long level = oneTimeDonationConfiguration.boost().level();\n      if (paymentDetails.customMetadata() != null) {\n        String levelMetadata = paymentDetails.customMetadata()\n            .getOrDefault(\"level\", Long.toString(oneTimeDonationConfiguration.boost().level()));\n        try {\n          level = Long.parseLong(levelMetadata);\n        } catch (NumberFormatException e) {\n          logger.error(\"failed to parse level metadata ({}) on payment intent {}\", levelMetadata,\n              paymentDetails.id(), e);\n          throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);\n        }\n      }\n      Duration levelExpiration;\n      if (oneTimeDonationConfiguration.boost().level() == level) {\n        levelExpiration = oneTimeDonationConfiguration.boost().expiration();\n      } else if (oneTimeDonationConfiguration.gift().level() == level) {\n        levelExpiration = oneTimeDonationConfiguration.gift().expiration();\n      } else {\n        logger.error(\"level ({}) returned from payment intent that is unknown to the server\", level);\n        throw new WebApplicationException(Response.Status.INTERNAL_SERVER_ERROR);\n      }\n      ReceiptCredentialRequest receiptCredentialRequest;\n      try {\n        receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest);\n      } catch (InvalidInputException e) {\n        throw new BadRequestException(\"invalid receipt credential request\", e);\n      }\n      final long finalLevel = level;\n      return issuedReceiptsManager.recordIssuance(paymentDetails.id(), request.processor,\n              receiptCredentialRequest, clock.instant())\n          .thenCompose(unused -> oneTimeDonationsManager.getPaidAt(paymentDetails.id(), paymentDetails.created()))\n          .thenApply(paidAt -> {\n            Instant expiration = paidAt\n                .plus(levelExpiration)\n                .truncatedTo(ChronoUnit.DAYS)\n                .plus(1, ChronoUnit.DAYS);\n            ReceiptCredentialResponse receiptCredentialResponse;\n            try {\n              receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(\n                  receiptCredentialRequest, expiration.getEpochSecond(), finalLevel);\n            } catch (VerificationFailedException e) {\n              throw new BadRequestException(\"receipt credential request failed verification\", e);\n            }\n            Metrics.counter(SubscriptionController.RECEIPT_ISSUED_COUNTER_NAME,\n                    Tags.of(\n                        Tag.of(SubscriptionController.PROCESSOR_TAG_NAME, request.processor.toString()),\n                        Tag.of(SubscriptionController.TYPE_TAG_NAME, \"boost\"),\n                        UserAgentTagUtil.getPlatformTag(userAgent)))\n                .increment();\n            return Response.ok(\n                    new CreateBoostReceiptCredentialsSuccessResponse(receiptCredentialResponse.serialize()))\n                .build();\n          });\n    });\n  }\n\n  @Nullable\n  private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {\n    try {\n      return UserAgentUtil.parseUserAgentString(userAgentString).platform();\n    } catch (final UnrecognizedUserAgentException e) {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/PaymentsController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;\n\n@Path(\"/v1/payments\")\n@Tag(name = \"Payments\")\npublic class PaymentsController {\n\n  private final ExternalServiceCredentialsGenerator paymentsServiceCredentialsGenerator;\n  private final CurrencyConversionManager currencyManager;\n\n\n  public static ExternalServiceCredentialsGenerator credentialsGenerator(final PaymentsServiceConfiguration cfg) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .prependUsername(true)\n        .build();\n  }\n\n  public PaymentsController(final CurrencyConversionManager currencyManager,\n      final ExternalServiceCredentialsGenerator paymentsServiceCredentialsGenerator) {\n    this.currencyManager = currencyManager;\n    this.paymentsServiceCredentialsGenerator = paymentsServiceCredentialsGenerator;\n  }\n\n  @GET\n  @Path(\"/auth\")\n  @Produces(MediaType.APPLICATION_JSON)\n  public ExternalServiceCredentials getAuth(final @Auth AuthenticatedDevice auth) {\n    return paymentsServiceCredentialsGenerator.generateForUuid(auth.accountIdentifier());\n  }\n\n  @GET\n  @Path(\"/conversions\")\n  @Produces(MediaType.APPLICATION_JSON)\n  public CurrencyConversionEntityList getConversions(final @Auth AuthenticatedDevice auth) {\n    return currencyManager.getCurrencyConversions().orElseThrow();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProfileController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.base.Preconditions;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.NotAuthorizedException;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.time.ZonedDateTime;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.auth.Anonymous;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.GroupSendTokenHeader;\nimport org.whispersystems.textsecuregcm.auth.OptionalAccess;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;\nimport org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.entities.BaseProfileResponse;\nimport org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest;\nimport org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse;\nimport org.whispersystems.textsecuregcm.entities.CreateProfileRequest;\nimport org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;\nimport org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;\nimport org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.s3.PolicySigner;\nimport org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.ProfileHelper;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n@Path(\"/v1/profile\")\n@Tag(name = \"Profile\")\npublic class ProfileController {\n  private final Clock clock;\n  private final RateLimiters rateLimiters;\n  private final ProfilesManager profilesManager;\n  private final AccountsManager accountsManager;\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  private final ProfileBadgeConverter profileBadgeConverter;\n  private final Map<String, BadgeConfiguration> badgeConfigurationMap;\n\n  private final PolicySigner policySigner;\n  private final PostPolicyGenerator policyGenerator;\n  private final ServerSecretParams serverSecretParams;\n  private final ServerZkProfileOperations zkProfileOperations;\n\n  private final Executor batchIdentityCheckExecutor;\n\n  private static final String EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE = \"expiringProfileKey\";\n\n  private static final String VERSION_NOT_FOUND_COUNTER_NAME = name(ProfileController.class, \"versionNotFound\");\n  private static final String DUPLICATE_AUTHENTICATION_COUNTER_NAME = name(ProfileController.class, \"duplicateAuthentication\");\n\n  public ProfileController(\n      Clock clock,\n      RateLimiters rateLimiters,\n      AccountsManager accountsManager,\n      ProfilesManager profilesManager,\n      DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      ProfileBadgeConverter profileBadgeConverter,\n      BadgesConfiguration badgesConfiguration,\n      PostPolicyGenerator policyGenerator,\n      PolicySigner policySigner,\n      ServerSecretParams serverSecretParams,\n      ServerZkProfileOperations zkProfileOperations,\n      Executor batchIdentityCheckExecutor) {\n    this.clock = clock;\n    this.rateLimiters = rateLimiters;\n    this.accountsManager = accountsManager;\n    this.profilesManager = profilesManager;\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n    this.profileBadgeConverter = profileBadgeConverter;\n    this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(\n        BadgeConfiguration::getId, Function.identity()));\n    this.serverSecretParams = serverSecretParams;\n    this.zkProfileOperations = zkProfileOperations;\n    this.policyGenerator = policyGenerator;\n    this.policySigner = policySigner;\n    this.batchIdentityCheckExecutor = Preconditions.checkNotNull(batchIdentityCheckExecutor);\n  }\n\n  @PUT\n  @Produces(MediaType.APPLICATION_JSON)\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Update profile\",\n      description = \"Updates an account’s profile. Must be authenticated.\")\n  @ApiResponse(responseCode = \"200\", description = \"The profile was updated successfully.\",\n      content = @Content(schema = @Schema(\n          implementation = ProfileAvatarUploadAttributes.class,\n          description = \"If the request changed the avatar, the response body contains an upload form.\")))\n  @ApiResponse(responseCode = \"400\", description = \"Invalid create profile request.\")\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  @ApiResponse(responseCode = \"403\", description = \"The request contained a payment address, but payments are not supported in the region of the account’s phone number.\")\n  @ApiResponse(responseCode = \"412\", description = \"The requesting account has the profiles_v2 capability\")\n  @ApiResponse(responseCode = \"422\", description = \"Invalid request format\")\n  public Response setProfile(@Auth AuthenticatedDevice auth, @NotNull @Valid CreateProfileRequest request) {\n\n    final Account account = accountsManager.getByAccountIdentifier(auth.accountIdentifier())\n        .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED));\n\n    final Optional<VersionedProfile> currentProfile =\n        profilesManager.get(auth.accountIdentifier(), request.version());\n\n    if (request.paymentAddress() != null && request.paymentAddress().length != 0) {\n      final boolean hasDisallowedPrefix =\n          dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()\n              .anyMatch(prefix -> account.getNumber().startsWith(prefix));\n\n      if (hasDisallowedPrefix && currentProfile.map(VersionedProfile::paymentAddress).isEmpty()) {\n        return Response.status(Response.Status.FORBIDDEN).build();\n      }\n    }\n\n    Optional<String> currentAvatar = Optional.empty();\n    if (currentProfile.isPresent() && currentProfile.get().avatar() != null && currentProfile.get().avatar()\n        .startsWith(\"profiles/\")) {\n      currentAvatar = Optional.of(currentProfile.get().avatar());\n    }\n\n    final String avatar = switch (request.getAvatarChange()) {\n      case UNCHANGED -> currentAvatar.orElse(null);\n      case CLEAR -> null;\n      case UPDATE -> ProfileHelper.generateAvatarObjectName();\n    };\n\n    profilesManager.set(auth.accountIdentifier(),\n        new VersionedProfile(\n            request.version(),\n            request.name(),\n            avatar,\n            request.aboutEmoji(),\n            request.about(),\n            request.paymentAddress(),\n            request.phoneNumberSharing(),\n            request.commitment().serialize()));\n\n    if (request.getAvatarChange() != CreateProfileRequest.AvatarChange.UNCHANGED) {\n      currentAvatar.ifPresent(profilesManager::deleteAvatar);\n    }\n\n    accountsManager.update(account, a -> {\n\n      final List<AccountBadge> updatedBadges = request.badges()\n          .map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges, a.getBadges()))\n          .orElseGet(a::getBadges);\n\n      a.setBadges(clock, updatedBadges);\n      a.setCurrentProfileVersion(request.version());\n    });\n\n    if (request.getAvatarChange() == CreateProfileRequest.AvatarChange.UPDATE) {\n      return Response.ok(generateAvatarUploadForm(avatar)).build();\n    } else {\n      return Response.ok().build();\n    }\n  }\n\n  @GET\n  @Path(\"/{identifier}/{version}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Get versioned profile\",\n      description = \"Retrieves a specific version of an account's profile. Requires either authentication or an unidentified access key.\")\n  @ApiResponse(responseCode = \"200\", description = \"Profile retrieved successfully.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Malformed identifier\")\n  @ApiResponse(responseCode = \"401\", description = \"Not authorized to access this profile.\")\n  @ApiResponse(responseCode = \"404\", description = \"Profile or account not found.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limit exceeded.\")\n  @ManagedAsync\n  public VersionedProfileResponse getProfile(\n      @Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,\n      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,\n      @Context ContainerRequestContext containerRequestContext,\n      @PathParam(\"identifier\") AciServiceIdentifier accountIdentifier,\n      @PathParam(\"version\") String version,\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent)\n      throws RateLimitExceededException {\n\n    final Optional<Account> maybeRequester =\n        maybeAuthenticatedDevice.map(\n            authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n                .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));\n\n    final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, \"getVersionedProfile\", userAgent);\n\n    return buildVersionedProfileResponse(targetAccount,\n        version,\n        maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false),\n        false,\n        containerRequestContext);\n  }\n\n  @GET\n  @Path(\"/{identifier}/{version}/{credentialRequest}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Get profile with credential\",\n      description = \"Retrieves a specific version of an account's profile along with an expiring profile key credential. Requires either authentication or an unidentified access key.\"\n  )\n  @ApiResponse(\n      responseCode = \"200\",\n      description = \"Account found. Profile information will be limited and credential will be null if the version was not found\",\n      useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid credential type or credential request.\")\n  @ApiResponse(responseCode = \"401\", description = \"Not authorized to access this profile.\")\n  @ApiResponse(responseCode = \"404\", description = \"Profile or account not found.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limit exceeded.\")\n  public ExpiringProfileKeyCredentialProfileResponse getProfile(\n      @Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,\n      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,\n      @Context ContainerRequestContext containerRequestContext,\n      @PathParam(\"identifier\") AciServiceIdentifier accountIdentifier,\n      @PathParam(\"version\") String version,\n      @PathParam(\"credentialRequest\") String credentialRequest,\n      @QueryParam(\"credentialType\") String credentialType,\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent)\n      throws RateLimitExceededException {\n\n    if (!EXPIRING_PROFILE_KEY_CREDENTIAL_TYPE.equals(credentialType)) {\n      throw new BadRequestException();\n    }\n\n    final Optional<Account> maybeRequester =\n        maybeAuthenticatedDevice.map(\n            authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n                .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));\n\n    final Account targetAccount = verifyPermissionToReceiveProfile(maybeRequester, accessKey, accountIdentifier, \"credentialRequest\", userAgent);\n    final boolean isSelf = maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), accountIdentifier)).orElse(false);\n\n    return buildExpiringProfileKeyCredentialProfileResponse(targetAccount,\n        version,\n        credentialRequest,\n        isSelf,\n        containerRequestContext);\n  }\n\n  // Although clients should generally be using versioned profiles wherever possible, there are still a few lingering\n  // use cases for getting profiles without a version (e.g. getting a contact's unidentified access key checksum).\n  @GET\n  @Path(\"/{identifier}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Get unversioned profile\",\n      description = \"Retrieves basic profile information without a specific version. Supports ACI and PNI identifiers. Requires authentication, an unidentified access key, or a group send token.\")\n  @ApiResponse(responseCode = \"200\", description = \"Unversioned profile retrieved successfully.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid request (e.g., multiple authorization types provided).\")\n  @ApiResponse(responseCode = \"401\", description = \"Not authorized to access this profile.\")\n  @ApiResponse(responseCode = \"404\", description = \"Account not found.\")\n  @ApiResponse(responseCode = \"429\", description = \"Rate limit exceeded.\")\n  @ManagedAsync\n  public BaseProfileResponse getUnversionedProfile(\n      @Auth Optional<AuthenticatedDevice> maybeAuthenticatedDevice,\n      @HeaderParam(HeaderUtils.UNIDENTIFIED_ACCESS_KEY) Optional<Anonymous> accessKey,\n      @HeaderParam(HeaderUtils.GROUP_SEND_TOKEN) Optional<GroupSendTokenHeader> groupSendToken,\n      @Context ContainerRequestContext containerRequestContext,\n      @HeaderParam(HttpHeaders.USER_AGENT) String userAgent,\n      @PathParam(\"identifier\") ServiceIdentifier identifier)\n      throws RateLimitExceededException {\n\n    final Optional<Account> maybeRequester =\n        maybeAuthenticatedDevice.map(\n            authenticatedDevice -> accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n                .orElseThrow(() -> new WebApplicationException(Response.Status.UNAUTHORIZED)));\n\n    final Account targetAccount;\n    if (groupSendToken.isPresent()) {\n      if (accessKey.isPresent()) {\n        throw new BadRequestException(\"may not provide both group send token and unidentified access key\");\n      }\n      try {\n        final GroupSendFullToken token = groupSendToken.get().token();\n        token.verify(List.of(identifier.toLibsignal()), clock.instant(), GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams));\n        targetAccount = accountsManager.getByServiceIdentifier(identifier).orElseThrow(NotFoundException::new);\n      } catch (VerificationFailedException e) {\n        throw new NotAuthorizedException(e);\n      }\n    } else {\n      targetAccount = verifyPermissionToReceiveProfile(\n          maybeRequester, accessKey.filter(ignored -> identifier.identityType() == IdentityType.ACI), identifier, \"getUnversionedProfile\", userAgent);\n    }\n    return switch (identifier.identityType()) {\n      case ACI -> buildBaseProfileResponseForAccountIdentity(targetAccount,\n          maybeRequester.map(requester -> ProfileHelper.isSelfProfileRequest(requester.getUuid(), identifier)).orElse(false),\n          containerRequestContext);\n      case PNI -> buildBaseProfileResponseForPhoneNumberIdentity(targetAccount);\n    };\n  }\n\n  @POST\n  @Path(\"/identity_check/batch\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Batch identity key check\",\n      description = \"Checks identity key fingerprints for multiple accounts. Returns accounts where the fingerprint does not match. Should not be authenticated.\")\n  @ApiResponse(\n      responseCode = \"200\",\n      description = \"Batch check completed successfully. Response may contain accounts with mismatched fingerprints.\",\n      content = @Content(schema = @Schema(implementation = BatchIdentityCheckResponse.class)))\n  @ApiResponse(responseCode = \"400\", description = \"Invalid request format or validation failed.\")\n  public CompletableFuture<BatchIdentityCheckResponse> runBatchIdentityCheck(@NotNull @Valid BatchIdentityCheckRequest request) {\n    return CompletableFuture.supplyAsync(() -> {\n          List<BatchIdentityCheckResponse.Element> responseElements = Collections.synchronizedList(new ArrayList<>());\n\n          final int targetBatchCount = 10;\n          // clamp the amount per batch to be in the closed range [30, 100]\n          final int batchSize = Math.min(Math.max(request.elements().size() / targetBatchCount, 30), 100);\n          // add 1 extra batch if there is any remainder to consume the final non-full batch\n          final int batchCount =\n              request.elements().size() / batchSize + (request.elements().size() % batchSize != 0 ? 1 : 0);\n\n          @SuppressWarnings(\"rawtypes\") CompletableFuture[] futures = new CompletableFuture[batchCount];\n          for (int i = 0; i < batchCount; ++i) {\n            List<BatchIdentityCheckRequest.Element> batch = request.elements()\n                .subList(i * batchSize, Math.min((i + 1) * batchSize, request.elements().size()));\n            futures[i] = CompletableFuture.runAsync(() -> {\n              MessageDigest sha256;\n              try {\n                sha256 = MessageDigest.getInstance(\"SHA-256\");\n              } catch (NoSuchAlgorithmException e) {\n                throw new AssertionError(e);\n              }\n              for (final BatchIdentityCheckRequest.Element element : batch) {\n                checkFingerprintAndAdd(element, responseElements, sha256);\n              }\n            }, batchIdentityCheckExecutor);\n          }\n\n      return new Pair<>(futures, responseElements);\n    }).thenCompose(futuresAndResponseElements -> CompletableFuture.allOf(futuresAndResponseElements.first())\n        .thenApply((ignored) -> new BatchIdentityCheckResponse(futuresAndResponseElements.second())));\n  }\n\n  private void checkFingerprintAndAdd(BatchIdentityCheckRequest.Element element,\n      Collection<BatchIdentityCheckResponse.Element> responseElements, MessageDigest md) {\n\n    final ServiceIdentifier identifier = element.uuid();\n    final Optional<Account> maybeAccount = accountsManager.getByServiceIdentifier(identifier);\n\n    maybeAccount.ifPresent(account -> {\n      final IdentityKey identityKey = account.getIdentityKey(identifier.identityType());\n      if (identityKey == null) {\n        return;\n      }\n\n      md.reset();\n      byte[] digest = md.digest(identityKey.serialize());\n      byte[] fingerprint = Util.truncate(digest, 4);\n\n      if (!Arrays.equals(fingerprint, element.fingerprint())) {\n        responseElements.add(new BatchIdentityCheckResponse.Element(element.uuid(), identityKey));\n      }\n    });\n  }\n\n  private ExpiringProfileKeyCredentialProfileResponse buildExpiringProfileKeyCredentialProfileResponse(\n      final Account account,\n      final String version,\n      final String encodedCredentialRequest,\n      final boolean isSelf,\n      final ContainerRequestContext containerRequestContext) {\n\n    final ExpiringProfileKeyCredentialResponse expiringProfileKeyCredentialResponse = profilesManager.get(account.getUuid(), version)\n        .map(profile -> {\n          final ExpiringProfileKeyCredentialResponse profileKeyCredentialResponse;\n          try {\n            profileKeyCredentialResponse = ProfileHelper.getExpiringProfileKeyCredential(HexFormat.of().parseHex(encodedCredentialRequest),\n                profile, new ServiceId.Aci(account.getUuid()), zkProfileOperations);\n          } catch (VerificationFailedException | InvalidInputException e) {\n            throw new BadRequestException(e);\n          }\n          return profileKeyCredentialResponse;\n        })\n        .orElse(null);\n\n    return new ExpiringProfileKeyCredentialProfileResponse(\n        buildVersionedProfileResponse(account, version, isSelf, true, containerRequestContext),\n        expiringProfileKeyCredentialResponse);\n  }\n\n  private VersionedProfileResponse buildVersionedProfileResponse(final Account account,\n      final String version,\n      final boolean isSelf,\n      final boolean hasCredentialRequest,\n      final ContainerRequestContext containerRequestContext) {\n\n    final Optional<VersionedProfile> maybeProfile = profilesManager.get(account.getUuid(), version);\n\n    if (maybeProfile.isEmpty()) {\n      // this can happen if an account re-registers, which includes some device-transfer scenarios\n      Metrics.counter(\n          VERSION_NOT_FOUND_COUNTER_NAME,\n          Tags.of(\n              \"self\", String.valueOf(isSelf),\n              \"credential_request\", String.valueOf(hasCredentialRequest),\n              \"platform\", UserAgentTagUtil.getPlatformTag(containerRequestContext.getHeaderString(\"User-Agent\")).getValue()))\n          .increment();\n    }\n\n    final byte[] name = maybeProfile.map(VersionedProfile::name).orElse(null);\n    final byte[] about = maybeProfile.map(VersionedProfile::about).orElse(null);\n    final byte[] aboutEmoji = maybeProfile.map(VersionedProfile::aboutEmoji).orElse(null);\n    final String avatar = maybeProfile.map(VersionedProfile::avatar).orElse(null);\n    final byte[] phoneNumberSharing = maybeProfile.map(VersionedProfile::phoneNumberSharing).orElse(null);\n\n    // Allow requests where either the version matches the latest version on Account or the latest version on Account\n    // is empty to read the payment address.\n    final byte[] paymentAddress = maybeProfile\n        .filter(p -> account.getCurrentProfileVersion().map(v -> v.equals(version)).orElse(true))\n        .map(VersionedProfile::paymentAddress)\n        .orElse(null);\n\n    return new VersionedProfileResponse(\n        buildBaseProfileResponseForAccountIdentity(account, isSelf, containerRequestContext),\n        name, about, aboutEmoji, avatar, paymentAddress, phoneNumberSharing);\n  }\n\n  private BaseProfileResponse buildBaseProfileResponseForAccountIdentity(final Account account,\n      final boolean isSelf,\n      final ContainerRequestContext containerRequestContext) {\n\n    return new BaseProfileResponse(account.getIdentityKey(IdentityType.ACI),\n        account.getUnidentifiedAccessKey().map(UnidentifiedAccessChecksum::generateFor).orElse(null),\n        account.isUnrestrictedUnidentifiedAccess(),\n        getAccountCapabilities(account),\n        profileBadgeConverter.convert(\n            HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext),\n            account.getBadges(),\n            isSelf),\n        new AciServiceIdentifier(account.getUuid()));\n  }\n\n  private BaseProfileResponse buildBaseProfileResponseForPhoneNumberIdentity(final Account account) {\n    return new BaseProfileResponse(account.getIdentityKey(IdentityType.PNI),\n        null,\n        false,\n        getAccountCapabilities(account),\n        Collections.emptyList(),\n        new PniServiceIdentifier(account.getPhoneNumberIdentifier()));\n  }\n\n  /**\n   * Verifies that the requester has permission to view the profile of the account identified by the given ACI.\n   *\n   * @param maybeRequester the authenticated account requesting the profile, if any\n   * @param maybeAccessKey an anonymous access key for the target account\n   * @param accountIdentifier the ACI of the target account\n   *\n   * @return the target account\n   *\n   * @throws RateLimitExceededException if the requester must wait before requesting the target account's profile\n   * @throws NotFoundException if no account was found for the target ACI\n   * @throws NotAuthorizedException if the requester is not authorized to receive the target account's profile or if the\n   * requester was not authenticated and did not present an anonymous access key\n   */\n  private Account verifyPermissionToReceiveProfile(final Optional<Account> maybeRequester,\n      final Optional<Anonymous> maybeAccessKey,\n      final ServiceIdentifier accountIdentifier,\n      final String endpoint,\n      @Nullable final String userAgent) throws RateLimitExceededException {\n\n    if (maybeRequester.isPresent() && maybeAccessKey.isPresent()) {\n      Metrics.counter(DUPLICATE_AUTHENTICATION_COUNTER_NAME,\n          Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), io.micrometer.core.instrument.Tag.of(\"endpoint\", endpoint)))\n          .increment();\n    }\n\n    if (maybeRequester.isPresent()) {\n      rateLimiters.getProfileLimiter().validate(maybeRequester.get().getUuid());\n    }\n\n    final Optional<Account> maybeTargetAccount = accountsManager.getByServiceIdentifier(accountIdentifier);\n\n    OptionalAccess.verify(maybeRequester, maybeAccessKey, maybeTargetAccount, accountIdentifier);\n    assert maybeTargetAccount.isPresent();\n\n    return maybeTargetAccount.get();\n  }\n\n  private ProfileAvatarUploadAttributes generateAvatarUploadForm(\n      final String objectName) {\n    ZonedDateTime now = ZonedDateTime.now(clock);\n    Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);\n    String signature = policySigner.getSignature(now, policy.second());\n\n    return new ProfileAvatarUploadAttributes(objectName, policy.first(),\n        \"private\", \"AWS4-HMAC-SHA256\",\n        now.format(PostPolicyGenerator.AWS_DATE_TIME), policy.second(), signature);\n  }\n\n  private static Map<String, Boolean> getAccountCapabilities(final Account account) {\n    return Arrays.stream(DeviceCapability.values())\n        .filter(DeviceCapability::includeInProfile)\n        .collect(Collectors.toMap(DeviceCapability::getName, account::hasCapability));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/ProvisioningController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.dropwizard.util.DataSize;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.util.Base64;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.ProvisioningMessage;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.ProvisioningManager;\n\n/**\n * The provisioning controller facilitates transmission of provisioning messages from the primary device associated with\n * an existing Signal account to a new device. To send a provisioning message, a primary device generally scans a QR\n * code displayed by the new device that contains the device's \"provisioning address\" and a public key. The primary\n * device then encrypts a use-case-specific provisioning message and posts it to\n * {@link #sendProvisioningMessage(AuthenticatedDevice, String, ProvisioningMessage, String)}, at which point the server\n * delivers the message to the new device via an open provisioning WebSocket.\n *\n * @see org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener\n */\n@Path(\"/v1/provisioning\")\n@Tag(name = \"Provisioning\")\npublic class ProvisioningController {\n\n  private final RateLimiters rateLimiters;\n  private final ProvisioningManager provisioningManager;\n\n  @VisibleForTesting\n  private static final long MAX_MESSAGE_SIZE = DataSize.kibibytes(256).toBytes();\n\n  private static final String REJECT_OVERSIZE_MESSAGE_COUNTER =\n      name(ProvisioningController.class, \"rejectOversizeMessage\");\n\n  public ProvisioningController(RateLimiters rateLimiters, ProvisioningManager provisioningManager) {\n    this.rateLimiters = rateLimiters;\n    this.provisioningManager = provisioningManager;\n  }\n\n  @Path(\"/{destination}\")\n  @PUT\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Send a provisioning message to a new device\",\n      description = \"\"\"\n          Send a provisioning message from an authenticated device to a device that (presumably) is not yet associated\n          with a Signal account.\n          \"\"\")\n  @ApiResponse(responseCode=\"204\", description=\"The provisioning message was delivered to the given provisioning address\")\n  @ApiResponse(responseCode=\"400\", description=\"The provisioning message was too large\")\n  @ApiResponse(responseCode=\"404\", description=\"No device with the given provisioning address was connected at the time of the request\")\n  public void sendProvisioningMessage(@Auth final AuthenticatedDevice auth,\n\n      @Parameter(description = \"The temporary provisioning address to which to send a provisioning message\")\n      @PathParam(\"destination\") final String provisioningAddress,\n\n      @Parameter(description = \"The provisioning message to send to the given provisioning address\")\n      @NotNull @Valid final ProvisioningMessage message,\n\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent)\n      throws RateLimitExceededException {\n\n    if (message.body().length() > MAX_MESSAGE_SIZE) {\n      Metrics.counter(REJECT_OVERSIZE_MESSAGE_COUNTER, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent))).increment();\n      throw new WebApplicationException(Response.Status.BAD_REQUEST);\n    }\n\n    rateLimiters.getMessagesLimiter().validate(auth.accountIdentifier());\n\n    final boolean subscriberPresent =\n        provisioningManager.sendProvisioningMessage(provisioningAddress, Base64.getMimeDecoder().decode(message.body()));\n\n    if (!subscriberPresent) {\n      throw new WebApplicationException(Response.Status.NOT_FOUND);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/RateLimitExceededException.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport java.time.Duration;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport io.grpc.StatusRuntimeException;\nimport org.whispersystems.textsecuregcm.grpc.ConvertibleToGrpcStatus;\nimport org.whispersystems.textsecuregcm.grpc.GrpcExceptions;\n\npublic class RateLimitExceededException extends Exception implements ConvertibleToGrpcStatus {\n\n  @Nullable\n  private final Duration retryDuration;\n\n  /**\n   * Constructs a new exception indicating when it may become safe to retry\n   *\n   * @param retryDuration A duration to wait before retrying, null if no duration can be indicated\n   */\n  public RateLimitExceededException(@Nullable final Duration retryDuration) {\n    super(null, null, true, false);\n    this.retryDuration = retryDuration;\n  }\n\n  public Optional<Duration> getRetryDuration() {\n    return Optional.ofNullable(retryDuration);\n  }\n\n  @Override\n  public StatusRuntimeException toStatusRuntimeException() {\n    return GrpcExceptions.rateLimitExceeded(retryDuration);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/RegistrationController.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.net.HttpHeaders;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.auth.BasicAuthorizationHeader;\nimport org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;\nimport org.whispersystems.textsecuregcm.entities.AccountCreationResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationLockFailure;\nimport org.whispersystems.textsecuregcm.entities.RegistrationRequest;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.DeviceSpec;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n@Path(\"/v1/registration\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Registration\")\npublic class RegistrationController {\n\n  private static final DistributionSummary REREGISTRATION_IDLE_DAYS_DISTRIBUTION = DistributionSummary\n      .builder(name(RegistrationController.class, \"reregistrationIdleDays\"))\n      .distributionStatisticExpiry(Duration.ofHours(2))\n      .register(Metrics.globalRegistry);\n\n  private static final String ACCOUNT_CREATED_COUNTER_NAME = name(RegistrationController.class, \"accountCreated\");\n  private static final String COUNTRY_CODE_TAG_NAME = \"countryCode\";\n  private static final String REGION_CODE_TAG_NAME = \"regionCode\";\n  private static final String VERIFICATION_TYPE_TAG_NAME = \"verification\";\n\n  private final AccountsManager accounts;\n  private final PhoneVerificationTokenManager phoneVerificationTokenManager;\n  private final RegistrationLockVerificationManager registrationLockVerificationManager;\n  private final RateLimiters rateLimiters;\n\n  public RegistrationController(final AccountsManager accounts,\n                                final PhoneVerificationTokenManager phoneVerificationTokenManager,\n                                final RegistrationLockVerificationManager registrationLockVerificationManager,\n                                final RateLimiters rateLimiters) {\n\n    this.accounts = accounts;\n    this.phoneVerificationTokenManager = phoneVerificationTokenManager;\n    this.registrationLockVerificationManager = registrationLockVerificationManager;\n    this.rateLimiters = rateLimiters;\n  }\n\n  @POST\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Registers an account\",\n  description = \"\"\"\n      Registers a new account or attempts to “re-register” an existing account. It is expected that a well-behaved client\n      could make up to three consecutive calls to this API:\n      1. gets 423 from existing registration lock \\n\n      2. gets 409 from device available for transfer \\n\n      3. success \\n\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Account creation succeeded\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"The session identified in the request is not verified\")\n  @ApiResponse(responseCode = \"403\", description = \"Verification failed for the provided Registration Recovery Password\")\n  @ApiResponse(responseCode = \"409\", description = \"The caller has not explicitly elected to skip transferring data from another device, but a device transfer is technically possible\")\n  @ApiResponse(responseCode = \"422\", description = \"The request did not pass validation\")\n  @ApiResponse(responseCode = \"423\", content = @Content(schema = @Schema(implementation = RegistrationLockFailure.class)))\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ApiResponse(responseCode = \"499\", description = \"Client must support post-quantum ratchet\")\n  public AccountCreationResponse register(\n      @HeaderParam(HttpHeaders.AUTHORIZATION) @NotNull final BasicAuthorizationHeader authorizationHeader,\n      @HeaderParam(HeaderUtils.X_SIGNAL_AGENT) final String signalAgent,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @NotNull @Valid final RegistrationRequest registrationRequest,\n      @Context final ContainerRequestContext requestContext) throws RateLimitExceededException, InterruptedException {\n\n    final String number = authorizationHeader.getUsername();\n    final String password = authorizationHeader.getPassword();\n\n    if (!registrationRequest.isEverySignedKeyValid(userAgent)) {\n      throw new WebApplicationException(\"Invalid signature\", 422);\n    }\n\n    if (!(registrationRequest.accountAttributes().getCapabilities() != null\n        ? registrationRequest.accountAttributes().getCapabilities()\n        : Collections.<DeviceCapability>emptySet()).containsAll(DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION)) {\n\n      throw new WebApplicationException(\"Missing required device capability\", 499);\n    }\n\n    rateLimiters.getRegistrationLimiter().validate(number);\n\n    final PhoneVerificationRequest.VerificationType verificationType = phoneVerificationTokenManager.verify(\n        requestContext, number, registrationRequest);\n\n    final Optional<Account> existingAccount = accounts.getByE164(number);\n\n    existingAccount.ifPresent(account -> {\n      final Instant accountLastSeen = Instant.ofEpochMilli(account.getLastSeen());\n      final Duration timeSinceLastSeen = Duration.between(accountLastSeen, Instant.now());\n      REREGISTRATION_IDLE_DAYS_DISTRIBUTION.record(timeSinceLastSeen.toDays());\n    });\n\n    if (!registrationRequest.skipDeviceTransfer() && existingAccount.map(account -> account.hasCapability(DeviceCapability.TRANSFER)).orElse(false)) {\n      // If a device transfer is possible, clients must explicitly opt out of a transfer (i.e. after prompting the user)\n      // before we'll let them create a new account \"from scratch\"\n      throw new WebApplicationException(Response.status(409, \"device transfer available\").build());\n    }\n\n    if (existingAccount.isPresent()) {\n      registrationLockVerificationManager.verifyRegistrationLock(existingAccount.get(),\n          registrationRequest.accountAttributes().getRegistrationLock(),\n          userAgent, RegistrationLockVerificationManager.Flow.REGISTRATION, verificationType);\n    }\n\n    final Account account = accounts.create(number,\n        registrationRequest.accountAttributes(),\n        existingAccount.map(Account::getBadges).orElseGet(ArrayList::new),\n        registrationRequest.aciIdentityKey(),\n        registrationRequest.pniIdentityKey(),\n        new DeviceSpec(\n            registrationRequest.accountAttributes().getName(),\n            password,\n            signalAgent,\n            registrationRequest.accountAttributes().getCapabilities(),\n            registrationRequest.accountAttributes().getRegistrationId(),\n            registrationRequest.accountAttributes().getPhoneNumberIdentityRegistrationId(),\n            registrationRequest.accountAttributes().getFetchesMessages(),\n            registrationRequest.deviceActivationRequest().apnToken(),\n            registrationRequest.deviceActivationRequest().gcmToken(),\n            registrationRequest.deviceActivationRequest().aciSignedPreKey(),\n            registrationRequest.deviceActivationRequest().pniSignedPreKey(),\n            registrationRequest.deviceActivationRequest().aciPqLastResortPreKey(),\n            registrationRequest.deviceActivationRequest().pniPqLastResortPreKey()),\n        userAgent);\n\n    Metrics.counter(ACCOUNT_CREATED_COUNTER_NAME, Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),\n            Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(number)),\n            Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(number)),\n            Tag.of(VERIFICATION_TYPE_TAG_NAME, verificationType.name())))\n        .increment();\n\n    final AccountIdentityResponse identityResponse = new AccountIdentityResponseBuilder(account)\n        // If there was an existing account, return whether it could have had something in the storage service\n        .storageCapable(existingAccount\n            .map(a -> a.hasCapability(DeviceCapability.STORAGE))\n            .orElse(false))\n        .build();\n\n    return new AccountCreationResponse(identityResponse, existingAccount.isPresent());\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigController.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.EntityTag;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.util.Arrays;\nimport java.util.HashSet;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.RemoteConfigurationResponse;\nimport org.whispersystems.textsecuregcm.storage.RemoteConfig;\nimport org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;\nimport org.whispersystems.textsecuregcm.util.Conversions;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n@Path(\"/v2/config\")\n@Tag(name = \"Remote Config\")\npublic class RemoteConfigController {\n\n  private final RemoteConfigsManager remoteConfigsManager;\n  private final Map<String, String> globalConfig;\n\n  private static final String GLOBAL_CONFIG_PREFIX = \"global.\";\n  private static final Set<String> PLATFORM_PREFIXES = Arrays.stream(ClientPlatform.values())\n    .map(p -> p.name().toLowerCase())\n    .collect(Collectors.toSet());\n\n  public RemoteConfigController(RemoteConfigsManager remoteConfigsManager,\n      Map<String, String> globalConfig,\n      final Clock clock) {\n    this.remoteConfigsManager = remoteConfigsManager;\n    this.globalConfig = globalConfig;\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Fetch remote configuration\",\n      description = \"Remote configuration is a list of namespaced keys that clients may use for consistent configuration or behavior. Configuration values change over time, and the list should be refreshed periodically, typically at client launch and every few hours thereafter. Some values depend on the authenticated user, so the list should be refreshed immediately if the user changes.\"\n  )\n  @ApiResponse(\n      responseCode = \"200\",\n      description = \"Remote configuration values for the authenticated user\",\n      content = @Content(schema = @Schema(implementation = RemoteConfigurationResponse.class)),\n      headers = @Header(name = \"ETag\", description = \"A hash of the configuration content which can be supplied in an If-None-Match header on future requests\"))\n  @ApiResponse(responseCode = \"304\", description = \"There is no change since the last fetch\", content = {})\n  @ApiResponse(responseCode = \"401\", description = \"This request requires authentication\", content = {})\n\n  public Response getAll(\n      @Auth AuthenticatedDevice auth,\n\n      @Parameter(description = \"The ETag header supplied with a previous response from this endpoint. Optional.\")\n      @HeaderParam(HttpHeaders.IF_NONE_MATCH)\n      @Nullable EntityTag eTag,\n\n      @Parameter(description = \"The user agent in standard form.\")\n      @HeaderParam(HttpHeaders.USER_AGENT)\n      String userAgent\n  ) {\n    final String platformPrefix = platformPrefix(userAgent);\n    final List<RemoteConfig> remoteConfigs = remoteConfigsManager.getAll();\n\n    try {\n      MessageDigest digest = MessageDigest.getInstance(\"SHA-256\");\n\n      final Map<String, String> configs = Stream.concat(\n          remoteConfigs.stream()\n              .filter(config -> {\n                  final String firstNameComponent = config.getName().split(\"\\\\.\", 2)[0];\n                  return firstNameComponent.equals(platformPrefix) || !PLATFORM_PREFIXES.contains(firstNameComponent);\n              })\n              .map(\n                  config -> {\n                          final byte[] hashKey = config.getHashKey() != null\n                              ? config.getHashKey().getBytes(StandardCharsets.UTF_8)\n                              : config.getName().getBytes(StandardCharsets.UTF_8);\n                          boolean inBucket = isInBucket(digest, auth.accountIdentifier(), hashKey, config.getPercentage(), config.getUuids());\n                          final String value = inBucket ? config.getValue() : config.getDefaultValue();\n                          return Pair.of(config.getName(), value == null ? String.valueOf(inBucket) : value);\n                      }),\n                  globalConfig.entrySet().stream()\n                    .map(e -> Pair.of(GLOBAL_CONFIG_PREFIX + e.getKey(), e.getValue())))\n        .collect(Collectors.toMap(Pair::getLeft, Pair::getRight));\n\n      final EntityTag newETag = new EntityTag(HexFormat.of().toHexDigits(configs.hashCode()));\n      if (newETag.equals(eTag)) {\n        return Response.notModified(eTag).build();\n      }\n\n      return Response.ok(new RemoteConfigurationResponse(configs))\n          .tag(newETag)\n          .build();\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  private static String platformPrefix(final String userAgent) {\n    try {\n      return UserAgentUtil.parseUserAgentString(userAgent).platform().name().toLowerCase();\n    } catch (UnrecognizedUserAgentException e) {\n      return null;\n    }\n  }\n\n  @VisibleForTesting\n  public static boolean isInBucket(MessageDigest digest, UUID uid, byte[] hashKey, int configPercentage,\n      Set<UUID> uuidsInBucket) {\n    if (uuidsInBucket.contains(uid)) {\n      return true;\n    }\n\n    ByteBuffer bb = ByteBuffer.allocate(16);\n    bb.putLong(uid.getMostSignificantBits());\n    bb.putLong(uid.getLeastSignificantBits());\n\n    digest.update(bb.array());\n\n    byte[] hash = digest.digest(hashKey);\n    int bucket = (int) (Util.ensureNonNegativeLong(Conversions.byteArrayToLong(hash)) % 100);\n\n    return bucket < configPercentage;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureStorageController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;\n\n@Path(\"/v1/storage\")\n@Tag(name = \"Secure Storage\")\npublic class SecureStorageController {\n\n  private final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator;\n\n  public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureStorageServiceConfiguration cfg) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .prependUsername(true)\n        .build();\n  }\n\n  public SecureStorageController(ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator) {\n    this.storageServiceCredentialsGenerator = storageServiceCredentialsGenerator;\n  }\n\n  @GET\n  @Path(\"/auth\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Generate credentials for Storage Service\",\n      description = \"\"\"\n          Generate Storage Service credentials. Generated credentials have an expiration time of 24 hours\\s\n          (however, the TTL is fully controlled by the server and may change even for already generated credentials).\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with generated credentials.\", useReturnTypeSchema = true)\n  public ExternalServiceCredentials getAuth(@Auth AuthenticatedDevice auth) {\n    return storageServiceCredentialsGenerator.generateForUuid(auth.accountIdentifier());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2Controller.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport org.whispersystems.textsecuregcm.entities.AuthCheckRequest;\nimport org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;\nimport org.whispersystems.textsecuregcm.limits.RateLimitedByIp;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.workers.RemoveExpiredAccountsCommand;\n\n@Path(\"/v2/{name: backup|svr}\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Secure Value Recovery\")\n@Schema(description = \"Note: /v2/backup is deprecated. Use /v2/svr instead.\")\npublic class SecureValueRecovery2Controller {\n  private static final String CREDENTIAL_AGE_DISTRIBUTION_NAME =\n      MetricsUtil.name(SecureValueRecovery2Controller.class, \"credentialAge\");\n\n\n  public static final Duration MAX_AGE = RemoveExpiredAccountsCommand.MAX_IDLE_DURATION;\n\n  public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg) {\n    return credentialsGenerator(cfg, Clock.systemUTC());\n  }\n\n  @VisibleForTesting\n  public static ExternalServiceCredentialsGenerator credentialsGenerator(final SecureValueRecoveryConfiguration cfg, final Clock clock) {\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .withUserDerivationKey(cfg.userIdTokenSharedSecret().value())\n        .prependUsername(false)\n        .withDerivedUsernameTruncateLength(16)\n        .withClock(clock)\n        .build();\n  }\n\n  private final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator;\n  private final AccountsManager accountsManager;\n\n  public SecureValueRecovery2Controller(final ExternalServiceCredentialsGenerator backupServiceCredentialGenerator,\n      final AccountsManager accountsManager) {\n    this.backupServiceCredentialGenerator = backupServiceCredentialGenerator;\n    this.accountsManager = accountsManager;\n  }\n\n  @GET\n  @Path(\"/auth\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Generate credentials for SVR2\",\n      description = \"\"\"\n          Generate SVR2 service credentials. Generated credentials have an expiration time of 30 days \n          (however, the TTL is fully controlled by the server side and may change even for already generated credentials). \n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with generated credentials.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"401\", description = \"Account authentication check failed.\")\n  public ExternalServiceCredentials getAuth(@Auth final AuthenticatedDevice auth) {\n    return backupServiceCredentialGenerator.generateFor(auth.accountIdentifier().toString());\n  }\n\n\n  @POST\n  @Path(\"/auth/check\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK)\n  @Operation(\n      summary = \"Check SVR2 credentials\",\n      description = \"\"\"\n          Over time, clients may wind up with multiple sets of SVR2 authentication credentials in cloud storage. \n          To determine which set is most current and should be used to communicate with SVR2 to retrieve a master key\n          (from which a registration recovery password can be derived), clients should call this endpoint \n          with a list of stored credentials. The response will identify which (if any) set of credentials are appropriate for communicating with SVR2.\n          \"\"\"\n  )\n  @ApiResponse(responseCode = \"200\", description = \"`JSON` with the check results.\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"422\", description = \"Provided list of SVR2 credentials could not be parsed\")\n  @ApiResponse(responseCode = \"400\", description = \"`POST` request body is not a valid `JSON`\")\n  public AuthCheckResponseV2 authCheck(\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @NotNull @Valid final AuthCheckRequest request) {\n    final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(\n        request.tokens(),\n        backupServiceCredentialGenerator,\n        MAX_AGE.getSeconds());\n\n    // the username associated with the provided number\n    final Optional<String> matchingUsername = accountsManager\n        .getByE164(request.number())\n        .map(Account::getUuid)\n        .map(backupServiceCredentialGenerator::generateForUuid)\n        .map(ExternalServiceCredentials::username);\n\n    // Instrument how expired or not the best credential is\n    credentials.stream()\n        .filter(info -> switch (info.status()) {\n          case VALID, EXPIRED -> true;\n          default -> false;\n        })\n        // Look only at credentials that match the current account for the e164\n        .filter(info -> matchingUsername.filter(info.credentials().username()::equals).isPresent())\n        // Instrument the matching credential with the most recent timestamp\n        .max(Comparator.comparing(ExternalServiceCredentialsSelector.CredentialInfo::timestamp))\n        .ifPresent(info -> DistributionSummary.builder(CREDENTIAL_AGE_DISTRIBUTION_NAME)\n            .baseUnit(\"days\")\n            .tags(Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), Tag.of(\"valid\", Boolean.toString(info.valid()))))\n            .register(Metrics.globalRegistry)\n            .record(Duration.between(Instant.ofEpochSecond(info.timestamp()), Instant.now()).toDays()));\n\n    return new AuthCheckResponseV2(credentials.stream().collect(Collectors.toMap(\n        ExternalServiceCredentialsSelector.CredentialInfo::token,\n        info -> {\n          if (!info.valid()) {\n            return AuthCheckResponseV2.Result.INVALID;\n          }\n          final String username = info.credentials().username();\n          // does this credential match the account id for the e164 provided in the request?\n          boolean match = matchingUsername.filter(username::equals).isPresent();\n          return match ? AuthCheckResponseV2.Result.MATCH : AuthCheckResponseV2.Result.NO_MATCH;\n        }\n    )));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/ServerRejectedException.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\npublic class ServerRejectedException extends Exception {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/StickerController.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport io.dropwizard.auth.Auth;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport java.security.SecureRandom;\nimport java.time.ZoneOffset;\nimport java.time.ZonedDateTime;\nimport java.util.HexFormat;\nimport java.util.LinkedList;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes;\nimport org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes.StickerPackFormUploadItem;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.s3.PolicySigner;\nimport org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;\nimport org.whispersystems.textsecuregcm.util.Constants;\nimport org.whispersystems.textsecuregcm.util.Pair;\n\n@Path(\"/v1/sticker\")\n@Tag(name = \"Stickers\")\npublic class StickerController {\n\n  private final RateLimiters        rateLimiters;\n  private final PolicySigner        policySigner;\n  private final PostPolicyGenerator policyGenerator;\n\n  public StickerController(RateLimiters rateLimiters, String accessKey, String accessSecret, String region, String bucket) {\n    this.rateLimiters    = rateLimiters;\n    this.policySigner    = new PolicySigner(accessSecret, region);\n    this.policyGenerator = new PostPolicyGenerator(region, bucket, accessKey);\n  }\n\n  @GET\n  @Produces(MediaType.APPLICATION_JSON)\n  @Path(\"/pack/form/{count}\")\n  public StickerPackFormUploadAttributes getStickersForm(@Auth AuthenticatedDevice auth,\n      @PathParam(\"count\") @Min(1) @Max(201) int stickerCount)\n      throws RateLimitExceededException {\n    rateLimiters.getStickerPackLimiter().validate(auth.accountIdentifier());\n\n    ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC);\n    String packId = generatePackId();\n    String packLocation = \"stickers/\" + packId;\n    String manifestKey = packLocation + \"/manifest.proto\";\n    Pair<String, String> manifestPolicy = policyGenerator.createFor(now, manifestKey,\n        Constants.MAXIMUM_STICKER_MANIFEST_SIZE_BYTES);\n    String manifestSignature = policySigner.getSignature(now, manifestPolicy.second());\n    StickerPackFormUploadItem manifest = new StickerPackFormUploadItem(-1, manifestKey, manifestPolicy.first(),\n        \"private\", \"AWS4-HMAC-SHA256\",\n        now.format(PostPolicyGenerator.AWS_DATE_TIME), manifestPolicy.second(), manifestSignature);\n\n    List<StickerPackFormUploadItem> stickers = new LinkedList<>();\n\n    for (int i = 0; i < stickerCount; i++) {\n      String stickerKey = packLocation + \"/full/\" + i;\n      Pair<String, String> stickerPolicy = policyGenerator.createFor(now, stickerKey,\n          Constants.MAXIMUM_STICKER_SIZE_BYTES);\n      String stickerSignature = policySigner.getSignature(now, stickerPolicy.second());\n      stickers.add(new StickerPackFormUploadItem(i, stickerKey, stickerPolicy.first(), \"private\", \"AWS4-HMAC-SHA256\",\n          now.format(PostPolicyGenerator.AWS_DATE_TIME), stickerPolicy.second(), stickerSignature));\n    }\n\n    return new StickerPackFormUploadAttributes(packId, manifest, stickers);\n  }\n\n  private String generatePackId() {\n    byte[] object = new byte[16];\n    new SecureRandom().nextBytes(object);\n\n    return HexFormat.of().formatHex(object);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/SubscriptionController.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonInclude.Include;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.Auth;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.ExternalDocumentation;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.ClientErrorException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.DELETE;\nimport jakarta.ws.rs.DefaultValue;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.QueryParam;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.math.BigDecimal;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.Arrays;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Map.Entry;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.badges.BadgeTranslator;\nimport org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.OneTimeDonationCurrencyConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SubscriptionLevelConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.PurchasableBadge;\nimport org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\nimport org.whispersystems.textsecuregcm.storage.SubscriberCredentials;\nimport org.whispersystems.textsecuregcm.storage.SubscriptionManager;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;\nimport org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;\nimport org.whispersystems.textsecuregcm.subscriptions.BankTransferType;\nimport org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;\nimport org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;\nimport org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;\nimport org.whispersystems.textsecuregcm.subscriptions.StripeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n@Path(\"/v1/subscription\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Subscriptions\")\npublic class SubscriptionController {\n\n  private final Clock clock;\n  private final SubscriptionConfiguration subscriptionConfiguration;\n  private final OneTimeDonationConfiguration oneTimeDonationConfiguration;\n  private final SubscriptionManager subscriptionManager;\n  private final StripeManager stripeManager;\n  private final BraintreeManager braintreeManager;\n  private final GooglePlayBillingManager googlePlayBillingManager;\n  private final AppleAppStoreManager appleAppStoreManager;\n  private final BadgeTranslator badgeTranslator;\n  private final BankMandateTranslator bankMandateTranslator;\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  static final String RECEIPT_ISSUED_COUNTER_NAME = MetricsUtil.name(SubscriptionController.class, \"receiptIssued\");\n  static final String PROCESSOR_TAG_NAME = \"processor\";\n  static final String TYPE_TAG_NAME = \"type\";\n  private static final String SUBSCRIPTION_TYPE_TAG_NAME = \"subscriptionType\";\n\n  public SubscriptionController(\n      @Nonnull Clock clock,\n      @Nonnull SubscriptionConfiguration subscriptionConfiguration,\n      @Nonnull OneTimeDonationConfiguration oneTimeDonationConfiguration,\n      @Nonnull SubscriptionManager subscriptionManager,\n      @Nonnull StripeManager stripeManager,\n      @Nonnull BraintreeManager braintreeManager,\n      @Nonnull GooglePlayBillingManager googlePlayBillingManager,\n      @Nonnull AppleAppStoreManager appleAppStoreManager,\n      @Nonnull BadgeTranslator badgeTranslator,\n      @Nonnull BankMandateTranslator bankMandateTranslator,\n      @NotNull DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n    this.subscriptionManager = subscriptionManager;\n    this.clock = Objects.requireNonNull(clock);\n    this.subscriptionConfiguration = Objects.requireNonNull(subscriptionConfiguration);\n    this.oneTimeDonationConfiguration = Objects.requireNonNull(oneTimeDonationConfiguration);\n    this.stripeManager = Objects.requireNonNull(stripeManager);\n    this.braintreeManager = Objects.requireNonNull(braintreeManager);\n    this.googlePlayBillingManager = Objects.requireNonNull(googlePlayBillingManager);\n    this.appleAppStoreManager = appleAppStoreManager;\n    this.badgeTranslator = Objects.requireNonNull(badgeTranslator);\n    this.bankMandateTranslator = Objects.requireNonNull(bankMandateTranslator);\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n  }\n\n  private Map<String, CurrencyConfiguration> buildCurrencyConfiguration() {\n    final List<CustomerAwareSubscriptionPaymentProcessor> subscriptionPaymentProcessors = List.of(stripeManager, braintreeManager);\n    return oneTimeDonationConfiguration.currencies()\n        .entrySet().stream()\n        .collect(Collectors.toMap(Entry::getKey, currencyAndConfig -> {\n          final String currency = currencyAndConfig.getKey();\n          final OneTimeDonationCurrencyConfiguration currencyConfig = currencyAndConfig.getValue();\n\n          final Map<String, List<BigDecimal>> oneTimeLevelsToSuggestedAmounts = Map.of(\n              String.valueOf(oneTimeDonationConfiguration.boost().level()), currencyConfig.boosts(),\n              String.valueOf(oneTimeDonationConfiguration.gift().level()), List.of(currencyConfig.gift())\n          );\n\n          final Function<Map<Long, ? extends SubscriptionLevelConfiguration>, Map<String, BigDecimal>> extractSubscriptionAmounts = levels ->\n              levels.entrySet().stream()\n                  .filter(levelIdAndConfig -> levelIdAndConfig.getValue().prices().containsKey(currency))\n                  .collect(Collectors.toMap(\n                      levelIdAndConfig -> String.valueOf(levelIdAndConfig.getKey()),\n                      levelIdAndConfig -> levelIdAndConfig.getValue().prices().get(currency).amount()));\n\n          final List<String> supportedPaymentMethods = Arrays.stream(PaymentMethod.values())\n              .filter(paymentMethod -> subscriptionPaymentProcessors.stream()\n                  .anyMatch(manager -> manager.supportsPaymentMethod(paymentMethod)\n                      && manager.getSupportedCurrenciesForPaymentMethod(paymentMethod).contains(currency)))\n              .map(PaymentMethod::name)\n              .collect(Collectors.toList());\n\n          if (supportedPaymentMethods.isEmpty()) {\n            throw new RuntimeException(\"Configuration has currency with no processor support: \" + currency);\n          }\n\n          return new CurrencyConfiguration(\n              currencyConfig.minimum(),\n              oneTimeLevelsToSuggestedAmounts,\n              extractSubscriptionAmounts.apply(subscriptionConfiguration.getDonationLevels()),\n              extractSubscriptionAmounts.apply(subscriptionConfiguration.getBackupLevels()),\n              supportedPaymentMethods);\n        }));\n  }\n\n  @VisibleForTesting\n  GetSubscriptionConfigurationResponse buildGetSubscriptionConfigurationResponse(\n      final List<Locale> acceptableLanguages) {\n    final Map<String, LevelConfiguration> donationLevels = new HashMap<>();\n\n    subscriptionConfiguration.getDonationLevels().forEach((levelId, levelConfig) -> {\n      final LevelConfiguration levelConfiguration = new LevelConfiguration(\n          \"\" /* deprecated and unused */,\n          badgeTranslator.translate(acceptableLanguages, levelConfig.badge()));\n      donationLevels.put(String.valueOf(levelId), levelConfiguration);\n    });\n\n    final Badge boostBadge = badgeTranslator.translate(acceptableLanguages,\n        oneTimeDonationConfiguration.boost().badge());\n    donationLevels.put(String.valueOf(oneTimeDonationConfiguration.boost().level()),\n        new LevelConfiguration(\n            \"\" /* deprecated and unused */,\n            // NB: the one-time badges are PurchasableBadge, which has a `duration` field\n            new PurchasableBadge(\n                boostBadge,\n                oneTimeDonationConfiguration.boost().expiration())));\n\n    final Badge giftBadge = badgeTranslator.translate(acceptableLanguages, oneTimeDonationConfiguration.gift().badge());\n    donationLevels.put(String.valueOf(oneTimeDonationConfiguration.gift().level()),\n        new LevelConfiguration(\n            \"\" /* deprecated and unused */,\n            new PurchasableBadge(\n                giftBadge,\n                oneTimeDonationConfiguration.gift().expiration())));\n\n    final long maxTotalBackupMediaBytes =\n        dynamicConfigurationManager.getConfiguration().getBackupConfiguration().maxTotalMediaSize();\n    final Map<String, BackupLevelConfiguration> backupLevels = subscriptionConfiguration.getBackupLevels()\n        .entrySet().stream()\n        .collect(Collectors.toMap(\n            e -> String.valueOf(e.getKey()),\n            e -> new BackupLevelConfiguration(\n                maxTotalBackupMediaBytes,\n                e.getValue().playProductId(),\n                e.getValue().mediaTtl().toDays())));\n\n    return new GetSubscriptionConfigurationResponse(buildCurrencyConfiguration(), donationLevels,\n        new BackupConfiguration(backupLevels, subscriptionConfiguration.getbackupFreeTierMediaDuration().toDays()),\n        oneTimeDonationConfiguration.sepaMaximumEuros());\n  }\n\n  @DELETE\n  @Path(\"/{subscriberId}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Cancel a subscription\", description = \"\"\"\n      Cancels any current subscription at the end of the current subscription period.\n\n      Note: Apple IAP subscriptions do not support server-side cancellation, so this method should only be called after\n      cancelling a subscription from storekit to keep server data up to date.\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"All subscriptions cancelled\")\n  @ApiResponse(responseCode = \"403\", description = \"Account authentication is present\")\n  @ApiResponse(responseCode = \"404\", description = \"subscriberId is not found or malformed\")\n  @ApiResponse(responseCode = \"400\", description = \"The associated subscription is not a type that can be cancelled\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ManagedAsync\n  public Response deleteSubscriber(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId) throws SubscriptionException, RateLimitExceededException {\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n    subscriptionManager.deleteSubscriber(subscriberCredentials);\n    return Response.ok().build();\n  }\n\n  @PUT\n  @Path(\"/{subscriberId}\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Create/refresh a subscriber\", description = \"\"\"\n      Creates a subscriber record if it does not exist, otherwise refreshes its last access time.\n\n      Subscribers MUST periodically hit this endpoint to update the access time on the subscription record. Subscribers\n      SHOULD attempt to make an update call approximately every 3 days. Not accessing this endpoint for an extended\n      period of time will result in the subscription being canceled.\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The subscriber was successfully created or refreshed\")\n  @ApiResponse(responseCode = \"403\", description = \"subscriberId authentication failure OR account authentication is present\")\n  @ApiResponse(responseCode = \"404\", description = \"subscriberId is malformed\")\n  @ManagedAsync\n  public Response updateSubscriber(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId) throws SubscriptionException {\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n    subscriptionManager.updateSubscriber(subscriberCredentials);\n    return Response.ok().build();\n  }\n\n  public record CreatePaymentMethodResponse(String clientSecret, PaymentProvider processor) {\n\n  }\n\n  @POST\n  @Path(\"/{subscriberId}/create_payment_method\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public CreatePaymentMethodResponse createPaymentMethod(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @QueryParam(\"type\") @DefaultValue(\"CARD\") PaymentMethod paymentMethodType,\n      @HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) throws SubscriptionException {\n\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n\n    final CustomerAwareSubscriptionPaymentProcessor customerAwareSubscriptionPaymentProcessor = switch (paymentMethodType) {\n      // Today, we always choose stripe to process non-paypal payment types, however we could use braintree to process\n      // other types (like CARD) in the future.\n      case CARD, SEPA_DEBIT, IDEAL -> stripeManager;\n      case GOOGLE_PLAY_BILLING, APPLE_APP_STORE ->\n          throw new BadRequestException(\"cannot create payment methods with payment type \" + paymentMethodType);\n      case PAYPAL -> throw new BadRequestException(\"The PAYPAL payment type must use create_payment_method/paypal\");\n      case UNKNOWN -> throw new BadRequestException(\"Invalid payment method\");\n    };\n\n    final String token = subscriptionManager.addPaymentMethodToCustomer(\n        subscriberCredentials,\n        customerAwareSubscriptionPaymentProcessor,\n        getClientPlatform(userAgentString),\n        CustomerAwareSubscriptionPaymentProcessor::createPaymentMethodSetupToken);\n\n    return new CreatePaymentMethodResponse(token, customerAwareSubscriptionPaymentProcessor.getProvider());\n  }\n\n  public record CreatePayPalBillingAgreementRequest(@NotBlank String returnUrl, @NotBlank String cancelUrl) {}\n\n  public record CreatePayPalBillingAgreementResponse(@NotBlank String approvalUrl, @NotBlank String token) {}\n\n  @POST\n  @Path(\"/{subscriberId}/create_payment_method/paypal\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public CreatePayPalBillingAgreementResponse createPayPalPaymentMethod(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @NotNull @Valid CreatePayPalBillingAgreementRequest request,\n      @Context ContainerRequestContext containerRequestContext,\n      @HeaderParam(HttpHeaders.USER_AGENT) @Nullable final String userAgentString) throws SubscriptionException {\n\n    final SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n    final Locale locale = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext).stream()\n        .filter(l -> !\"*\".equals(l.getLanguage()))\n        .findFirst()\n        .orElse(Locale.US);\n\n    final BraintreeManager.PayPalBillingAgreementApprovalDetails billingAgreementApprovalDetails = subscriptionManager.addPaymentMethodToCustomer(\n            subscriberCredentials,\n            braintreeManager,\n            getClientPlatform(userAgentString),\n            (mgr, customerId) ->\n                mgr.createPayPalBillingAgreement(request.returnUrl, request.cancelUrl, locale.toLanguageTag()))\n        .join();\n    return new CreatePayPalBillingAgreementResponse(\n        billingAgreementApprovalDetails.approvalUrl(),\n        billingAgreementApprovalDetails.billingAgreementToken());\n  }\n\n  private CustomerAwareSubscriptionPaymentProcessor getCustomerAwareProcessor(PaymentProvider processor) {\n    return switch (processor) {\n      case STRIPE -> stripeManager;\n      case BRAINTREE -> braintreeManager;\n      case GOOGLE_PLAY_BILLING, APPLE_APP_STORE -> throw new BadRequestException(\"Operation cannot be performed with the \" + processor + \" payment provider\");\n    };\n  }\n\n  @POST\n  @Path(\"/{subscriberId}/default_payment_method/{processor}/{paymentMethodToken}\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public Response setDefaultPaymentMethodWithProcessor(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @PathParam(\"processor\") PaymentProvider processor,\n      @PathParam(\"paymentMethodToken\") @NotEmpty String paymentMethodToken) throws SubscriptionException {\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n\n    final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(processor);\n\n    setDefaultPaymentMethod(manager, paymentMethodToken, subscriberCredentials);\n    return Response.ok().build();\n  }\n\n  public record SetSubscriptionLevelSuccessResponse(long level) {\n  }\n\n  public record SetSubscriptionLevelErrorResponse(List<Error> errors) {\n\n    public record Error(SetSubscriptionLevelErrorResponse.Error.Type type, String message) {\n\n      public enum Type {\n        // The requested level was invalid\n        UNSUPPORTED_LEVEL,\n        // The requested currency was invalid\n        UNSUPPORTED_CURRENCY,\n        // The card could not be charged\n        PAYMENT_REQUIRES_ACTION,\n        // The request arguments were invalid representing a programmer error\n        INVALID_ARGUMENTS\n      }\n    }\n  }\n\n  @PUT\n  @Path(\"/{subscriberId}/level/{level}/{currency}/{idempotencyKey}\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public SetSubscriptionLevelSuccessResponse setSubscriptionLevel(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @PathParam(\"level\") long level,\n      @PathParam(\"currency\") String currency,\n      @PathParam(\"idempotencyKey\") String idempotencyKey) throws SubscriptionException {\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n    try {\n      final Subscriptions.Record record = subscriptionManager.getSubscriber(subscriberCredentials);\n      final ProcessorCustomer processorCustomer = record.getProcessorCustomer()\n          .orElseThrow(() ->\n              // a missing customer ID indicates the client made requests out of order,\n              // and needs to call create_payment_method to create a customer for the given payment method\n              new ClientErrorException(Status.CONFLICT));\n\n      final String subscriptionTemplateId = getSubscriptionTemplateId(level, currency,\n          processorCustomer.processor());\n\n      final CustomerAwareSubscriptionPaymentProcessor manager = getCustomerAwareProcessor(\n          processorCustomer.processor());\n      subscriptionManager.updateSubscriptionLevelForCustomer(subscriberCredentials, record, manager, level,\n          currency, idempotencyKey, subscriptionTemplateId, this::subscriptionsAreSameType);\n      return new SetSubscriptionLevelSuccessResponse(level);\n    } catch (SubscriptionInvalidLevelException e) {\n      throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)\n          .entity(new SubscriptionController.SetSubscriptionLevelErrorResponse(List.of(\n              new SubscriptionController.SetSubscriptionLevelErrorResponse.Error(\n                  SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL,\n                  null))))\n          .build());\n    } catch (SubscriptionPaymentRequiresActionException e) {\n      throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)\n          .entity(new SetSubscriptionLevelErrorResponse(List.of(new SetSubscriptionLevelErrorResponse.Error(\n              SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION, null))))\n          .build());\n    } catch (SubscriptionInvalidArgumentsException e) {\n      throw new BadRequestException(Response.status(Response.Status.BAD_REQUEST)\n          .entity(new SetSubscriptionLevelErrorResponse(List.of(new SetSubscriptionLevelErrorResponse.Error(\n              SetSubscriptionLevelErrorResponse.Error.Type.INVALID_ARGUMENTS, e.getMessage()))))\n          .build());\n    }\n  }\n\n  public boolean subscriptionsAreSameType(long level1, long level2) {\n    return subscriptionConfiguration.getSubscriptionLevel(level1).type()\n        == subscriptionConfiguration.getSubscriptionLevel(level2).type();\n  }\n\n  @POST\n  @Path(\"/{subscriberId}/appstore/{originalTransactionId}\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Set app store subscription\", description = \"\"\"\n  Set an originalTransactionId that represents an IAP subscription made with the app store.\n  \n  To set up an app store subscription:\n  1. Create a subscriber with `PUT subscriptions/{subscriberId}` (you must regularly refresh this subscriber)\n  2. [Create a subscription](https://developer.apple.com/documentation/storekit/in-app_purchase/) with the App Store\n     directly via StoreKit and obtain a originalTransactionId.\n  3. `POST` the purchaseToken here\n  4. Obtain a receipt at `POST /v1/subscription/{subscriberId}/receipt_credentials` which can then be used to obtain the\n     entitlement\n  \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The originalTransactionId was successfully validated\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"402\", description = \"The subscription transaction is incomplete or invalid\")\n  @ApiResponse(responseCode = \"403\", description = \"subscriberId authentication failure OR account authentication is present\")\n  @ApiResponse(responseCode = \"404\", description = \"No such subscriberId exists or subscriberId is malformed or the specified transaction does not exist\")\n  @ApiResponse(responseCode = \"409\", description = \"subscriberId is already linked to a processor that does not support appstore payments. Delete this subscriberId and use a new one.\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ManagedAsync\n  public SetSubscriptionLevelSuccessResponse setAppStoreSubscription(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @PathParam(\"originalTransactionId\") String originalTransactionId) throws SubscriptionException, RateLimitExceededException {\n    final SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n\n    return new SetSubscriptionLevelSuccessResponse(subscriptionManager\n        .updateAppStoreTransactionId(subscriberCredentials, appleAppStoreManager, originalTransactionId));\n  }\n\n\n  @POST\n  @Path(\"/{subscriberId}/playbilling/{purchaseToken}\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Set a google play billing purchase token\", description = \"\"\"\n  Set a purchaseToken that represents an IAP subscription made with Google Play Billing.\n\n  To set up a subscription with Google Play Billing:\n  1. Create a subscriber with `PUT subscriptions/{subscriberId}` (you must regularly refresh this subscriber)\n  2. [Create a subscription](https://developer.android.com/google/play/billing/integrate) with Google Play Billing\n     directly and obtain a purchaseToken. Do not [acknowledge](https://developer.android.com/google/play/billing/integrate#subscriptions)\n     the purchaseToken.\n  3. `POST` the purchaseToken here\n  4. Obtain a receipt at `POST /v1/subscription/{subscriberId}/receipt_credentials` which can then be used to obtain the\n     entitlement\n\n  After calling this method, the payment is confirmed. Callers must durably store their subscriberId before calling\n  this method to ensure their payment is tracked.\n\n  Once a purchaseToken to is posted to a subscriberId, the same subscriberId must not be used with another payment\n  method. A different playbilling purchaseToken can be posted to the same subscriberId, in this case the subscription\n  associated with the old purchaseToken will be cancelled.\n  \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The purchaseToken was validated and acknowledged\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"402\", description = \"The purchaseToken payment is incomplete or invalid\")\n  @ApiResponse(responseCode = \"403\", description = \"subscriberId authentication failure OR account authentication is present\")\n  @ApiResponse(responseCode = \"404\", description = \"No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist\")\n  @ApiResponse(responseCode = \"409\", description = \"subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ManagedAsync\n  public SetSubscriptionLevelSuccessResponse setPlayStoreSubscription(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @PathParam(\"purchaseToken\") String purchaseToken) throws SubscriptionException, RateLimitExceededException {\n    final SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n\n    return new SetSubscriptionLevelSuccessResponse(subscriptionManager\n        .updatePlayBillingPurchaseToken(subscriberCredentials, googlePlayBillingManager, purchaseToken));\n  }\n\n  @Schema(description = \"\"\"\n      Comprehensive configuration for donation subscriptions, backup subscriptions, gift subscriptions, and one-time\n      donations pricing information for all levels are included in currencies. All levels that have an associated\n      badge are included in levels.  All levels that correspond to a backup payment tier are included in\n      backupLevels.\"\"\")\n  public record GetSubscriptionConfigurationResponse(\n      @Schema(description = \"A map of lower-cased ISO 3 currency codes to minimums and level-specific scalar amounts\")\n      Map<String, CurrencyConfiguration> currencies,\n      @Schema(description = \"A map of numeric donation level IDs to level-specific badge configuration\")\n      Map<String, LevelConfiguration> levels,\n      @Schema(description = \"Backup specific configuration\")\n      BackupConfiguration backup,\n      @Schema(description = \"The maximum value of a one-time donation SEPA transaction\")\n      BigDecimal sepaMaximumEuros) {}\n\n  @Schema(description = \"Configuration for a currency - use to present appropriate client interfaces\")\n  public record CurrencyConfiguration(\n      @Schema(description = \"The minimum amount that may be submitted for a one-time donation in the currency\")\n      BigDecimal minimum,\n      @Schema(description = \"A map of numeric one-time donation level IDs to the list of default amounts to be presented\")\n      Map<String, List<BigDecimal>> oneTime,\n      @Schema(description = \"A map of numeric subscription level IDs to the amount charged for that level\")\n      Map<String, BigDecimal> subscription,\n      @Schema(description = \"A map of numeric backup level IDs to the amount charged for that level\")\n      Map<String, BigDecimal> backupSubscription,\n      @Schema(description = \"The payment methods that support the given currency\")\n      List<String> supportedPaymentMethods) {}\n\n  @Schema(description = \"Configuration for a donation level - use to present appropriate client interfaces\")\n  public record LevelConfiguration(\n      @Deprecated(forRemoval = true) // may be removed after 2025-01-28\n      @Schema(description = \"The localized name for the level\")\n      String name,\n      @Schema(description = \"The displayable badge associated with the level\")\n      Badge badge) {}\n\n  public record BackupConfiguration(\n      @Schema(description = \"A map of numeric backup level IDs to level-specific backup configuration\")\n      Map<String, BackupLevelConfiguration> levels,\n      @Schema(description = \"The number of days of media a free tier backup user gets\")\n      long freeTierMediaDays) {}\n\n  @Schema(description = \"Configuration for a backup level - use to present appropriate client interfaces\")\n  public record BackupLevelConfiguration(\n      @Schema(description = \"The amount of media storage in bytes that a paying subscriber may store\")\n      long storageAllowanceBytes,\n      @Schema(description = \"The play billing productID associated with this backup level\")\n      String playProductId,\n      @Schema(description = \"The duration, in days, for which your backed up media is retained on the server after you stop refreshing with a paid credential\")\n      long mediaTtlDays) {}\n\n  @GET\n  @Path(\"/configuration\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Subscription configuration \",\n      description = \"\"\"\n          Returns all configuration for badges, donation subscriptions, backup subscriptions, and one-time donation (\n          \"boost\" and \"gift\") minimum and suggested amounts.\"\"\")\n  @ApiResponse(responseCode = \"200\", useReturnTypeSchema = true)\n  @ManagedAsync\n  public GetSubscriptionConfigurationResponse getConfiguration(@Context ContainerRequestContext containerRequestContext) {\n    List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);\n    return buildGetSubscriptionConfigurationResponse(acceptableLanguages);\n  }\n\n  @GET\n  @Path(\"/bank_mandate/{bankTransferType}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public GetBankMandateResponse getBankMandate(final @Context ContainerRequestContext containerRequestContext,\n      final @PathParam(\"bankTransferType\") BankTransferType bankTransferType) {\n    List<Locale> acceptableLanguages = HeaderUtils.getAcceptableLanguagesForRequest(containerRequestContext);\n    return new GetBankMandateResponse(bankMandateTranslator.translate(acceptableLanguages, bankTransferType));\n  }\n\n  public record GetBankMandateResponse(String mandate) {}\n\n  public record GetSubscriptionInformationResponse(\n      @Schema(description = \"Information about the subscription, or null if no subscription is present\")\n      SubscriptionController.GetSubscriptionInformationResponse.Subscription subscription,\n      @Schema(description = \"May be omitted entirely if no charge failure is detected\")\n      @JsonInclude(Include.NON_NULL) ChargeFailure chargeFailure) {\n\n    public record Subscription(\n        @Schema(description = \"The subscription level\")\n        long level,\n\n        @Schema(\n            description = \"If present, UNIX Epoch Timestamp in seconds, can be used to calculate next billing date.\",\n            externalDocs = @ExternalDocumentation(description = \"Calculate next billing date\", url = \"https://stripe.com/docs/billing/subscriptions/billing-cycle\"))\n        Instant billingCycleAnchor,\n\n        @Schema(description = \"UNIX Epoch Timestamp in seconds, when the current subscription period ends\")\n        Instant endOfCurrentPeriod,\n\n        @Schema(description = \"Whether there is a currently active subscription\")\n        boolean active,\n\n        @Schema(description = \"If true, an active subscription will not auto-renew at the end of the current period\")\n        boolean cancelAtPeriodEnd,\n\n        @Schema(description = \"A three-letter ISO 4217 currency code for currency used in the subscription\")\n        String currency,\n\n        @Schema(\n            description = \"The amount paid for the subscription in the currency's smallest unit\",\n            externalDocs = @ExternalDocumentation(description = \"Stripe Currencies\", url = \"https://docs.stripe.com/currencies\"))\n        BigDecimal amount,\n\n        @Schema(\n            description = \"The subscription's status, mapped to Stripe's statuses. trialing will never be returned\",\n            externalDocs = @ExternalDocumentation(description = \"Stripe subscription statuses\", url = \"https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses\"))\n        String status,\n\n        @Schema(description = \"The payment provider associated with the subscription\")\n        PaymentProvider processor,\n\n        @Schema(description = \"The payment method associated with the subscription\")\n        PaymentMethod paymentMethod,\n\n        @Schema(description = \"Whether the latest invoice for the subscription is in a non-terminal state\")\n        boolean paymentProcessing) {}\n  }\n\n  @GET\n  @Path(\"/{subscriberId}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Subscription information\", description = \"\"\"\n      Returns information about the current subscription associated with the provided subscriberId if one exists.\n  \n      Although it uses [Stripe’s values](https://stripe.com/docs/billing/subscriptions/overview#subscription-statuses),\n      the status field in the response is generic, with [Braintree-specific values](https://developer.paypal.com/braintree/docs/guides/recurring-billing/overview#subscription-statuses) mapped\n      to Stripe's. Since we don’t support trials or unpaid subscriptions, the associated statuses will never be returned\n      by the API.\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The subscriberId exists\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"403\", description = \"subscriberId authentication failure OR account authentication is present\")\n  @ApiResponse(responseCode = \"404\", description = \"No such subscriberId exists or subscriberId is malformed\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ManagedAsync\n  public GetSubscriptionInformationResponse getSubscriptionInformation(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId) throws SubscriptionException, RateLimitExceededException {\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n    return subscriptionManager.getSubscriptionInformation( subscriberCredentials)\n        .map(subscriptionInformation ->\n            new GetSubscriptionInformationResponse(\n                new GetSubscriptionInformationResponse.Subscription(\n                    subscriptionInformation.level(),\n                    subscriptionInformation.billingCycleAnchor(),\n                    subscriptionInformation.endOfCurrentPeriod(),\n                    subscriptionInformation.active(),\n                    subscriptionInformation.cancelAtPeriodEnd(),\n                    subscriptionInformation.price().currency(),\n                    subscriptionInformation.price().amount(),\n                    subscriptionInformation.status().getApiValue(),\n                    subscriptionInformation.paymentProvider(),\n                    subscriptionInformation.paymentMethod(),\n                    subscriptionInformation.paymentProcessing()),\n                subscriptionInformation.chargeFailure()\n            ))\n        .orElseGet(() -> new GetSubscriptionInformationResponse(null, null));\n  }\n\n  public record GetReceiptCredentialsRequest(\n      @Schema(description = \"A ReceiptCredentialRequest encoded in standard base64 with padding\")\n      @NotEmpty byte[] receiptCredentialRequest) {\n  }\n\n  public record GetReceiptCredentialsResponse(\n      @Schema(description = \"A ReceiptCredentialResponse encoded in standard base64 with padding\")\n      @NotEmpty byte[] receiptCredentialResponse) {\n  }\n\n  @POST\n  @Path(\"/{subscriberId}/receipt_credentials\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(summary = \"Create receipt credentials\", description = \"\"\"\n      Create a receipt from a valid payment invoice that can be used to obtain an entitlement\n\n      This request is repeatable so long as the ReceiptCredentialRequest remains the same. Clients should use the same\n      ReceiptCredentialRequest value until they attempt to redeem the resulting ReceiptCredentialPresentation. After\n      this point, the ReceiptCredentialRequest MUST NOT be reused or you may not be able to redeem a valid payment\n      invoice. Clients SHOULD retry requests at this endpoint with the same ReceiptCredentialRequest value until\n      receiving a response. After receiving a response, clients should then compute the ReceiptCredentialPresentation\n      and redeem it at the receipt redemption endpoint. Once the first attempt is made there, the same\n      ReceiptCredentialRequest MUST NOT be used again to request receipt credentials.\n\n      Note that you may in fact redeem TWO or more invoices for the same ReceiptCredentialRequest while retrying this\n      operation if a later invoice gets paid while you are retrying. However, the returned receipt is always for the\n      latest invoice, so it will have the latest expiration possible and no entitlement time will be lost. The important\n      thing is not to reuse ReceiptCredentialRequest after you have started attempting to redeem the associated\n      ReceiptCredentialPresentation. Then you may produce a ReceiptCredentialPresentation for a later invoice that\n      cannot be redeemed.\n\n      Clients MUST validate that the generated receipt credential's level and expiration matches their expectations.\n      \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Successfully created receipt\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"204\", description = \"No invoice has been issued for this subscription OR invoice is in 'draft' or 'open' state\")\n  @ApiResponse(responseCode = \"400\", description = \"Bad ReceiptCredentialRequest\")\n  @ApiResponse(responseCode = \"402\", description = \"Invoice is in any state other than 'draft', 'open', or 'paid'. May include chargeFailure details in body.\",\n      content = @Content(schema = @Schema(\n          nullable = true,\n          example = \"\"\"\n              {\n                \"chargeFailure\": {\n                  \"code\": \"incorrect_account_holder_name\",\n                  \"message\": \"The transaction can't be processed because your customer's account information is missing [...]\",\n                  \"outcomeNetworkStatus\": \"declined_by_network\",\n                  \"outcomeReason\": \"generic_decline\",\n                  \"outcomeType\": \"issuer_declined\"\n                }\n              }\n              \"\"\",\n          implementation = SubscriptionExceptionMapper.ChargeFailureResponse.class)))\n  @ApiResponse(responseCode = \"403\", description = \"subscriberId authentication failure OR account authentication is present\")\n  @ApiResponse(responseCode = \"404\", description = \"subscriberId is not found OR malformed OR no subscription setup on the subscriber id\")\n  @ApiResponse(responseCode = \"409\", description = \"latest paid receipt on subscription was already redeemed for a receipt credential but with a different receipt credential request\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, a positive integer indicating the number of seconds before a subsequent attempt could succeed\"))\n  @ManagedAsync\n  public Response createSubscriptionReceiptCredentials(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @NotNull @Valid GetReceiptCredentialsRequest request) throws SubscriptionException, RateLimitExceededException {\n    SubscriberCredentials subscriberCredentials = SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n    try {\n      final SubscriptionManager.ReceiptResult receiptCredential = subscriptionManager.createReceiptCredentials(\n          subscriberCredentials, request, this::receiptExpirationWithGracePeriod);\n\n      final ReceiptCredentialResponse receiptCredentialResponse = receiptCredential.receiptCredentialResponse();\n      final CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receipt = receiptCredential.receiptItem();\n      Metrics.counter(RECEIPT_ISSUED_COUNTER_NAME,\n              Tags.of(\n                  Tag.of(PROCESSOR_TAG_NAME, receiptCredential.paymentProvider().toString()),\n                  Tag.of(TYPE_TAG_NAME, \"subscription\"),\n                  Tag.of(SUBSCRIPTION_TYPE_TAG_NAME,\n                      subscriptionConfiguration.getSubscriptionLevel(receipt.level()).type().name()\n                          .toLowerCase(Locale.ROOT)),\n                  UserAgentTagUtil.getPlatformTag(userAgent)))\n          .increment();\n      return Response.ok(new GetReceiptCredentialsResponse(receiptCredentialResponse.serialize())).build();\n    } catch (SubscriptionReceiptRequestedForOpenPaymentException e) {\n      return Response.noContent().build();\n    }\n  }\n\n  @POST\n  @Path(\"/{subscriberId}/default_payment_method_for_ideal/{setupIntentId}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @ManagedAsync\n  public Response setDefaultPaymentMethodForIdeal(\n      @Auth Optional<AuthenticatedDevice> authenticatedAccount,\n      @PathParam(\"subscriberId\") String subscriberId,\n      @PathParam(\"setupIntentId\") @NotEmpty String setupIntentId) throws SubscriptionException {\n    SubscriberCredentials subscriberCredentials =\n        SubscriberCredentials.process(authenticatedAccount, subscriberId, clock);\n\n    final String generatedSepaId = stripeManager.getGeneratedSepaIdFromSetupIntent(setupIntentId).join();\n    setDefaultPaymentMethod(stripeManager, generatedSepaId, subscriberCredentials);\n    return Response.ok().build();\n  }\n\n  private void setDefaultPaymentMethod(final CustomerAwareSubscriptionPaymentProcessor manager,\n      final String paymentMethodId,\n      final SubscriberCredentials requestData) throws SubscriptionException {\n    try {\n      final Subscriptions.Record record = subscriptionManager.getSubscriber(requestData);\n\n      final ProcessorCustomer processorCustomer = record.getProcessorCustomer()\n          // a missing customer ID indicates the client made requests out of order,\n          // and needs to call create_payment_method to create a customer for the given payment method\n          .orElseThrow(() ->new ClientErrorException(Status.CONFLICT));\n\n      manager\n          .setDefaultPaymentMethodForCustomer(processorCustomer.customerId(), paymentMethodId, record.subscriptionId);\n    } catch (SubscriptionInvalidArgumentsException e) {\n      // Here, invalid arguments must mean that the client has made requests out of order, and needs to finish\n      // setting up the paymentMethod first\n      throw new ClientErrorException(Status.CONFLICT);\n    }\n  }\n\n  private Instant receiptExpirationWithGracePeriod(CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem) {\n    final PaymentTime paymentTime = receiptItem.paymentTime();\n    return switch (subscriptionConfiguration.getSubscriptionLevel(receiptItem.level()).type()) {\n      case DONATION -> paymentTime.receiptExpiration(\n          subscriptionConfiguration.getBadgeExpiration(),\n          subscriptionConfiguration.getBadgeGracePeriod());\n      case BACKUP -> paymentTime.receiptExpiration(\n          subscriptionConfiguration.getBackupExpiration(),\n          subscriptionConfiguration.getBackupGracePeriod());\n    };\n  }\n\n\n  private String getSubscriptionTemplateId(long level, String currency, PaymentProvider processor) {\n    final SubscriptionLevelConfiguration config = subscriptionConfiguration.getSubscriptionLevel(level);\n    if (config == null) {\n      throw new BadRequestException(Response.status(Status.BAD_REQUEST)\n          .entity(new SetSubscriptionLevelErrorResponse(List.of(\n              new SetSubscriptionLevelErrorResponse.Error(\n                  SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL, null))))\n          .build());\n    }\n    final Optional<String> templateId = Optional\n        .ofNullable(config.prices().get(currency.toLowerCase(Locale.ROOT)))\n        .map(priceConfiguration -> priceConfiguration.processorIds().get(processor));\n    return templateId.orElseThrow(() -> new BadRequestException(Response.status(Status.BAD_REQUEST)\n        .entity(new SetSubscriptionLevelErrorResponse(List.of(\n            new SetSubscriptionLevelErrorResponse.Error(\n                SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_CURRENCY, null))))\n        .build()));\n  }\n\n  @Nullable\n  private static ClientPlatform getClientPlatform(@Nullable final String userAgentString) {\n    try {\n      return UserAgentUtil.parseUserAgentString(userAgentString).platform();\n    } catch (final UnrecognizedUserAgentException e) {\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationController.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.Parameter;\nimport io.swagger.v3.oas.annotations.enums.ParameterIn;\nimport io.swagger.v3.oas.annotations.headers.Header;\nimport io.swagger.v3.oas.annotations.media.Content;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport io.swagger.v3.oas.annotations.responses.ApiResponse;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.ClientErrorException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.ForbiddenException;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.HeaderParam;\nimport jakarta.ws.rs.NotFoundException;\nimport jakarta.ws.rs.PATCH;\nimport jakarta.ws.rs.POST;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.ServerErrorException;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.SecureRandom;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.CancellationException;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.Strings;\nimport org.apache.http.HttpStatus;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.captcha.AssessmentResult;\nimport org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest;\nimport org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;\nimport org.whispersystems.textsecuregcm.entities.VerificationCodeRequest;\nimport org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.DevicePlatformUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.PushNotification;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.registration.ClientType;\nimport org.whispersystems.textsecuregcm.registration.MessageTransport;\nimport org.whispersystems.textsecuregcm.registration.RegistrationFraudException;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceException;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;\nimport org.whispersystems.textsecuregcm.registration.TransportNotAllowedException;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\nimport org.whispersystems.textsecuregcm.spam.RegistrationFraudChecker;\nimport org.whispersystems.textsecuregcm.spam.RegistrationFraudChecker.VerificationCheck;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.VerificationSessionManager;\nimport org.whispersystems.textsecuregcm.telephony.CarrierData;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataException;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.ObsoletePhoneNumberFormatException;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\n@Path(\"/v1/verification\")\n@io.swagger.v3.oas.annotations.tags.Tag(name = \"Verification\")\npublic class VerificationController {\n\n  private static final Logger logger = LoggerFactory.getLogger(VerificationController.class);\n  private static final Duration REGISTRATION_RPC_TIMEOUT = Duration.ofSeconds(15);\n  private static final Duration DYNAMODB_TIMEOUT = Duration.ofSeconds(5);\n\n  private static final SecureRandom RANDOM = new SecureRandom();\n\n  private static final String PUSH_CHALLENGE_COUNTER_NAME = name(VerificationController.class, \"pushChallenge\");\n  private static final String CHALLENGE_PRESENT_TAG_NAME = \"present\";\n  private static final String CHALLENGE_MATCH_TAG_NAME = \"matches\";\n  private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(VerificationController.class, \"captcha\");\n  private static final String COUNTRY_CODE_TAG_NAME = \"countryCode\";\n  private static final String REGION_CODE_TAG_NAME = \"regionCode\";\n  private static final String SCORE_TAG_NAME = \"score\";\n  private static final String CODE_REQUESTED_COUNTER_NAME = name(VerificationController.class, \"codeRequested\");\n  private static final String VERIFICATION_TRANSPORT_TAG_NAME = \"transport\";\n  private static final String VERIFIED_COUNTER_NAME = name(VerificationController.class, \"verified\");\n  private static final String SUCCESS_TAG_NAME = \"success\";\n  private static final String RECOVERY_PASSWORD_REMOVED_TAG_NAME = \"recoveryPasswordRemoved\";\n  private static final String REREGISTRATION_TAG_NAME = \"reregistration\";\n  private static final String EXISTING_ACCOUNT_PLATFORM = \"existingAccountPlatform\";\n  private static final String EXISTING_ACCOUNT_RECENTLY_SEEN_TAG_NAME = \"existingAccountRecentlySeen\";\n\n  private final RegistrationServiceClient registrationServiceClient;\n  private final VerificationSessionManager verificationSessionManager;\n  private final PushNotificationManager pushNotificationManager;\n  private final RegistrationCaptchaManager registrationCaptchaManager;\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers;\n  private final RateLimiters rateLimiters;\n  private final AccountsManager accountsManager;\n  private final CarrierDataProvider carrierDataProvider;\n  private final RegistrationFraudChecker registrationFraudChecker;\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  private final Clock clock;\n\n  public VerificationController(final RegistrationServiceClient registrationServiceClient,\n      final VerificationSessionManager verificationSessionManager,\n      final PushNotificationManager pushNotificationManager,\n      final RegistrationCaptchaManager registrationCaptchaManager,\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n      final PhoneNumberIdentifiers phoneNumberIdentifiers,\n      final RateLimiters rateLimiters,\n      final AccountsManager accountsManager,\n      final CarrierDataProvider carrierDataProvider,\n      final RegistrationFraudChecker registrationFraudChecker,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final Clock clock) {\n    this.registrationServiceClient = registrationServiceClient;\n    this.verificationSessionManager = verificationSessionManager;\n    this.pushNotificationManager = pushNotificationManager;\n    this.registrationCaptchaManager = registrationCaptchaManager;\n    this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n    this.rateLimiters = rateLimiters;\n    this.accountsManager = accountsManager;\n    this.carrierDataProvider = carrierDataProvider;\n    this.registrationFraudChecker = registrationFraudChecker;\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n    this.clock = clock;\n  }\n\n  @POST\n  @Path(\"/session\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Creates a new verification session for a specific phone number\",\n      description = \"\"\"\n          Initiates a session to be able to verify the phone number for account registration. Check the response and\n          submit requested information at PATCH /session/{sessionId}\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"The verification session was created successfully\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"422\", description = \"The request did not pass validation\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\", headers = @Header(\n      name = \"Retry-After\",\n      description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\",\n      schema = @Schema(implementation = Integer.class)))\n  public VerificationSessionResponse createSession(@NotNull @Valid final CreateVerificationSessionRequest request,\n      @Context final ContainerRequestContext requestContext)\n      throws RateLimitExceededException, ObsoletePhoneNumberFormatException {\n\n    final Pair<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(\n        request.updateVerificationSessionRequest());\n\n    final Phonenumber.PhoneNumber phoneNumber;\n    try {\n      phoneNumber = Util.canonicalizePhoneNumber(PhoneNumberUtil.getInstance().parse(request.number(), null));\n    } catch (final NumberParseException e) {\n      throw new ServerErrorException(\"could not parse already validated number\", Response.Status.INTERNAL_SERVER_ERROR);\n    }\n\n    Optional<CarrierData> maybeCarrierData;\n\n    if (dynamicConfigurationManager.getConfiguration().getCarrierDataLookupConfiguration().enabled()) {\n      try {\n        maybeCarrierData = carrierDataProvider.lookupCarrierData(phoneNumber,\n            dynamicConfigurationManager.getConfiguration().getCarrierDataLookupConfiguration().maxCacheAge());\n      } catch (final IOException | CarrierDataException e) {\n        logger.warn(\"Failed to retrieve carrier data\", e);\n        maybeCarrierData = Optional.empty();\n      }\n    } else {\n      maybeCarrierData = Optional.empty();\n    }\n\n    final RegistrationServiceSession registrationServiceSession;\n    try {\n      final String sourceHost = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n      registrationServiceSession = registrationServiceClient.createRegistrationSession(phoneNumber,\n          sourceHost,\n          accountsManager.getByE164(request.number()).isPresent(),\n          maybeCarrierData.flatMap(CarrierData::mcc).orElse(null),\n          maybeCarrierData.flatMap(CarrierData::mnc).orElse(null),\n          REGISTRATION_RPC_TIMEOUT).join();\n    } catch (final CancellationException e) {\n\n      throw new ServerErrorException(\"registration service unavailable\", Response.Status.SERVICE_UNAVAILABLE);\n    } catch (final CompletionException e) {\n\n      if (ExceptionUtils.unwrap(e) instanceof RateLimitExceededException re) {\n        throw re;\n      }\n\n      throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR, e);\n    }\n\n    VerificationSession verificationSession = new VerificationSession(registrationServiceSession.encodedSessionId(),\n        null,\n        maybeCarrierData.orElse(null),\n        new ArrayList<>(),\n        Collections.emptyList(),\n        null,\n        null,\n        false,\n        clock.millis(),\n        clock.millis(),\n        registrationServiceSession.expiration());\n\n    verificationSession = handlePushToken(pushTokenAndType, verificationSession);\n    // unconditionally request a captcha -- it will either be the only requested information, or a fallback\n    // if a push challenge sent in `handlePushToken` doesn't arrive in time\n    verificationSession.requestedInformation().add(VerificationSession.Information.CAPTCHA);\n\n    storeVerificationSession(verificationSession);\n\n    return buildResponse(registrationServiceSession, verificationSession);\n  }\n\n  @PATCH\n  @Path(\"/session/{sessionId}\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Update a registration verification session\",\n      description = \"\"\"\n          Updates the session with requested information like an answer to a push challenge or captcha.\n          If `requestedInformation` in the response is empty, and `allowedToRequestCode` is `true`, proceed to call\n          `POST /session/{sessionId}/code`. If `requestedInformation` is empty and `allowedToRequestCode` is `false`,\n          then the caller must create a new verification session.\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Session was updated successfully with the information provided\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"403\", description = \"The information provided was not accepted (e.g push challenge or captcha verification failed)\")\n  @ApiResponse(responseCode = \"422\", description = \"The request did not pass validation\")\n  @ApiResponse(responseCode = \"429\", description = \"Too many attempts\",\n      content = @Content(schema = @Schema(implementation = VerificationSessionResponse.class)),\n      headers = @Header(\n          name = \"Retry-After\",\n          description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\",\n          schema = @Schema(implementation = Integer.class)))\n  public VerificationSessionResponse updateSession(\n      @PathParam(\"sessionId\") final String encodedSessionId,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @Context final ContainerRequestContext requestContext,\n      @NotNull @Valid final UpdateVerificationSessionRequest updateVerificationSessionRequest) {\n\n    final String sourceHost = (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n    final Pair<String, PushNotification.TokenType> pushTokenAndType = validateAndExtractPushToken(\n        updateVerificationSessionRequest);\n\n    final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);\n    VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);\n\n    final VerificationCheck verificationCheck = registrationFraudChecker.checkVerificationAttempt(\n        requestContext,\n        verificationSession,\n        registrationServiceSession.number(),\n        updateVerificationSessionRequest);\n\n    try {\n      // these handle* methods ordered from least likely to fail to most, so take care when considering a change\n\n      verificationSession = verificationCheck.updatedSession().orElse(verificationSession);\n\n      verificationSession = handlePushToken(pushTokenAndType, verificationSession);\n\n      verificationSession = handlePushChallenge(updateVerificationSessionRequest, registrationServiceSession,\n          verificationSession);\n\n      verificationSession = handleCaptcha(sourceHost, updateVerificationSessionRequest, registrationServiceSession,\n          verificationSession, userAgent, verificationCheck.scoreThreshold());\n    } catch (final RateLimitExceededException e) {\n\n      final Response response = buildResponseForRateLimitExceeded(verificationSession, registrationServiceSession,\n          e.getRetryDuration());\n      throw new ClientErrorException(response);\n\n    } catch (final ForbiddenException e) {\n\n      throw new ClientErrorException(Response.status(Response.Status.FORBIDDEN)\n          .entity(buildResponse(registrationServiceSession, verificationSession))\n          .build());\n\n    } finally {\n      // Each of the handle* methods may update requestedInformation, submittedInformation, and allowedToRequestCode,\n      // and we want to be sure to store a changes, even if a later method throws\n      updateStoredVerificationSession(verificationSession);\n    }\n\n    return buildResponse(registrationServiceSession, verificationSession);\n  }\n\n  private void storeVerificationSession(final VerificationSession verificationSession) {\n    verificationSessionManager.insert(verificationSession)\n        .orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)\n        .join();\n  }\n\n  private void updateStoredVerificationSession(final VerificationSession verificationSession) {\n    verificationSessionManager.update(verificationSession)\n        .orTimeout(DYNAMODB_TIMEOUT.toSeconds(), TimeUnit.SECONDS)\n        .join();\n  }\n\n  /**\n   * If {@code pushTokenAndType} values are not {@code null}, sends a push challenge. If there is no existing push\n   * challenge in the session, one will be created, set on the returned session record, and\n   * {@link VerificationSession#requestedInformation()} will be updated.\n   */\n  private VerificationSession handlePushToken(\n      final Pair<String, PushNotification.TokenType> pushTokenAndType, VerificationSession verificationSession) {\n\n    if (pushTokenAndType.first() != null) {\n\n      if (verificationSession.pushChallenge() == null) {\n\n        final List<VerificationSession.Information> requestedInformation = new ArrayList<>();\n        requestedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);\n        requestedInformation.addAll(verificationSession.requestedInformation());\n\n        verificationSession = new VerificationSession(verificationSession.sessionId(),\n            generatePushChallenge(),\n            verificationSession.carrierData(),\n            requestedInformation,\n            verificationSession.submittedInformation(),\n            verificationSession.smsSenderOverride(),\n            verificationSession.voiceSenderOverride(),\n            verificationSession.allowedToRequestCode(),\n            verificationSession.createdTimestamp(),\n            clock.millis(),\n            verificationSession.remoteExpirationSeconds()\n        );\n      }\n\n      pushNotificationManager.sendRegistrationChallengeNotification(pushTokenAndType.first(), pushTokenAndType.second(),\n          verificationSession.pushChallenge());\n    }\n\n    return verificationSession;\n  }\n\n  /**\n   * If a push challenge value is present, compares against the stored value. If they match, then\n   * {@link VerificationSession.Information#PUSH_CHALLENGE} is removed from requested information, added to submitted\n   * information, and {@link VerificationSession#allowedToRequestCode()} is re-evaluated.\n   *\n   * @throws ForbiddenException         if values to not match.\n   * @throws RateLimitExceededException if too many push challenges have been submitted\n   */\n  private VerificationSession handlePushChallenge(\n      final UpdateVerificationSessionRequest updateVerificationSessionRequest,\n      final RegistrationServiceSession registrationServiceSession,\n      VerificationSession verificationSession) throws RateLimitExceededException {\n\n    if (verificationSession.submittedInformation()\n        .contains(VerificationSession.Information.PUSH_CHALLENGE)) {\n      // skip if a challenge has already been submitted\n      return verificationSession;\n    }\n\n    final boolean pushChallengePresent = updateVerificationSessionRequest.pushChallenge() != null;\n    if (pushChallengePresent) {\n      rateLimiters.getVerificationPushChallengeLimiter()\n          .validate(registrationServiceSession.encodedSessionId());\n    }\n\n    final boolean pushChallengeMatches;\n    if (pushChallengePresent && verificationSession.pushChallenge() != null) {\n      pushChallengeMatches = MessageDigest.isEqual(\n          updateVerificationSessionRequest.pushChallenge().getBytes(StandardCharsets.UTF_8),\n          verificationSession.pushChallenge().getBytes(StandardCharsets.UTF_8));\n    } else {\n      pushChallengeMatches = false;\n    }\n\n    Metrics.counter(PUSH_CHALLENGE_COUNTER_NAME,\n            COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number()),\n            REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number()),\n            CHALLENGE_PRESENT_TAG_NAME, Boolean.toString(pushChallengePresent),\n            CHALLENGE_MATCH_TAG_NAME, Boolean.toString(pushChallengeMatches))\n        .increment();\n\n    if (pushChallengeMatches) {\n      final List<VerificationSession.Information> submittedInformation = new ArrayList<>(\n          verificationSession.submittedInformation());\n      submittedInformation.add(VerificationSession.Information.PUSH_CHALLENGE);\n\n      final List<VerificationSession.Information> requestedInformation = new ArrayList<>(\n          verificationSession.requestedInformation());\n      // a push challenge satisfies a requested captcha\n      requestedInformation.remove(VerificationSession.Information.CAPTCHA);\n      final boolean allowedToRequestCode = (verificationSession.allowedToRequestCode()\n          || requestedInformation.remove(VerificationSession.Information.PUSH_CHALLENGE))\n          && requestedInformation.isEmpty();\n\n      verificationSession = new VerificationSession(verificationSession.sessionId(),\n          verificationSession.pushChallenge(),\n          verificationSession.carrierData(),\n          requestedInformation,\n          submittedInformation,\n          verificationSession.smsSenderOverride(),\n          verificationSession.voiceSenderOverride(),\n          allowedToRequestCode,\n          verificationSession.createdTimestamp(),\n          clock.millis(),\n          verificationSession.remoteExpirationSeconds());\n\n    } else if (pushChallengePresent) {\n      throw new ForbiddenException();\n    }\n    return verificationSession;\n  }\n\n  /**\n   * If a captcha value is present, it is assessed. If it is valid, then {@link VerificationSession.Information#CAPTCHA}\n   * is removed from requested information, added to submitted information, and\n   * {@link VerificationSession#allowedToRequestCode()} is re-evaluated.\n   *\n   * @throws ForbiddenException         if assessment is not valid.\n   * @throws RateLimitExceededException if too many captchas have been submitted\n   */\n  private VerificationSession handleCaptcha(\n      final String sourceHost,\n      final UpdateVerificationSessionRequest updateVerificationSessionRequest,\n      final RegistrationServiceSession registrationServiceSession,\n      VerificationSession verificationSession,\n      final String userAgent,\n      final Optional<Float> captchaScoreThreshold) throws RateLimitExceededException {\n\n    if (updateVerificationSessionRequest.captcha() == null) {\n      return verificationSession;\n    }\n\n    rateLimiters.getVerificationCaptchaLimiter().validate(registrationServiceSession.encodedSessionId());\n\n    final AssessmentResult assessmentResult;\n    try {\n\n      assessmentResult = registrationCaptchaManager.assessCaptcha(\n              Optional.empty(),\n              Optional.of(updateVerificationSessionRequest.captcha()), sourceHost, userAgent)\n          .orElseThrow(() -> new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR));\n\n      Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, Tags.of(\n              Tag.of(SUCCESS_TAG_NAME, String.valueOf(assessmentResult.isValid(captchaScoreThreshold))),\n              UserAgentTagUtil.getPlatformTag(userAgent),\n              Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),\n              Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),\n              Tag.of(SCORE_TAG_NAME, assessmentResult.getScoreString())))\n          .increment();\n\n    } catch (final IOException e) {\n      logger.error(\"error assessing captcha during registration verification\", e);\n      throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);\n    }\n\n    if (assessmentResult.isValid(captchaScoreThreshold)) {\n      final List<VerificationSession.Information> submittedInformation = new ArrayList<>(\n          verificationSession.submittedInformation());\n      submittedInformation.add(VerificationSession.Information.CAPTCHA);\n\n      final List<VerificationSession.Information> requestedInformation = new ArrayList<>(\n          verificationSession.requestedInformation());\n      // a captcha satisfies a push challenge, in case of push deliverability issues\n      requestedInformation.remove(VerificationSession.Information.PUSH_CHALLENGE);\n      final boolean allowedToRequestCode = (verificationSession.allowedToRequestCode()\n          || requestedInformation.remove(VerificationSession.Information.CAPTCHA))\n          && requestedInformation.isEmpty();\n\n      verificationSession = new VerificationSession(verificationSession.sessionId(),\n          verificationSession.pushChallenge(),\n          verificationSession.carrierData(),\n          requestedInformation,\n          submittedInformation,\n          verificationSession.smsSenderOverride(),\n          verificationSession.voiceSenderOverride(),\n          allowedToRequestCode,\n          verificationSession.createdTimestamp(),\n          clock.millis(),\n          verificationSession.remoteExpirationSeconds());\n    } else {\n      throw new ForbiddenException();\n    }\n\n    return verificationSession;\n  }\n\n  @GET\n  @Path(\"/session/{sessionId}\")\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Get a registration verification session\",\n      description = \"\"\"\n          Retrieve metadata of the registration verification session with the specified ID\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Session was retrieved successfully\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid session ID\")\n  @ApiResponse(responseCode = \"404\", description = \"Session with the specified ID could not be found\")\n  @ApiResponse(responseCode = \"422\", description = \"Malformed session ID encoding\")\n  public VerificationSessionResponse getSession(@PathParam(\"sessionId\") final String encodedSessionId) {\n\n    final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);\n    final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);\n\n    return buildResponse(registrationServiceSession, verificationSession);\n  }\n\n  @POST\n  @Path(\"/session/{sessionId}/code\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Request a verification code\",\n      description = \"\"\"\n          Sends a verification code to the phone number associated with the specified session via SMS or phone call.\n          This endpoint can only be called when the session metadata includes \"allowedToRequestCode = true\"\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"Verification code was successfully sent\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid session ID\")\n  @ApiResponse(responseCode = \"404\", description = \"Session with the specified ID could not be found\")\n  @ApiResponse(responseCode = \"409\", description = \"The session is already verified or not in a state to request a code because requested information hasn't been provided yet\",\n      content = @Content(schema = @Schema(implementation = VerificationSessionResponse.class)))\n  @ApiResponse(responseCode = \"418\", description = \"The request to send a verification code with the given transport could not be fulfilled, but may succeed with a different transport\",\n      content = @Content(schema = @Schema(implementation = VerificationSessionResponse.class)))\n  @ApiResponse(responseCode = \"422\", description = \"Request did not pass validation\")\n  @ApiResponse(responseCode = \"429\", description = \"\"\"\n      Too may attempts; the caller is not permitted to send a verification code via the requested channel at this time\n      and may need to wait before trying again; if the session metadata does not specify a time at which the caller may\n      try again, then the caller has exhausted their permitted attempts and must either try a different transport or\n      create a new verification session.\n      \"\"\",\n      content = @Content(schema = @Schema(implementation = VerificationSessionResponse.class)),\n      headers = @Header(\n          name = \"Retry-After\",\n          description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\",\n          schema = @Schema(implementation = Integer.class)\n      ))\n  @ApiResponse(responseCode = \"440\", description = \"\"\"\n      The attempt to send a verification code failed because an external service (e.g. the SMS provider) refused to\n      deliver the code. This may be a temporary or permanent failure, as indicated in the response body. If temporary,\n      clients may try again after a reasonable delay. If permanent, clients should not retry the request and should\n      communicate the permanent failure to the end user. Permanent failures may result in the server disallowing all\n      future attempts to request or submit verification codes (since those attempts would be all but guaranteed to fail).\n      \"\"\",\n      content = @Content(schema = @Schema(implementation = RegistrationServiceSenderExceptionMapper.SendVerificationCodeFailureResponse.class)))\n  public VerificationSessionResponse requestVerificationCode(@PathParam(\"sessionId\") final String encodedSessionId,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @Parameter(in = ParameterIn.HEADER, description = \"Ordered list of languages in which the client prefers to receive SMS or voice verification messages\") @HeaderParam(HttpHeaders.ACCEPT_LANGUAGE)\n      final Optional<String> acceptLanguage,\n      @NotNull @Valid final VerificationCodeRequest verificationCodeRequest,\n      @Context final ContainerRequestContext requestContext) throws Throwable {\n\n    final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);\n\n    final VerificationSession verificationSession;\n    {\n      final VerificationSession storedVerificationSession = retrieveVerificationSession(registrationServiceSession);\n\n      verificationSession =\n          registrationFraudChecker.checkSendVerificationCodeAttempt(requestContext, storedVerificationSession,\n                  registrationServiceSession.number())\n              .updatedSession()\n              .orElse(storedVerificationSession);\n    }\n\n    if (registrationServiceSession.verified()) {\n      throw new ClientErrorException(\n          Response.status(Response.Status.CONFLICT)\n              .entity(buildResponse(registrationServiceSession, verificationSession))\n              .build());\n    }\n\n    if (!verificationSession.allowedToRequestCode()) {\n      final Response.Status status = verificationSession.requestedInformation().isEmpty()\n          ? Response.Status.TOO_MANY_REQUESTS\n          : Response.Status.CONFLICT;\n\n      throw new ClientErrorException(\n          Response.status(status)\n              .entity(buildResponse(registrationServiceSession, verificationSession))\n              .build());\n    }\n\n    final MessageTransport messageTransport = verificationCodeRequest.transport().toMessageTransport();\n\n    final ClientType clientType = switch (verificationCodeRequest.client()) {\n      case \"ios\" -> ClientType.IOS;\n      case \"android-2021-03\" -> ClientType.ANDROID_WITH_FCM;\n      default -> {\n        if (Strings.CI.startsWith(verificationCodeRequest.client(), \"android\")) {\n          yield ClientType.ANDROID_WITHOUT_FCM;\n        }\n        yield ClientType.UNKNOWN;\n      }\n    };\n\n    final String senderOverride = switch (messageTransport) {\n      case SMS -> verificationSession.smsSenderOverride();\n      case VOICE -> verificationSession.voiceSenderOverride();\n    };\n\n    final RegistrationServiceSession resultSession;\n    try {\n      resultSession = registrationServiceClient.sendVerificationCode(registrationServiceSession.id(),\n          messageTransport,\n          clientType,\n          acceptLanguage.orElse(null),\n          senderOverride,\n          REGISTRATION_RPC_TIMEOUT).join();\n    } catch (final CancellationException e) {\n      throw new ServerErrorException(\"registration service unavailable\", Response.Status.SERVICE_UNAVAILABLE);\n    } catch (final CompletionException e) {\n      final Throwable unwrappedException = ExceptionUtils.unwrap(e);\n      switch (unwrappedException) {\n        case RateLimitExceededException rateLimitExceededException -> {\n          if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {\n            final Response response = buildResponseForRateLimitExceeded(verificationSession,\n                ve.getRegistrationSession(),\n                ve.getRetryDuration());\n            throw new ClientErrorException(response);\n          }\n\n          throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null));\n        }\n        case RegistrationServiceException registrationServiceException ->\n            throw registrationServiceException.getRegistrationSession()\n                .map(s -> buildResponse(s, verificationSession))\n                .map(verificationSessionResponse -> {\n                  final Response response = registrationServiceException instanceof TransportNotAllowedException\n                      ? Response.status(418).entity(verificationSessionResponse).build()\n                      : Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build();\n\n                  return new ClientErrorException(response);\n                })\n                .orElseGet(NotFoundException::new);\n        case RegistrationFraudException _ -> {\n          if (dynamicConfigurationManager.getConfiguration().getRegistrationConfiguration()\n              .squashDeclinedAttemptErrors()) {\n            return buildResponse(registrationServiceSession, verificationSession);\n          } else {\n            throw unwrappedException.getCause();\n          }\n        }\n        case RegistrationServiceSenderException _ -> throw unwrappedException;\n        case null, default -> {\n          logger.error(\"Registration service failure\", unwrappedException);\n          throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);\n        }\n      }\n    }\n\n    Metrics.counter(CODE_REQUESTED_COUNTER_NAME, Tags.of(\n            UserAgentTagUtil.getPlatformTag(userAgent),\n            Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),\n            Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),\n            Tag.of(VERIFICATION_TRANSPORT_TAG_NAME, verificationCodeRequest.transport().toString())))\n        .increment();\n\n    return buildResponse(resultSession, verificationSession);\n  }\n\n  @PUT\n  @Path(\"/session/{sessionId}/code\")\n  @Consumes(MediaType.APPLICATION_JSON)\n  @Produces(MediaType.APPLICATION_JSON)\n  @Operation(\n      summary = \"Submit a verification code\",\n      description = \"\"\"\n          Submits a verification code received via SMS or voice for verification\n          \"\"\")\n  @ApiResponse(responseCode = \"200\", description = \"\"\"\n      The request to check a verification code was processed (though the submitted code may not be the correct code);\n      the session metadata will indicate whether the submitted code was correct\n      \"\"\", useReturnTypeSchema = true)\n  @ApiResponse(responseCode = \"400\", description = \"Invalid session ID or verification  code\")\n  @ApiResponse(responseCode = \"404\", description = \"Session with the specified ID could not be found\")\n  @ApiResponse(responseCode = \"409\", description = \"The session is already verified or no code has been requested yet for this session\",\n      content = @Content(schema = @Schema(implementation = VerificationSessionResponse.class)))\n  @ApiResponse(responseCode = \"429\", description = \"\"\"\n      Too many attempts; the caller is not permitted to submit a verification code at this time and may need to wait\n      before trying again; if the session metadata does not specify a time at which the caller may try again, then the\n      caller has exhausted their permitted attempts and must create a new verification session.\n      \"\"\",\n      content = @Content(schema = @Schema(implementation = VerificationSessionResponse.class)),\n      headers = @Header(\n          name = \"Retry-After\",\n          description = \"If present, an positive integer indicating the number of seconds before a subsequent attempt could succeed\",\n          schema = @Schema(implementation = Integer.class)))\n  public VerificationSessionResponse verifyCode(@PathParam(\"sessionId\") final String encodedSessionId,\n      @HeaderParam(HttpHeaders.USER_AGENT) final String userAgent,\n      @NotNull @Valid final SubmitVerificationCodeRequest submitVerificationCodeRequest)\n      throws RateLimitExceededException {\n\n    final RegistrationServiceSession registrationServiceSession = retrieveRegistrationServiceSession(encodedSessionId);\n    final VerificationSession verificationSession = retrieveVerificationSession(registrationServiceSession);\n\n    if (registrationServiceSession.verified()) {\n      final VerificationSessionResponse verificationSessionResponse = buildResponse(registrationServiceSession,\n          verificationSession);\n\n      throw new ClientErrorException(\n          Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build());\n    }\n\n    final RegistrationServiceSession resultSession;\n    try {\n      resultSession = registrationServiceClient.checkVerificationCode(registrationServiceSession.id(),\n              submitVerificationCodeRequest.code(),\n              REGISTRATION_RPC_TIMEOUT)\n          .join();\n    } catch (final CancellationException e) {\n      logger.warn(\"Unexpected cancellation from registration service\", e);\n      throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE);\n    } catch (final CompletionException e) {\n      final Throwable unwrappedException = ExceptionUtils.unwrap(e);\n      if (unwrappedException instanceof RateLimitExceededException rateLimitExceededException) {\n\n        if (rateLimitExceededException instanceof VerificationSessionRateLimitExceededException ve) {\n          final Response response = buildResponseForRateLimitExceeded(verificationSession, ve.getRegistrationSession(),\n              ve.getRetryDuration());\n          throw new ClientErrorException(response);\n        }\n\n        throw new RateLimitExceededException(rateLimitExceededException.getRetryDuration().orElse(null));\n\n      } else if (unwrappedException instanceof RegistrationServiceException registrationServiceException) {\n\n        throw registrationServiceException.getRegistrationSession()\n            .map(s -> buildResponse(s, verificationSession))\n            .map(verificationSessionResponse -> new ClientErrorException(\n                Response.status(Response.Status.CONFLICT).entity(verificationSessionResponse).build()))\n            .orElseGet(NotFoundException::new);\n\n      } else {\n        logger.error(\"Registration service failure\", unwrappedException);\n        throw new ServerErrorException(Response.Status.INTERNAL_SERVER_ERROR);\n      }\n    }\n\n    boolean existingRRP = false;\n    if (resultSession.verified()) {\n      existingRRP = registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join()).join();\n    }\n\n    Optional<Account> maybeExistingAccount;\n    try {\n      maybeExistingAccount = accountsManager.getByE164(registrationServiceSession.number());\n    } catch (RuntimeException e) {\n      // Only for metrics, so it's ok if we fail to lookup the account\n      maybeExistingAccount = Optional.empty();\n    }\n\n    Tags tags = Tags.of(\n        UserAgentTagUtil.getPlatformTag(userAgent),\n        Tag.of(COUNTRY_CODE_TAG_NAME, Util.getCountryCode(registrationServiceSession.number())),\n        Tag.of(REGION_CODE_TAG_NAME, Util.getRegion(registrationServiceSession.number())),\n        Tag.of(SUCCESS_TAG_NAME, Boolean.toString(resultSession.verified())),\n        Tag.of(RECOVERY_PASSWORD_REMOVED_TAG_NAME, Boolean.toString(existingRRP)),\n        Tag.of(REREGISTRATION_TAG_NAME, Boolean.toString(maybeExistingAccount.isPresent())));\n\n    if (maybeExistingAccount.isPresent()) {\n      final Account existingAccount = maybeExistingAccount.get();\n      final Duration timeSinceLastSeen =\n          Duration.between(Instant.ofEpochMilli(existingAccount.getLastSeen()), clock.instant());\n      // Clients may reuse recent SVR2 credentials to authenticate via recovery password instead of SMS verification.\n      // If this is a re-registering account, check if the client could possibly have an unexpired SVR2 credential.\n      final boolean recentlySeen = timeSinceLastSeen.compareTo(SecureValueRecovery2Controller.MAX_AGE) < 0;\n      final String existingPlatform = DevicePlatformUtil\n          .getDevicePlatform(existingAccount.getPrimaryDevice())\n          .map(ClientPlatform::name).orElse(\"unknown\");\n      tags = tags\n          .and(EXISTING_ACCOUNT_PLATFORM, existingPlatform)\n          .and(EXISTING_ACCOUNT_RECENTLY_SEEN_TAG_NAME, Boolean.toString(recentlySeen));\n    }\n\n    Metrics.counter(VERIFIED_COUNTER_NAME, tags).increment();\n\n    return buildResponse(resultSession, verificationSession);\n  }\n\n  private Response buildResponseForRateLimitExceeded(final VerificationSession verificationSession,\n      final RegistrationServiceSession registrationServiceSession,\n      final Optional<Duration> retryDuration) {\n\n    final Response.ResponseBuilder responseBuilder = Response.status(Response.Status.TOO_MANY_REQUESTS)\n        .entity(buildResponse(registrationServiceSession, verificationSession));\n\n    retryDuration\n        .filter(d -> !d.isNegative())\n        .ifPresent(d -> responseBuilder.header(HttpHeaders.RETRY_AFTER, d.toSeconds()));\n\n    return responseBuilder.build();\n  }\n\n  /**\n   * @throws ClientErrorException with {@code 422} status if the ID cannot be decoded\n   * @throws NotFoundException    if the ID cannot be found\n   */\n  private RegistrationServiceSession retrieveRegistrationServiceSession(final String encodedSessionId) {\n    final byte[] sessionId;\n\n    try {\n      sessionId = decodeSessionId(encodedSessionId);\n    } catch (final IllegalArgumentException e) {\n      throw new ClientErrorException(\"Malformed session ID\", HttpStatus.SC_UNPROCESSABLE_ENTITY);\n    }\n\n    try {\n      final RegistrationServiceSession registrationServiceSession = registrationServiceClient.getSession(sessionId,\n              REGISTRATION_RPC_TIMEOUT).join()\n          .orElseThrow(NotFoundException::new);\n\n      if (registrationServiceSession.verified()) {\n        registrationRecoveryPasswordsManager.remove(phoneNumberIdentifiers.getPhoneNumberIdentifier(registrationServiceSession.number()).join());\n      }\n\n      return registrationServiceSession;\n\n    } catch (final CompletionException | CancellationException e) {\n      final Throwable unwrapped = ExceptionUtils.unwrap(e);\n\n      if (unwrapped instanceof StatusRuntimeException grpcRuntimeException) {\n        if (grpcRuntimeException.getStatus().getCode() == Status.Code.INVALID_ARGUMENT) {\n          throw new BadRequestException();\n        }\n      }\n      logger.error(\"Registration service failure\", e);\n      throw new ServerErrorException(Response.Status.SERVICE_UNAVAILABLE, e);\n    }\n  }\n\n  /**\n   * @throws NotFoundException if the session has no record\n   */\n  private VerificationSession retrieveVerificationSession(final RegistrationServiceSession registrationServiceSession) {\n\n    return verificationSessionManager.findForId(registrationServiceSession.encodedSessionId())\n        .orTimeout(5, TimeUnit.SECONDS)\n        .join().orElseThrow(NotFoundException::new);\n  }\n\n  /**\n   * @throws ClientErrorException with {@code 422} status if the only one of token and type are present\n   */\n  private Pair<String, PushNotification.TokenType> validateAndExtractPushToken(\n      final UpdateVerificationSessionRequest request) {\n\n    final String pushToken;\n    final PushNotification.TokenType pushTokenType;\n    if (Objects.isNull(request.pushToken())\n        != Objects.isNull(request.pushTokenType())) {\n      throw new WebApplicationException(\"must specify both pushToken and pushTokenType or neither\",\n          HttpStatus.SC_UNPROCESSABLE_ENTITY);\n    } else {\n      pushToken = request.pushToken();\n      pushTokenType = pushToken == null\n          ? null\n          : request.pushTokenType().toTokenType();\n    }\n\n    return new Pair<>(pushToken, pushTokenType);\n  }\n\n  private VerificationSessionResponse buildResponse(final RegistrationServiceSession registrationServiceSession,\n      final VerificationSession verificationSession) {\n    return new VerificationSessionResponse(registrationServiceSession.encodedSessionId(),\n        registrationServiceSession.nextSms(),\n        registrationServiceSession.nextVoiceCall(), registrationServiceSession.nextVerificationAttempt(),\n        verificationSession.allowedToRequestCode(), verificationSession.requestedInformation(),\n        registrationServiceSession.verified());\n  }\n\n  public static byte[] decodeSessionId(final String sessionId) {\n    return Base64.getUrlDecoder().decode(sessionId);\n  }\n\n  private static String generatePushChallenge() {\n    final byte[] challenge = new byte[16];\n    RANDOM.nextBytes(challenge);\n\n    return HexFormat.of().formatHex(challenge);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/controllers/VerificationSessionRateLimitExceededException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport java.time.Duration;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\n\npublic class VerificationSessionRateLimitExceededException extends RateLimitExceededException {\n\n  private final RegistrationServiceSession registrationServiceSession;\n\n  /**\n   * Constructs a new exception indicating when it may become safe to retry\n   *\n   * @param registrationServiceSession the associated registration session\n   * @param retryDuration              A duration to wait before retrying, null if no duration can be indicated\n   * @param legacy                     whether to use a legacy status code when mapping the exception to an HTTP\n   *                                   response\n   */\n  public VerificationSessionRateLimitExceededException(\n      final RegistrationServiceSession registrationServiceSession, @Nullable final Duration retryDuration,\n      final boolean legacy) {\n    super(retryDuration);\n    this.registrationServiceSession = registrationServiceSession;\n  }\n\n  public RegistrationServiceSession getRegistrationSession() {\n    return registrationServiceSession;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/currency/CoinGeckoClient.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.currency;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.google.common.annotations.VisibleForTesting;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.Locale;\nimport java.util.Map;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class CoinGeckoClient {\n\n  private final HttpClient httpClient;\n  private final String apiKey;\n  private final Map<String, String> currencyIdsBySymbol;\n\n  private static final Logger logger = LoggerFactory.getLogger(CoinGeckoClient.class);\n\n  private static final TypeReference<Map<String, Map<String, BigDecimal>>> RESPONSE_TYPE = new TypeReference<>() {};\n\n  public CoinGeckoClient(final HttpClient httpClient, final String apiKey, final Map<String, String> currencyIdsBySymbol) {\n    this.httpClient = httpClient;\n    this.apiKey = apiKey;\n    this.currencyIdsBySymbol = currencyIdsBySymbol;\n  }\n\n  public BigDecimal getSpotPrice(final String currency, final String base) throws IOException {\n    if (!currencyIdsBySymbol.containsKey(currency)) {\n      throw new IllegalArgumentException(\"No currency ID found for \" + currency);\n    }\n\n    final URI quoteUri = URI.create(\n        String.format(\"https://pro-api.coingecko.com/api/v3/simple/price?ids=%s&vs_currencies=%s\",\n            currencyIdsBySymbol.get(currency), base.toLowerCase(Locale.ROOT)));\n\n    try {\n      final HttpResponse<String> response = httpClient.send(HttpRequest.newBuilder()\n              .GET()\n              .uri(quoteUri)\n              .header(\"Accept\", \"application/json\")\n              .header(\"x-cg-pro-api-key\", apiKey)\n              .timeout(Duration.ofSeconds(15))\n              .build(),\n          HttpResponse.BodyHandlers.ofString());\n\n      if (response.statusCode() < 200 || response.statusCode() >= 300) {\n        logger.warn(\"CoinGecko request failed with response: {}\", response);\n        throw new IOException(\"CoinGecko request failed with status code \" + response.statusCode());\n      }\n\n      return extractConversionRate(parseResponse(response.body()).get(currencyIdsBySymbol.get(currency)), base.toLowerCase(Locale.ROOT));\n    } catch (final InterruptedException e) {\n      throw new IOException(\"Interrupted while waiting for a response\", e);\n    }\n  }\n\n  @VisibleForTesting\n  static Map<String, Map<String,BigDecimal>> parseResponse(final String responseJson) throws JsonProcessingException {\n    return SystemMapper.jsonMapper().readValue(responseJson, RESPONSE_TYPE);\n  }\n\n  @VisibleForTesting\n  static BigDecimal extractConversionRate(final Map<String,BigDecimal> response, final String destinationCurrency)\n      throws IOException {\n    if (!response.containsKey(destinationCurrency)) {\n      throw new IOException(\"Response does not contain conversion rate for \" + destinationCurrency);\n    }\n\n    return response.get(destinationCurrency);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.currency;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.SetArgs;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\n\npublic class CurrencyConversionManager implements Managed {\n\n  private static final Logger logger = LoggerFactory.getLogger(CurrencyConversionManager.class);\n\n  private static final Duration FIXER_REFRESH_INTERVAL = Duration.ofMinutes(15);\n  @VisibleForTesting\n  static final String FIXER_SHARED_CACHE_CURRENT_KEY = \"CurrencyConversionManager::FixerCacheCurrent\";\n  private static final String FIXER_SHARED_CACHE_DATA_KEY = \"CurrencyConversionManager::FixerCacheData\";\n\n  private static final Duration COIN_GECKO_REFRESH_INTERVAL = Duration.ofMinutes(5);\n  @VisibleForTesting\n  static final String COIN_GECKO_SHARED_CACHE_CURRENT_KEY = \"CurrencyConversionManager::CoinGeckoCacheCurrent\";\n  private static final String COIN_GECKO_SHARED_CACHE_DATA_KEY = \"CurrencyConversionManager::CoinGeckoCacheData\";\n\n  private static final String CACHED_DATA_UPDATED_COUNTER_NAME = MetricsUtil.name(CurrencyConversionManager.class, \"cachedDataUpdate\");\n  private static final String SOURCE_TAG_NAME = \"source\";\n\n  private static final Counter CACHED_DATA_UPDATE_ERRORS_COUNTER = Metrics.counter(\n      MetricsUtil.name(CurrencyConversionManager.class, \"errors\"));\n\n  private final FixerClient fixerClient;\n\n  private final CoinGeckoClient coinGeckoClient;\n\n  private final FaultTolerantRedisClusterClient cacheCluster;\n\n  private final Clock clock;\n\n  private final List<String> currencies;\n\n  private final ScheduledExecutorService executor;\n\n  private final AtomicReference<CurrencyConversionEntityList> cached = new AtomicReference<>(null);\n\n  private Map<String, BigDecimal> cachedFixerValues;\n\n  private Map<String, BigDecimal> cachedCoinGeckoValues;\n\n  private ScheduledFuture<?> cacheUpdateFuture;\n\n  public CurrencyConversionManager(\n      final FixerClient fixerClient,\n      final CoinGeckoClient coinGeckoClient,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final List<String> currencies,\n      final ScheduledExecutorService executor,\n      final Clock clock) {\n    this.fixerClient = fixerClient;\n    this.coinGeckoClient = coinGeckoClient;\n    this.cacheCluster = cacheCluster;\n    this.currencies  = currencies;\n    this.executor = executor;\n    this.clock = clock;\n  }\n\n  public Optional<CurrencyConversionEntityList> getCurrencyConversions() {\n    return Optional.ofNullable(cached.get());\n  }\n\n  @Override\n  public void start() throws Exception {\n    cacheUpdateFuture = executor.scheduleWithFixedDelay(() -> {\n      try {\n        update();\n      } catch (Throwable t) {\n        CACHED_DATA_UPDATE_ERRORS_COUNTER.increment();\n        logger.warn(\"Error updating currency conversions\", t);\n      }\n    }, 0, 15, TimeUnit.SECONDS);\n  }\n\n  @Override\n  public void stop() throws Exception {\n    if (cacheUpdateFuture != null) {\n      cacheUpdateFuture.cancel(true);\n    }\n  }\n\n  @VisibleForTesting\n  void update() throws IOException {\n    updateFixerCacheIfNecessary();\n    updateCoinGeckoCacheIfNecessary();\n    updateEntity();\n  }\n\n  private void updateEntity() {\n    final List<CurrencyConversionEntity> entities = new ArrayList<>(cachedCoinGeckoValues.size());\n\n    for (Map.Entry<String, BigDecimal> currency : cachedCoinGeckoValues.entrySet()) {\n      final BigDecimal usdValue = stripTrailingZerosAfterDecimal(currency.getValue());\n\n      final Map<String, BigDecimal> values = new HashMap<>();\n      values.put(\"USD\", usdValue);\n\n      for (Map.Entry<String, BigDecimal> conversion : cachedFixerValues.entrySet()) {\n        values.put(conversion.getKey(), stripTrailingZerosAfterDecimal(conversion.getValue().multiply(usdValue)));\n      }\n\n      entities.add(new CurrencyConversionEntity(currency.getKey(), values));\n    }\n\n    this.cached.set(new CurrencyConversionEntityList(entities, clock.millis()));\n  }\n\n  private void updateFixerCacheIfNecessary() throws IOException {\n    {\n      final Map<String, BigDecimal> fixerValuesFromSharedCache = getCachedData(FIXER_SHARED_CACHE_DATA_KEY);\n\n      if (fixerValuesFromSharedCache != null && !fixerValuesFromSharedCache.isEmpty()) {\n        cachedFixerValues = fixerValuesFromSharedCache;\n      }\n    }\n\n    final boolean shouldUpdateSharedCache = shouldUpdateSharedCache(FIXER_SHARED_CACHE_CURRENT_KEY,\n        FIXER_REFRESH_INTERVAL);\n\n    if (shouldUpdateSharedCache || cachedFixerValues == null) {\n\n      cachedFixerValues = new HashMap<>(fixerClient.getConversionsForBase(\"USD\"));\n\n      if (shouldUpdateSharedCache) {\n        updateCachedData(FIXER_SHARED_CACHE_DATA_KEY, cachedFixerValues);\n        Metrics.counter(CACHED_DATA_UPDATED_COUNTER_NAME, SOURCE_TAG_NAME, \"fixer\").increment();\n      }\n    }\n  }\n\n  private void updateCoinGeckoCacheIfNecessary() throws IOException {\n    {\n      final Map<String, BigDecimal> coinGeckoValuesFromSharedCache = getCachedData(COIN_GECKO_SHARED_CACHE_DATA_KEY);\n\n      if (coinGeckoValuesFromSharedCache != null && !coinGeckoValuesFromSharedCache.isEmpty()) {\n        cachedCoinGeckoValues = coinGeckoValuesFromSharedCache;\n      }\n    }\n\n    final boolean shouldUpdateSharedCache = shouldUpdateSharedCache(COIN_GECKO_SHARED_CACHE_CURRENT_KEY,\n        COIN_GECKO_REFRESH_INTERVAL);\n\n    if (shouldUpdateSharedCache || cachedCoinGeckoValues == null) {\n      final Map<String, BigDecimal> conversionRatesFromCoinGecko = new HashMap<>(currencies.size());\n\n      for (final String currency : currencies) {\n        conversionRatesFromCoinGecko.put(currency, coinGeckoClient.getSpotPrice(currency, \"USD\"));\n      }\n\n      cachedCoinGeckoValues = conversionRatesFromCoinGecko;\n\n      if (shouldUpdateSharedCache) {\n        updateCachedData(COIN_GECKO_SHARED_CACHE_DATA_KEY, cachedCoinGeckoValues);\n        Metrics.counter(CACHED_DATA_UPDATED_COUNTER_NAME, SOURCE_TAG_NAME, \"coingecko\").increment();\n      }\n    }\n  }\n\n  private Map<String, BigDecimal> getCachedData(final String cacheKey) {\n    return cacheCluster.withCluster(connection -> {\n      final Map<String, BigDecimal> parsedSharedCacheData = new HashMap<>();\n\n      connection.sync().hgetall(cacheKey).forEach((currency, conversionRate) ->\n          parsedSharedCacheData.put(currency, new BigDecimal(conversionRate)));\n\n      return parsedSharedCacheData;\n    });\n  }\n\n  private boolean shouldUpdateSharedCache(final String cacheKey, final Duration interval) {\n    return cacheCluster.withCluster(connection ->\n        \"OK\".equals(connection.sync().set(cacheKey, \"true\", SetArgs.Builder.nx().ex(interval))));\n  }\n\n  private void updateCachedData(final String cacheKey, final Map<String, BigDecimal> data) {\n    cacheCluster.useCluster(connection -> {\n      final Map<String, String> sharedValues = new HashMap<>();\n\n      data.forEach((currency, conversionRate) -> sharedValues.put(currency, conversionRate.toString()));\n\n      connection.sync().hset(cacheKey, sharedValues);\n    });\n  }\n\n  private BigDecimal stripTrailingZerosAfterDecimal(BigDecimal bigDecimal) {\n    BigDecimal n = bigDecimal.stripTrailingZeros();\n    if (n.scale() < 0) {\n      return n.setScale(0);\n    } else {\n      return n;\n    }\n  }\n\n  @VisibleForTesting\n  void setCachedFixerValues(final Map<String, BigDecimal> cachedFixerValues) {\n    this.cachedFixerValues = cachedFixerValues;\n  }\n\n  public Optional<BigDecimal> convertToUsd(final BigDecimal amount, final String currency) {\n    if (\"USD\".equalsIgnoreCase(currency)) {\n      return Optional.of(amount);\n    }\n\n    return Optional.ofNullable(cachedFixerValues.get(currency.toUpperCase(Locale.ROOT)))\n        .map(conversionRate -> amount.divide(conversionRate, 2, RoundingMode.HALF_EVEN));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/currency/FixerClient.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.currency;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class FixerClient {\n\n  private final String apiKey;\n  private final HttpClient client;\n\n  public FixerClient(HttpClient client, String apiKey) {\n    this.apiKey = apiKey;\n    this.client = client;\n  }\n\n  public Map<String, BigDecimal> getConversionsForBase(String base) throws FixerException {\n    try {\n      final URI uri = URI.create(\"https://data.fixer.io/api/latest?access_key=\" + apiKey + \"&base=\" + base);\n\n      final HttpResponse<String> response = client.send(HttpRequest.newBuilder()\n              .GET()\n              .uri(uri)\n              .timeout(Duration.ofSeconds(15))\n              .build(),\n          HttpResponse.BodyHandlers.ofString());\n\n      if (response.statusCode() < 200 || response.statusCode() >= 300) {\n        throw new FixerException(\"Bad response: \" + response.statusCode() + \" \" + response.toString());\n      }\n\n      final FixerResponse parsedResponse = SystemMapper.jsonMapper().readValue(response.body(), FixerResponse.class);\n\n      if (parsedResponse.success) {\n        return parsedResponse.rates;\n      } else {\n        throw new FixerException(\"Got failed response!\");\n      }\n    } catch (IOException | InterruptedException e) {\n      throw new FixerException(e);\n    }\n  }\n\n  private static class FixerResponse {\n\n    @JsonProperty\n    private boolean success;\n\n    @JsonProperty\n    private long timestamp;\n\n    @JsonProperty\n    private String base;\n\n    @JsonProperty\n    private String date;\n\n    @JsonProperty\n    private Map<String, BigDecimal> rates;\n\n  }\n\n  public static class FixerException extends IOException {\n    public FixerException(String message) {\n      super(message);\n    }\n\n    public FixerException(Exception exception) {\n      super(exception);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountAttributes.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.whispersystems.textsecuregcm.util.RegistrationIdValidator.validRegistrationId;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.Size;\nimport java.util.Optional;\nimport java.util.Set;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic class AccountAttributes {\n\n  @JsonProperty\n  private boolean fetchesMessages;\n\n  @JsonProperty\n  private int registrationId;\n\n  @JsonProperty(\"pniRegistrationId\")\n  private int phoneNumberIdentityRegistrationId;\n\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n  @Size(max = 225)\n  private byte[] name;\n\n  @JsonProperty\n  @ExactlySize({0, 64})\n  private String registrationLock;\n\n  @JsonProperty\n  @ExactlySize({0, UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH})\n  private byte[] unidentifiedAccessKey;\n\n  @JsonProperty\n  private boolean unrestrictedUnidentifiedAccess;\n\n  @JsonProperty\n  @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class)\n  @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class)\n  @Nullable\n  private Set<DeviceCapability> capabilities;\n\n  @JsonProperty\n  private boolean discoverableByPhoneNumber = true;\n\n  @JsonProperty\n  @Nullable\n  @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n  @ExactlySize({0, 32})\n  private byte[] recoveryPassword = null;\n\n  public AccountAttributes() {\n  }\n\n  @VisibleForTesting\n  public AccountAttributes(\n      final boolean fetchesMessages,\n      final int registrationId,\n      final int phoneNumberIdentifierRegistrationId,\n      final byte[] name,\n      final String registrationLock,\n      final boolean discoverableByPhoneNumber,\n      final Set<DeviceCapability> capabilities) {\n    this.fetchesMessages = fetchesMessages;\n    this.registrationId = registrationId;\n    this.phoneNumberIdentityRegistrationId = phoneNumberIdentifierRegistrationId;\n    this.name = name;\n    this.registrationLock = registrationLock;\n    this.discoverableByPhoneNumber = discoverableByPhoneNumber;\n    this.capabilities = capabilities;\n  }\n\n  public boolean getFetchesMessages() {\n    return fetchesMessages;\n  }\n\n  public int getRegistrationId() {\n    return registrationId;\n  }\n\n  public int getPhoneNumberIdentityRegistrationId() {\n    return phoneNumberIdentityRegistrationId;\n  }\n\n  public byte[] getName() {\n    return name;\n  }\n\n  public String getRegistrationLock() {\n    return registrationLock;\n  }\n\n  public byte[] getUnidentifiedAccessKey() {\n    return unidentifiedAccessKey;\n  }\n\n  public boolean isUnrestrictedUnidentifiedAccess() {\n    return unrestrictedUnidentifiedAccess;\n  }\n\n  @Nullable\n  public Set<DeviceCapability> getCapabilities() {\n    return capabilities;\n  }\n\n  public boolean isDiscoverableByPhoneNumber() {\n    return discoverableByPhoneNumber;\n  }\n\n  public Optional<byte[]> recoveryPassword() {\n    return Optional.ofNullable(recoveryPassword);\n  }\n\n  @VisibleForTesting\n  public AccountAttributes withUnidentifiedAccessKey(final byte[] unidentifiedAccessKey) {\n    this.unidentifiedAccessKey = unidentifiedAccessKey;\n    return this;\n  }\n\n  @VisibleForTesting\n  public AccountAttributes withRecoveryPassword(final byte[] recoveryPassword) {\n    this.recoveryPassword = recoveryPassword;\n    return this;\n  }\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isEachRegistrationIdValid() {\n    return validRegistrationId(registrationId) && validRegistrationId(phoneNumberIdentityRegistrationId);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountCreationResponse.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonUnwrapped;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\npublic record AccountCreationResponse(\n\n    @JsonUnwrapped\n    AccountIdentityResponse identityResponse,\n\n    @Schema(description = \"If true, there was an existing account registered for this number\")\n    boolean reregistration) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountDataReportResponse.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport com.fasterxml.jackson.datatype.jsr310.ser.InstantSerializer;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\n\npublic record AccountDataReportResponse(UUID reportId,\n                                        @JsonSerialize(using = InstantSerializer.class)\n                                        @JsonFormat(pattern = DATE_FORMAT, timezone = UTC)\n                                        Instant reportTimestamp,\n                                        AccountAndDevicesDataReport data) {\n\n  private static final String DATE_FORMAT = \"yyyy-MM-dd'T'HH:mm:ss'Z'\";\n  private static final String UTC = \"Etc/UTC\";\n\n  @JsonProperty\n  @Schema(description = \"A plaintext representation of the data report\")\n  String text() {\n\n    final StringBuilder builder = new StringBuilder();\n\n    // header\n    builder.append(String.format(\"\"\"\n            Report ID: %s\n            Report timestamp: %s\n                          \n            \"\"\",\n        reportId,\n        reportTimestamp.truncatedTo(ChronoUnit.SECONDS)));\n\n    // account\n    builder.append(String.format(\"\"\"\n            # Account\n            Phone number: %s\n            Allow sealed sender from anyone: %s\n            Find account by phone number: %s\n            \"\"\",\n        data.account.phoneNumber(),\n        data.account.allowSealedSenderFromAnyone(),\n        data.account.findAccountByPhoneNumber()));\n\n    // badges\n    builder.append(\"Badges:\");\n\n    if (data.account.badges().isEmpty()) {\n      builder.append(\" None\\n\");\n    } else {\n      builder.append(\"\\n\");\n      data.account.badges().forEach(badgeDataReport -> builder.append(String.format(\"\"\"\n              - ID: %s\n                Expiration: %s\n                Visible: %s\n              \"\"\",\n          badgeDataReport.id(),\n          badgeDataReport.expiration().truncatedTo(ChronoUnit.SECONDS),\n          badgeDataReport.visible())));\n    }\n\n    // devices\n    builder.append(\"\\n# Devices\\n\");\n\n    data.devices().forEach(deviceDataReport ->\n        builder.append(String.format(\"\"\"\n                - ID: %s\n                  Created: %s\n                  Last seen: %s\n                  User-agent: %s\n                \"\"\",\n            deviceDataReport.id(),\n            deviceDataReport.created().truncatedTo(ChronoUnit.SECONDS),\n            deviceDataReport.lastSeen().truncatedTo(ChronoUnit.SECONDS),\n            deviceDataReport.userAgent())));\n\n    return builder.toString();\n  }\n\n\n  public record AccountAndDevicesDataReport(AccountDataReport account,\n                                            List<DeviceDataReport> devices) {\n\n  }\n\n  public record AccountDataReport(String phoneNumber, List<BadgeDataReport> badges, boolean allowSealedSenderFromAnyone,\n                                  boolean findAccountByPhoneNumber) {\n\n  }\n\n  public record DeviceDataReport(byte id,\n                                 @JsonFormat(pattern = DATE_FORMAT, timezone = UTC)\n                                 Instant lastSeen,\n                                 @JsonFormat(pattern = DATE_FORMAT, timezone = UTC)\n                                 Instant created,\n                                 @Nullable String userAgent) {\n\n\n  }\n\n  public record BadgeDataReport(String id,\n                                @JsonFormat(pattern = DATE_FORMAT, timezone = UTC)\n                                Instant expiration,\n                                boolean visible) {\n\n    public BadgeDataReport(AccountBadge badge) {\n      this(badge.id(), badge.expiration(), badge.visible());\n    }\n\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentifierResponse.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record AccountIdentifierResponse(@NotNull\n                                        @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n                                        @JsonDeserialize(using = ServiceIdentifierAdapter.AciServiceIdentifierDeserializer.class)\n                                        AciServiceIdentifier uuid) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountIdentityResponse.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\n\npublic record AccountIdentityResponse(\n    @Schema(description = \"the account identifier for this account\")\n    UUID uuid,\n\n    @Schema(description = \"the phone number associated with this account\")\n    String number,\n\n    @Schema(description = \"the account identifier for this account's phone-number identity\")\n    UUID pni,\n\n    @Schema(description = \"a hash of this account's username, if set\")\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @Nullable byte[] usernameHash,\n\n    @Schema(description = \"this account's username link handle, if set\")\n    @Nullable UUID usernameLinkHandle,\n\n    @Schema(description = \"whether any of this account's devices support storage\")\n    boolean storageCapable,\n\n    @Schema(description = \"entitlements for this account and their current expirations\")\n    Entitlements entitlements) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountMismatchedDevices.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record AccountMismatchedDevices(@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n                                       @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n                                       ServiceIdentifier uuid,\n\n                                       MismatchedDevicesResponse devices) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AccountStaleDevices.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record AccountStaleDevices(@JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n                                  @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n                                  ServiceIdentifier uuid,\n\n                                  StaleDevicesResponse devices) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerCaptchaChallengeRequest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\n\npublic class AnswerCaptchaChallengeRequest extends AnswerChallengeRequest {\n\n  @Schema(description = \"The value of the token field from the server's 428 response\")\n  @NotBlank\n  private String token;\n\n  @Schema(\n      description = \"A string representing a solved captcha\",\n      example = \"signal-hcaptcha.30b01b46-d8c9-4c30-bbd7-9719acfe0c10.challenge.abcdefg1345\")\n  @NotBlank\n  private String captcha;\n\n  public String getToken() {\n    return token;\n  }\n\n  public String getCaptcha() {\n    return captcha;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonSubTypes;\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = \"type\")\n@JsonSubTypes({\n    @JsonSubTypes.Type(value = AnswerPushChallengeRequest.class, name = \"rateLimitPushChallenge\"),\n    @JsonSubTypes.Type(value = AnswerCaptchaChallengeRequest.class, name = \"captcha\")\n})\npublic abstract class AnswerChallengeRequest {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AnswerPushChallengeRequest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\n\npublic class AnswerPushChallengeRequest extends AnswerChallengeRequest {\n\n  @Schema(description = \"A token provided to the client via a push payload\")\n  @NotBlank\n  private String challenge;\n\n  public String getChallenge() {\n    return challenge;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ApnRegistrationId.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport jakarta.validation.constraints.NotEmpty;\n\npublic record ApnRegistrationId(@NotEmpty String apnRegistrationId) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AttachmentDescriptorV3.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.Map;\n\npublic record AttachmentDescriptorV3(\n    @Schema(description = \"\"\"\n        Indicates the CDN type. 2 in the v3 API, 2 or 3 in the v4 API.\n        2 indicates resumable uploads using GCS,\n        3 indicates resumable uploads using TUS\n        \"\"\")\n    int cdn,\n    @Schema(description = \"The location within the specified cdn where the finished upload can be found\")\n    String key,\n    @Schema(description = \"A map of headers to include with all upload requests. Potentially contains time-limited upload credentials\")\n    Map<String, String> headers,\n\n    @Schema(description = \"The URL to upload to with the appropriate protocol\")\n    String signedUploadLocation) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonAlias;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.List;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport org.whispersystems.textsecuregcm.util.E164;\n\npublic record AuthCheckRequest(@Schema(description = \"The e164-formatted phone number.\")\n                               @NotNull @E164 String number,\n                               @Schema(description = \"\"\"\n                               A list of SVR tokens, previously retrieved from `backup/auth`. Tokens should be the\n                               of the form \"username:password\". May contain at most 10 tokens.\"\"\")\n                               @JsonProperty(\"tokens\")\n                               @JsonAlias(\"passwords\") // deprecated\n                               @NotEmpty @Size(max = 10) List<String> tokens) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AuthCheckResponseV2.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonValue;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.Map;\nimport jakarta.validation.constraints.NotNull;\n\npublic record AuthCheckResponseV2(@Schema(description = \"A dictionary with the auth check results: `SVR Credentials -> 'match'/'no-match'/'invalid'`\")\n                                  @NotNull Map<String, Result> matches) {\n\n  public enum Result {\n    MATCH(\"match\"),\n    NO_MATCH(\"no-match\"),\n    INVALID(\"invalid\");\n\n    private final String clientCode;\n\n    Result(final String clientCode) {\n      this.clientCode = clientCode;\n    }\n\n    @JsonValue\n    public String clientCode() {\n      return clientCode;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/AvatarChange.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\npublic enum AvatarChange {\n  AVATAR_CHANGE_UNCHANGED,\n  AVATAR_CHANGE_CLEAR,\n  AVATAR_CHANGE_UPDATE\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/Badge.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.base.Strings;\nimport java.util.List;\nimport java.util.Objects;\n\npublic class Badge {\n  private final String id;\n  private final String category;\n  private final String name;\n  private final String description;\n  private final List<String> sprites6;\n  private final String svg;\n  private final List<BadgeSvg> svgs;\n\n  @JsonCreator\n  public Badge(\n      @JsonProperty(\"id\") final String id,\n      @JsonProperty(\"category\") final String category,\n      @JsonProperty(\"name\") final String name,\n      @JsonProperty(\"description\") final String description,\n      @JsonProperty(\"sprites6\") final List<String> sprites6,\n      @JsonProperty(\"svg\") final String svg,\n      @JsonProperty(\"svgs\") final List<BadgeSvg> svgs) {\n    this.id = id;\n    this.category = category;\n    this.name = name;\n    this.description = description;\n    this.sprites6 = Objects.requireNonNull(sprites6);\n    if (sprites6.size() != 6) {\n      throw new IllegalArgumentException(\"sprites must have size 6\");\n    }\n    if (Strings.isNullOrEmpty(svg)) {\n      throw new IllegalArgumentException(\"svg cannot be empty\");\n    }\n    this.svg = svg;\n    this.svgs = Objects.requireNonNull(svgs);\n  }\n\n  public String getId() {\n    return id;\n  }\n\n  public String getCategory() {\n    return category;\n  }\n\n  public String getName() {\n    return name;\n  }\n\n  public String getDescription() {\n    return description;\n  }\n\n  public List<String> getSprites6() {\n    return sprites6;\n  }\n\n  public String getSvg() {\n    return svg;\n  }\n\n  public List<BadgeSvg> getSvgs() {\n    return svgs;\n  }\n\n  /**\n   * Workaround for old Android builds that expect this field to exist but don't care it's an empty string.\n   */\n  @Deprecated\n  @JsonProperty\n  public String getImageUrl() {\n    return \"\";\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n    Badge badge = (Badge) o;\n    return Objects.equals(id, badge.id)\n        && Objects.equals(category, badge.category)\n        && Objects.equals(name, badge.name)\n        && Objects.equals(description, badge.description)\n        && Objects.equals(sprites6, badge.sprites6)\n        && Objects.equals(svg, badge.svg)\n        && Objects.equals(svgs, badge.svgs);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(id, category, name, description, sprites6, svg, svgs);\n  }\n\n  @Override\n  public String toString() {\n    return \"Badge{\" +\n        \"id='\" + id + '\\'' +\n        \", category='\" + category + '\\'' +\n        \", name='\" + name + '\\'' +\n        \", description='\" + description + '\\'' +\n        \", sprites6=\" + sprites6 +\n        \", svg='\" + svg + '\\'' +\n        \", svgs=\" + svgs +\n        '}';\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/BadgeSvg.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.base.Strings;\nimport java.util.Objects;\nimport jakarta.validation.constraints.NotEmpty;\n\npublic class BadgeSvg {\n  private final String light;\n  private final String dark;\n\n  @JsonCreator\n  public BadgeSvg(\n      @JsonProperty(\"light\") final String light,\n      @JsonProperty(\"dark\") final String dark) {\n    if (Strings.isNullOrEmpty(light)) {\n      throw new IllegalArgumentException(\"light cannot be empty\");\n    }\n    this.light = light;\n    if (Strings.isNullOrEmpty(dark)) {\n      throw new IllegalArgumentException(\"dark cannot be empty\");\n    }\n    this.dark = dark;\n  }\n\n  @NotEmpty\n  public String getLight() {\n    return light;\n  }\n\n  @NotEmpty\n  public String getDark() {\n    return dark;\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n    BadgeSvg badgeSvg = (BadgeSvg) o;\n    return Objects.equals(light, badgeSvg.light)\n        && Objects.equals(dark, badgeSvg.dark);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(light, dark);\n  }\n\n  @Override\n  public String toString() {\n    return \"BadgeSvg{\" +\n        \"light='\" + light + '\\'' +\n        \", dark='\" + dark + '\\'' +\n        '}';\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/BaseProfileResponse.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\n\nimport java.util.List;\nimport java.util.Map;\n\n@Schema(description = \"Unversioned profile containing basic information\")\npublic class BaseProfileResponse {\n\n  @Schema(description = \"The account's public identity key (unpadded base64)\")\n  @JsonProperty\n  @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n  @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n  private IdentityKey identityKey;\n\n  @Schema(description = \"Checksum for unidentified access authentication (padded base64)\")\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n  private byte[] unidentifiedAccess;\n\n  @Schema(description = \"Whether unidentified access is unrestricted for this account\")\n  @JsonProperty\n  private boolean unrestrictedUnidentifiedAccess;\n\n  @Schema(description = \"Device capabilities and whether they are enabled\")\n  @JsonProperty\n  private Map<String, Boolean> capabilities;\n\n  @Schema(description = \"List of badges displayed on the profile\")\n  @JsonProperty\n  private List<Badge> badges;\n\n  @Schema(description = \"Service identifier (ACI or PNI)\")\n  @JsonProperty\n  @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n  @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n  private ServiceIdentifier uuid;\n\n  public BaseProfileResponse() {\n  }\n\n  public BaseProfileResponse(final IdentityKey identityKey,\n      final byte[] unidentifiedAccess,\n      final boolean unrestrictedUnidentifiedAccess,\n      final Map<String, Boolean> capabilities,\n      final List<Badge> badges,\n      final ServiceIdentifier uuid) {\n\n    this.identityKey = identityKey;\n    this.unidentifiedAccess = unidentifiedAccess;\n    this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;\n    this.capabilities = capabilities;\n    this.badges = badges;\n    this.uuid = uuid;\n  }\n\n  public IdentityKey getIdentityKey() {\n    return identityKey;\n  }\n\n  public byte[] getUnidentifiedAccess() {\n    return unidentifiedAccess;\n  }\n\n  public boolean isUnrestrictedUnidentifiedAccess() {\n    return unrestrictedUnidentifiedAccess;\n  }\n\n  public Map<String, Boolean> getCapabilities() {\n    return capabilities;\n  }\n\n  public List<Badge> getBadges() {\n    return badges;\n  }\n\n  public ServiceIdentifier getUuid() {\n    return uuid;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckRequest.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\n@Schema(description = \"Request to check identity key fingerprints for multiple accounts\")\npublic record BatchIdentityCheckRequest(\n    @Schema(description = \"List of accounts to check\")\n    @Valid @NotNull @Size(max = 1000) List<Element> elements) {\n\n  /**\n   * @param uuid        account id or phone number id\n   * @param fingerprint most significant 4 bytes of SHA-256 of the 33-byte identity key field (32-byte curve25519 public\n   *                    key prefixed with 0x05)\n   */\n  @Schema(description = \"A service identifier and expected identity key fingerprint\")\n  public record Element(\n      @Schema(description = \"Identifier (ACI or PNI)\")\n      @NotNull\n      @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n      @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n      ServiceIdentifier uuid,\n\n      @Schema(description = \"Expected identity key fingerprint (4 bytes, most significant bytes of SHA-256 hash of 33-byte identity key field)\")\n      @NotNull\n      @ExactlySize(4)\n      byte[] fingerprint) {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/BatchIdentityCheckResponse.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.List;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\n@Schema(description = \"Response containing accounts with mismatched identity key fingerprints\")\npublic record BatchIdentityCheckResponse(\n    @Schema(description = \"List of accounts where fingerprint did not match (empty if all matched)\")\n    @Valid List<Element> elements) {\n\n  @Schema(description = \"An account with a mismatched identity key fingerprint\")\n  public record Element(\n      @Schema(description = \"Service identifier (ACI or PNI)\")\n      @JsonInclude(JsonInclude.Include.NON_EMPTY)\n      @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n      @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n      @NotNull\n      ServiceIdentifier uuid,\n\n      @Schema(description = \"The actual identity key for this account\")\n      @NotNull\n      @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n      @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n      IdentityKey identityKey) {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ChangeNumberRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.ArraySchema;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport javax.annotation.Nullable;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.E164;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\nimport org.whispersystems.textsecuregcm.util.RegistrationIdValidator;\n\npublic record ChangeNumberRequest(\n    @Schema(description=\"\"\"\n        A session ID from registration service, if using session id to authenticate this request.\n        Must not be combined with `recoveryPassword`.\"\"\")\n    String sessionId,\n\n    @Schema(type=\"string\", description=\"\"\"\n        The base64-encoded recovery password for the new phone number, if using a recovery password to authenticate this request.\n        Must not be combined with `sessionId`.\"\"\")\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class) byte[] recoveryPassword,\n\n    @E164\n    @Schema(description=\"the new phone number for this account\")\n    @NotBlank String number,\n\n    @Schema(description=\"the registration lock password for the new phone number, if necessary\")\n    @JsonProperty(\"reglock\") @Nullable String registrationLock,\n\n    @Schema(description=\"the new public identity key to use for the phone-number identity associated with the new phone number\")\n    @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n    @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n    @NotNull IdentityKey pniIdentityKey,\n\n    @ArraySchema(\n        arraySchema=@Schema(description=\"\"\"\n        A list of synchronization messages to send to companion devices to supply the private keysManager\n        associated with the new identity key and their new prekeys.\n        Exactly one message must be supplied for each device other than the sending (primary) device.\"\"\"))\n    @NotNull @Valid List<@NotNull @Valid IncomingMessage> deviceMessages,\n\n    @Schema(description=\"\"\"\n        A new signed elliptic-curve prekey for each device on the account, including this one.\n        Each must be accompanied by a valid signature from the new identity key in this request.\"\"\")\n    @NotNull @NotEmpty @Valid Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,\n\n    @Schema(description=\"\"\"\n        A new signed post-quantum last-resort prekey for each device on the account, including this one.\n        Each must be accompanied by a valid signature from the new identity key in this request.\"\"\")\n    @NotNull @NotEmpty @Valid Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,\n\n    @Schema(description=\"the new phone-number-identity registration ID for each device on the account, including this one\")\n    @NotNull @NotEmpty Map<Byte, Integer> pniRegistrationIds) implements PhoneVerificationRequest {\n\n  public boolean isSignatureValidOnEachSignedPreKey(@Nullable final String userAgent) {\n    final List<SignedPreKey<?>> spks = new ArrayList<>(devicePniSignedPrekeys.values());\n    spks.addAll(devicePniPqLastResortPrekeys.values());\n\n    return PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, spks, userAgent, \"change-number\");\n  }\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isEachPniRegistrationIdValid() {\n    return pniRegistrationIds == null || pniRegistrationIds.values().stream().allMatch(RegistrationIdValidator::validRegistrationId);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/CheckKeysRequest.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic record CheckKeysRequest(\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n        The identity type for which to check for a shared view of repeated-use keys\n        \"\"\")\n    IdentityType identityType,\n\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @ExactlySize(32)\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n        A 32-byte digest of the client's repeated-use keys for the given identity type. The digest is calculated as:\n              \n        SHA256(identityKeyBytes || signedEcPreKeyId || signedEcPreKeyIdBytes || lastResortKeyId || lastResortKeyBytes)\n              \n        …where the elements of the hash are:\n              \n        - identityKeyBytes: the serialized form of the client's public identity key as produced by libsignal (i.e. one\n          version byte followed by 32 bytes of key material for a total of 33 bytes)\n        - signedEcPreKeyId: an 8-byte, big-endian representation of the ID of the client's signed EC pre-key\n        - signedEcPreKeyBytes: the serialized form of the client's signed EC pre-key as produced by libsignal (i.e. one\n          version byte followed by 32 bytes of key material for a total of 33 bytes)\n        - lastResortKeyId: an 8-byte, big-endian representation of the ID of the client's last-resort Kyber key\n        - lastResortKeyBytes: the serialized form of the client's last-resort Kyber key as produced by libsignal (i.e.\n          one version byte followed by 1568 bytes of key material for a total of 1569 bytes)\n          \"\"\")\n    byte[] digest) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ConfirmUsernameHashRequest.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport javax.annotation.Nullable;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\n\npublic record ConfirmUsernameHashRequest(\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @ExactlySize(AccountController.USERNAME_HASH_LENGTH)\n    byte[] usernameHash,\n\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @NotNull\n    byte[] zkProof,\n\n    @Schema(type = \"string\", description = \"The url-safe base64-encoded encrypted username to be stored for username links\")\n    @Nullable\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @Size(min = 1, max = AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH)\n    byte[] encryptedUsername\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateCallLinkCredential.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\npublic record CreateCallLinkCredential(byte[] credential, long redemptionTime){}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateProfileRequest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.List;\nimport java.util.Optional;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport org.whispersystems.textsecuregcm.util.ValidHexString;\n\n@Schema(description = \"Request to create or update a versioned profile\")\npublic record CreateProfileRequest(\n  @Schema(description = \"Profile key commitment\")\n  @JsonProperty\n  @NotNull\n  @JsonDeserialize(using = ProfileKeyCommitmentAdapter.Deserializing.class)\n  @JsonSerialize(using = ProfileKeyCommitmentAdapter.Serializing.class)\n  ProfileKeyCommitment commitment,\n\n  @Schema(description = \"Profile version identifier (hex-encoding of the public key)\")\n  @JsonProperty\n  @NotEmpty\n  @ValidHexString\n  @ExactlySize({64})\n  String version,\n\n  @Schema(description = \"Encrypted profile name. Padded base64.\")\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n  @ExactlySize({81, 285})\n  byte[] name,\n\n  @Schema(description = \"Encrypted about emoji. Padded base64.\")\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n  @ExactlySize({0, 60})\n  byte[] aboutEmoji,\n\n  @Schema(description = \"Encrypted about text. Padded base64.\")\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n  @ExactlySize({0, 156, 282, 540})\n  byte[] about,\n\n  @Schema(description = \"Encrypted payment address. Padded base64.\")\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n  @ExactlySize({0, 582})\n  byte[] paymentAddress,\n\n  @Schema(description = \"Whether the profile has an avatar\")\n  @JsonProperty(\"avatar\")\n  boolean hasAvatar,\n\n  @Schema(description = \"Whether the avatar is unchanged from the previous version\")\n  @JsonProperty\n  boolean sameAvatar,\n\n  @Schema(description = \"List of badge IDs to display on the profile\")\n  @JsonProperty(\"badgeIds\")\n  Optional<List<String>> badges,\n\n  @Schema(description = \"Encrypted phone number sharing preference. Padded base64.\")\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n  @ExactlySize({0, 29})\n  byte[] phoneNumberSharing\n) {\n\n  public enum AvatarChange {\n    UNCHANGED,\n    CLEAR,\n    UPDATE;\n  }\n\n  public AvatarChange getAvatarChange() {\n    if (!hasAvatar()) {\n      return AvatarChange.CLEAR;\n    }\n    if (!sameAvatar) {\n      return AvatarChange.UPDATE;\n    }\n    return AvatarChange.UNCHANGED;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/CreateVerificationSessionRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonUnwrapped;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport org.whispersystems.textsecuregcm.util.E164;\n\npublic record CreateVerificationSessionRequest(\n\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"The e164-formatted phone number to be verified\")\n    @E164\n    @NotBlank\n    @JsonProperty\n    String number,\n\n\n    @Valid\n    @JsonUnwrapped\n    UpdateVerificationSessionRequest updateVerificationSessionRequest) {\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntity.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport java.math.BigDecimal;\nimport java.util.Map;\n\npublic class CurrencyConversionEntity {\n\n  @JsonProperty\n  private String base;\n\n  @JsonProperty\n  private Map<String, BigDecimal> conversions;\n\n  public CurrencyConversionEntity(String base, Map<String, BigDecimal> conversions) {\n    this.base        = base;\n    this.conversions = conversions;\n  }\n\n  public CurrencyConversionEntity() {}\n\n  public String getBase() {\n    return base;\n  }\n\n  public Map<String, BigDecimal> getConversions() {\n    return conversions;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/CurrencyConversionEntityList.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport java.util.List;\n\npublic class CurrencyConversionEntityList {\n\n  @JsonProperty\n  private List<CurrencyConversionEntity> currencies;\n\n  @JsonProperty\n  private long timestamp;\n\n  public CurrencyConversionEntityList(List<CurrencyConversionEntity> currencies, long timestamp) {\n    this.currencies = currencies;\n    this.timestamp  = timestamp;\n  }\n\n  public CurrencyConversionEntityList() {}\n\n  public List<CurrencyConversionEntity> getCurrencies() {\n    return currencies;\n  }\n\n  public long getTimestamp() {\n    return timestamp;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/DeliveryCertificate.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\npublic class DeliveryCertificate {\n\n  private final byte[] certificate;\n\n  @JsonCreator\n  public DeliveryCertificate(\n      @JsonProperty(\"certificate\") byte[] certificate) {\n    this.certificate = certificate;\n  }\n\n  public byte[] getCertificate() {\n    return certificate;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceActivationRequest.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\n\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\n\nimport java.util.Optional;\n\npublic record DeviceActivationRequest(\n    @NotNull\n    @Valid\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n        A signed EC pre-key to be associated with this account's ACI.\n        \"\"\")\n    ECSignedPreKey aciSignedPreKey,\n\n    @NotNull\n    @Valid\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n        A signed EC pre-key to be associated with this account's PNI.\n        \"\"\")\n    ECSignedPreKey pniSignedPreKey,\n\n    @NotNull\n    @Valid\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n        A signed Kyber-1024 \"last resort\" pre-key to be associated with this account's ACI.\n        \"\"\")\n    KEMSignedPreKey aciPqLastResortPreKey,\n\n    @NotNull\n    @Valid\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n        A signed Kyber-1024 \"last resort\" pre-key to be associated with this account's PNI.\n        \"\"\")\n    KEMSignedPreKey pniPqLastResortPreKey,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"\"\"\n        An APNs token set for the account's primary device. If provided, the account's primary\n        device will be notified of new messages via push notifications to the given token.\n        Callers must provide exactly one of an APNs token set, an FCM token, or an\n        `AccountAttributes` entity with `fetchesMessages` set to `true`.\n        \"\"\")\n    Optional<@Valid ApnRegistrationId> apnToken,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"\"\"\n        An FCM/GCM token for the account's primary device. If provided, the account's primary\n        device will be notified of new messages via push notifications to the given token.\n        Callers must provide exactly one of an APNs token set, an FCM token, or an\n        `AccountAttributes` entity with `fetchesMessages` set to `true`.\n        \"\"\")\n    Optional<@Valid GcmRegistrationId> gcmToken) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfo.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;\n\npublic record DeviceInfo(long id,\n\n                         @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n                         @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n                         byte[] name,\n\n                         long lastSeen,\n\n                         @Schema(description = \"The registration ID of the given device.\")\n                         int registrationId,\n\n                         @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                         @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                         @Schema(description = \"\"\"\n                             The ciphertext of the time in milliseconds since epoch when the device was attached\n                             to the parent account, encoded in standard base64 without padding.\n                             \"\"\")\n                         byte[] createdAtCiphertext) {\n\n  public static DeviceInfo forDevice(final Device device) {\n    return new DeviceInfo(device.getId(), device.getName(), device.getLastSeen(), device.getRegistrationId(\n        IdentityType.ACI), device.getCreatedAtCiphertext());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceInfoList.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport java.util.List;\n\npublic record DeviceInfoList(List<DeviceInfo> devices) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/DeviceName.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.Size;\n\npublic record DeviceName(@JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                         @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                         @NotEmpty\n                         @Size(max = 225)\n                         byte[] deviceName) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ECPreKey.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.whispersystems.textsecuregcm.storage.KeyIdUtil;\nimport org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;\n\npublic record ECPreKey(\n    @Schema(description=\"\"\"\n        An arbitrary ID for this key, which will be provided by peers using this key to encrypt messages so the private key can be looked up.\n        Should not be zero. Should be less than 2^24.\n        \"\"\")\n    @Max(KeyIdUtil.MAX_KEY_ID)\n    @Min(KeyIdUtil.MIN_KEY_ID)\n    long keyId,\n\n    @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class)\n    @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class)\n    @Schema(type=\"string\", description=\"\"\"\n        The public key, serialized in libsignal's elliptic-curve public key format and then base64-encoded.\n        \"\"\")\n    ECPublicKey publicKey) implements PreKey<ECPublicKey> {\n\n  @Override\n  public byte[] serializedPublicKey() {\n    return publicKey().serialize();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ECSignedPreKey.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.whispersystems.textsecuregcm.storage.KeyIdUtil;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;\nimport java.util.Arrays;\nimport java.util.Objects;\n\npublic record ECSignedPreKey(\n    @Schema(description=\"\"\"\n        An arbitrary ID for this key, which will be provided by peers using this key to encrypt messages so the private key can be looked up.\n        Should not be zero. Should be less than 2^24.\n        \"\"\")\n    @Max(KeyIdUtil.MAX_KEY_ID)\n    @Min(KeyIdUtil.MIN_KEY_ID)\n    long keyId,\n\n    @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class)\n    @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class)\n    @Schema(type=\"string\", description=\"\"\"\n        The public key, serialized in libsignal's elliptic-curve public key format and then base64-encoded.\n        \"\"\")\n    ECPublicKey publicKey,\n\n    @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @Schema(type=\"string\", description=\"\"\"\n        The signature of the serialized `publicKey` with the account (or phone-number identity)'s identity key, base64-encoded.\n        \"\"\")\n    byte[] signature) implements SignedPreKey<ECPublicKey> {\n\n  @Override\n  public byte[] serializedPublicKey() {\n    return publicKey().serialize();\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o)\n      return true;\n    if (o == null || getClass() != o.getClass())\n      return false;\n    ECSignedPreKey that = (ECSignedPreKey) o;\n    return keyId == that.keyId && publicKey.equals(that.publicKey) && Arrays.equals(signature, that.signature);\n  }\n\n  @Override\n  public int hashCode() {\n    int result = Objects.hash(keyId, publicKey);\n    result = 31 * result + Arrays.hashCode(signature);\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/EncryptedUsername.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\n\npublic record EncryptedUsername(\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @NotNull\n    @Size(min = 1, max = EncryptedUsername.MAX_SIZE)\n    @Schema(type = \"string\", description = \"the URL-safe base64 encoding of the encrypted username\")\n    byte[] usernameLinkEncryptedValue,\n\n    @JsonProperty\n    @Schema(type = \"boolean\", description = \"if set and the account already has an encrypted-username link handle, reuse the same link handle rather than generating a new one. The response will still have the link handle.\")\n    boolean keepLinkHandle\n) {\n\n  public static final int MAX_SIZE = 128;\n\n  public EncryptedUsername(final byte[] usernameLinkEncryptedValue) {\n    this(usernameLinkEncryptedValue, false);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/Entitlements.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.util.InstantAdapter;\n\nimport javax.annotation.Nullable;\nimport java.time.Instant;\nimport java.util.List;\n\npublic record Entitlements(\n    @Schema(description = \"Active badges added via /v1/donation/redeem-receipt\")\n    List<BadgeEntitlement> badges,\n    @Schema(description = \"If present, the backup level set via /v1/archives/redeem-receipt\")\n    @Nullable BackupEntitlement backup) {\n\n  public record BadgeEntitlement(\n      @Schema(description = \"The badge id\")\n      String id,\n\n      @Schema(description = \"When the badge expires, in number of seconds since epoch\", implementation = Long.class)\n      @JsonSerialize(using = InstantAdapter.EpochSecondSerializer.class)\n      @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)\n      @JsonProperty(\"expirationSeconds\")\n      Instant expiration,\n\n      @Schema(description = \"Whether the badge is currently configured to be visible\")\n      boolean visible) {\n  }\n\n  public record BackupEntitlement(\n      @Schema(description = \"The backup level of the account\")\n      long backupLevel,\n\n      @Schema(description = \"When the backup entitlement expires, in number of seconds since epoch\", implementation = Long.class)\n      @JsonSerialize(using = InstantAdapter.EpochSecondSerializer.class)\n      @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)\n      @JsonProperty(\"expirationSeconds\")\n      Instant expiration) {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialProfileResponse.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonUnwrapped;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport javax.annotation.Nullable;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\n\n@Schema(description = \"Profile response with an expiring profile key credential\")\npublic class ExpiringProfileKeyCredentialProfileResponse {\n\n  @JsonUnwrapped\n  private VersionedProfileResponse versionedProfileResponse;\n\n  @Schema(description = \"Expiring profile key credential response. Null if profile version was not found\")\n  @JsonProperty\n  @JsonSerialize(using = ExpiringProfileKeyCredentialResponseAdapter.Serializing.class)\n  @JsonDeserialize(using = ExpiringProfileKeyCredentialResponseAdapter.Deserializing.class)\n  @Nullable\n  private ExpiringProfileKeyCredentialResponse credential;\n\n  public ExpiringProfileKeyCredentialProfileResponse() {\n  }\n\n  public ExpiringProfileKeyCredentialProfileResponse(final VersionedProfileResponse versionedProfileResponse,\n      @Nullable final ExpiringProfileKeyCredentialResponse credential) {\n\n    this.versionedProfileResponse = versionedProfileResponse;\n    this.credential = credential;\n  }\n\n  @Nullable\n  public ExpiringProfileKeyCredentialResponse getCredential() {\n    return credential;\n  }\n\n  public VersionedProfileResponse getVersionedProfileResponse() {\n    return versionedProfileResponse;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ExpiringProfileKeyCredentialResponseAdapter.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\n\npublic class ExpiringProfileKeyCredentialResponseAdapter {\n\n  public static class Serializing extends JsonSerializer<ExpiringProfileKeyCredentialResponse> {\n    @Override\n    public void serialize(ExpiringProfileKeyCredentialResponse response, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)\n        throws IOException {\n      if (response == null) jsonGenerator.writeNull();\n      else                  jsonGenerator.writeString(Base64.getEncoder().encodeToString(response.serialize()));\n    }\n  }\n\n  public static class Deserializing extends JsonDeserializer<ExpiringProfileKeyCredentialResponse> {\n    @Override\n    public ExpiringProfileKeyCredentialResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)\n        throws IOException {\n      try {\n        return new ExpiringProfileKeyCredentialResponse(Base64.getDecoder().decode(jsonParser.getValueAsString()));\n      } catch (InvalidInputException e) {\n        throw new IOException(e);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/GcmRegistrationId.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport jakarta.validation.constraints.NotEmpty;\n\npublic record GcmRegistrationId(@NotEmpty String gcmRegistrationId) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/GetCreateCallLinkCredentialsRequest.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport jakarta.validation.constraints.NotEmpty;\n\n\npublic record GetCreateCallLinkCredentialsRequest(@NotEmpty byte[] createCallLinkCredentialRequest) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/GroupCredentials.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport java.util.List;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\n\npublic record GroupCredentials(List<GroupCredential> credentials, List<CallLinkAuthCredential> callLinkAuthCredentials, @Nullable UUID pni) {\n\n  public record GroupCredential(byte[] credential, long redemptionTime) {\n  }\n\n  public record CallLinkAuthCredential(byte[] credential, long redemptionTime) {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.google.protobuf.ByteString;\nimport com.webauthn4j.converter.jackson.deserializer.json.ByteArrayBase64Deserializer;\nimport io.micrometer.core.instrument.Metrics;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport java.time.Clock;\nimport java.util.Arrays;\nimport java.util.Objects;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\npublic record IncomingMessage(int type,\n                              byte destinationDeviceId,\n                              int destinationRegistrationId,\n\n                              @JsonDeserialize(using = ByteArrayBase64Deserializer.class)\n                              @NotNull\n                              // Note that max size is validated elsewhere in the interest of controlling responses and\n                              // reporting additional metrics.\n                              @Size(min = 1)\n                              byte[] content) {\n\n  private static final String REJECT_INVALID_ENVELOPE_TYPE_COUNTER_NAME =\n      MetricsUtil.name(IncomingMessage.class, \"rejectInvalidEnvelopeType\");\n\n  public MessageProtos.Envelope toEnvelope(final ServiceIdentifier destinationIdentifier,\n      @Nullable AciServiceIdentifier sourceServiceIdentifier,\n      @Nullable Byte sourceDeviceId,\n      final long timestamp,\n      final boolean story,\n      final boolean ephemeral,\n      final boolean urgent,\n      @Nullable byte[] reportSpamToken,\n      final Clock clock) {\n\n    final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder();\n\n    envelopeBuilder\n        .setType(MessageProtos.Envelope.Type.forNumber(type))\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(clock.millis())\n        .setDestinationServiceId(destinationIdentifier.toServiceIdentifierString())\n        .setEphemeral(ephemeral)\n        .setUrgent(urgent);\n\n    if (story) {\n      // Avoid sending this field if it's false.\n      envelopeBuilder.setStory(true);\n    }\n\n    if (sourceServiceIdentifier != null && sourceDeviceId != null) {\n      envelopeBuilder\n          .setSourceServiceId(sourceServiceIdentifier.toServiceIdentifierString())\n          .setSourceDevice(sourceDeviceId.intValue());\n    }\n\n    if (reportSpamToken != null) {\n      envelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken));\n    }\n\n    envelopeBuilder.setContent(ByteString.copyFrom(content()));\n\n    return envelopeBuilder.build();\n  }\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isValidEnvelopeType() {\n    if (type() == MessageProtos.Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE ||\n        MessageProtos.Envelope.Type.forNumber(type()) == null) {\n\n      Metrics.counter(REJECT_INVALID_ENVELOPE_TYPE_COUNTER_NAME).increment();\n\n      return false;\n    }\n\n    return true;\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (!(o instanceof IncomingMessage(int otherType, byte otherDeviceId, int otherRegistrationId, byte[] otherContent)))\n      return false;\n    return type == otherType && destinationDeviceId == otherDeviceId\n        && destinationRegistrationId == otherRegistrationId && Objects.deepEquals(content, otherContent);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(type, destinationDeviceId, destinationRegistrationId, Arrays.hashCode(content));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingMessageList.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.PositiveOrZero;\nimport java.util.List;\nimport java.util.Objects;\nimport org.whispersystems.textsecuregcm.controllers.MessageController;\n\npublic record IncomingMessageList(@NotNull\n                                  @Valid\n                                  List<@NotNull @Valid IncomingMessage> messages,\n\n                                  boolean online,\n\n                                  boolean urgent,\n\n                                  @PositiveOrZero\n                                  @Max(MessageController.MAX_TIMESTAMP)\n                                  long timestamp) {\n\n  private static final Counter REJECT_DUPLICATE_RECIPIENT_COUNTER =\n      Metrics.counter(\n          name(IncomingMessageList.class, \"rejectDuplicateRecipients\"),\n          \"multiRecipient\", \"false\");\n\n  @JsonCreator\n  public IncomingMessageList(@JsonProperty(\"messages\") @NotNull @Valid List<@NotNull IncomingMessage> messages,\n      @JsonProperty(\"online\") boolean online,\n      @JsonProperty(\"urgent\") Boolean urgent,\n      @JsonProperty(\"timestamp\") long timestamp) {\n\n    this(messages, online, urgent == null || urgent, timestamp);\n  }\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isNotDuplicateRecipients() {\n    if (messages == null) {\n      return false;\n    }\n    final boolean valid = messages.stream()\n        .filter(Objects::nonNull)\n        .map(IncomingMessage::destinationDeviceId).distinct().count() == messages.size();\n\n    if (!valid) {\n      REJECT_DUPLICATE_RECIPIENT_COUNTER.increment();\n    }\n\n    return valid;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/IncomingWebsocketMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class IncomingWebsocketMessage {\n\n  public static final int TYPE_ACKNOWLEDGE_MESSAGE = 1;\n  public static final int TYPE_PING_MESSAGE        = 2;\n  public static final int TYPE_PONG_MESSAGE        = 3;\n\n  @JsonProperty\n  protected int type;\n\n  public IncomingWebsocketMessage() {}\n\n  public IncomingWebsocketMessage(int type) {\n    this.type = type;\n  }\n\n  public int getType() {\n    return type;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/KEMSignedPreKey.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\nimport org.whispersystems.textsecuregcm.storage.KeyIdUtil;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.KEMPublicKeyAdapter;\nimport java.util.Arrays;\nimport java.util.Objects;\n\npublic record KEMSignedPreKey(\n    @Schema(description=\"\"\"\n        An arbitrary ID for this key, which will be provided by peers using this key to encrypt messages so the private key can be looked up.\n        Should not be zero. Should be less than 2^24. The owner of this key must be able to determine from the key ID whether this represents\n        a single-use or last-resort key, but another party should *not* be able to tell.\n        \"\"\")\n    @Max(KeyIdUtil.MAX_KEY_ID)\n    @Min(KeyIdUtil.MIN_KEY_ID)\n    long keyId,\n\n    @JsonSerialize(using = KEMPublicKeyAdapter.Serializer.class)\n    @JsonDeserialize(using = KEMPublicKeyAdapter.Deserializer.class)\n    @Schema(type=\"string\", description=\"\"\"\n        The public key, serialized in libsignal's Kyber1024 public key format and then base64-encoded.\n        \"\"\")\n    KEMPublicKey publicKey,\n\n    @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @Schema(type=\"string\", description=\"\"\"\n        The signature of the serialized `publicKey` with the account (or phone-number identity)'s identity key, base64-encoded.\n        \"\"\")\n    byte[] signature) implements SignedPreKey<KEMPublicKey> {\n\n  @Override\n  public byte[] serializedPublicKey() {\n    return publicKey().serialize();\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o)\n      return true;\n    if (o == null || getClass() != o.getClass())\n      return false;\n    KEMSignedPreKey that = (KEMSignedPreKey) o;\n    return keyId == that.keyId && publicKey.equals(that.publicKey) && Arrays.equals(signature, that.signature);\n  }\n\n  @Override\n  public int hashCode() {\n    int result = Objects.hash(keyId, publicKey);\n    result = 31 * result + Arrays.hashCode(signature);\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyDistinguishedKeyResponse.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\n\npublic record KeyTransparencyDistinguishedKeyResponse(\n    @NotNull\n    @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @Schema(description = \"The serialized `DistinguishedResponse` encoded in standard un-padded base64\")\n    byte[] serializedResponse\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorRequest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Positive;\nimport java.util.Optional;\nimport jakarta.validation.constraints.PositiveOrZero;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record KeyTransparencyMonitorRequest(\n\n    @Valid\n    @NotNull\n    AciMonitor aci,\n\n    @Valid\n    @NotNull\n    Optional<@Valid E164Monitor> e164,\n\n    @Valid\n    @NotNull\n    Optional<@Valid UsernameHashMonitor> usernameHash,\n\n    @Schema(description = \"The tree head size to prove consistency against.\")\n    @Positive long lastNonDistinguishedTreeHeadSize,\n\n    @Schema(description = \"The distinguished tree head size to prove consistency against.\")\n    @Positive long lastDistinguishedTreeHeadSize\n) {\n\n  public record AciMonitor(\n      @NotNull\n      @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n      @JsonDeserialize(using = ServiceIdentifierAdapter.AciServiceIdentifierDeserializer.class)\n      @Schema(description = \"The aci identifier to monitor\")\n      AciServiceIdentifier value,\n\n      @Schema(description = \"A log tree position maintained by the client for the aci.\")\n      @PositiveOrZero\n      long entryPosition,\n\n      @Schema(description = \"The commitment index derived from a previous search request, encoded in standard unpadded base64\")\n      @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n      @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(32)\n      byte[] commitmentIndex\n  ) {}\n\n  public record E164Monitor(\n      @Schema(description = \"The e164-formatted phone number to monitor\")\n      @NotBlank\n      String value,\n\n      @Schema(description = \"A log tree position maintained by the client for the e164.\")\n      @PositiveOrZero\n      long entryPosition,\n\n      @Schema(description = \"The commitment index derived from a previous search request, encoded in standard unpadded base64\")\n      @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n      @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(32)\n      byte[] commitmentIndex\n  ) {}\n\n  public record UsernameHashMonitor(\n\n      @Schema(description = \"The username hash to monitor, encoded in url-safe unpadded base64.\")\n      @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n      @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n      @NotNull\n      @NotEmpty\n      byte[] value,\n\n      @Schema(description = \"A log tree position maintained by the client for the username hash.\")\n      @PositiveOrZero\n      long entryPosition,\n\n      @Schema(description = \"The commitment index derived from a previous search request, encoded in standard unpadded base64\")\n      @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n      @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n      @NotNull\n      @ExactlySize(32)\n      byte[] commitmentIndex\n  ) {}\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencyMonitorResponse.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\n\npublic record KeyTransparencyMonitorResponse(\n    @NotNull\n    @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @Schema(description = \"The serialized `MonitorResponse` encoded in standard un-padded base64\")\n    byte[] serializedResponse\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchRequest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Positive;\nimport java.util.Optional;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;\nimport org.whispersystems.textsecuregcm.util.E164;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record KeyTransparencySearchRequest(\n    @NotNull\n    @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n    @JsonDeserialize(using = ServiceIdentifierAdapter.AciServiceIdentifierDeserializer.class)\n    @Schema(description = \"The ACI to look up\")\n    AciServiceIdentifier aci,\n\n    @E164\n    @Schema(description = \"The E164-formatted phone number to look up\")\n    Optional<String> e164,\n\n    @JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @Schema(description = \"The username hash to look up, encoded in web-safe unpadded base64.\")\n    Optional<byte[]> usernameHash,\n\n    @NotNull\n    @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n    @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n    @Schema(description=\"The public ACI identity key associated with the provided ACI\")\n    IdentityKey aciIdentityKey,\n\n    @JsonSerialize(contentUsing = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n    @JsonDeserialize(contentUsing = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n    @Schema(description=\"The unidentified access key associated with the account\")\n    Optional<byte[]> unidentifiedAccessKey,\n\n    @Schema(description = \"The non-distinguished tree head size to prove consistency against.\")\n    Optional<@Positive Long> lastTreeHeadSize,\n\n    @Schema(description = \"The distinguished tree head size to prove consistency against.\")\n    @Positive long distinguishedTreeHeadSize\n) {\n    // This is the max value for a protobuf uint32 field\n    private static final long MAX_UINT32 = 0xFFFFFFFFL;\n\n    @AssertTrue\n    @Schema(hidden = true)\n    public boolean isUnidentifiedAccessKeyProvidedWithE164() {\n      return unidentifiedAccessKey.isPresent() == e164.isPresent();\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/KeyTransparencySearchResponse.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\n\npublic record KeyTransparencySearchResponse(\n    @NotNull\n    @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @Schema(description = \"The serialized `SearchResponse` encoded in standard un-padded base64.\")\n    byte[] serializedResponse\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/LinkDeviceRequest.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonUnwrapped;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Optional;\n\npublic record LinkDeviceRequest(@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n                                The verification code associated with this device. Must match the verification code\n                                provided by the server when provisioning this device.\n                                \"\"\")\n                                @NotBlank\n                                String verificationCode,\n\n                                @NotNull\n                                @Valid\n                                AccountAttributes accountAttributes,\n\n                                @NotNull\n                                @Valid\n                                @JsonUnwrapped\n                                @JsonProperty(access = JsonProperty.Access.READ_ONLY)\n                                DeviceActivationRequest deviceActivationRequest) {\n\n  @JsonCreator\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  public LinkDeviceRequest(@JsonProperty(\"verificationCode\") String verificationCode,\n                           @JsonProperty(\"accountAttributes\") AccountAttributes accountAttributes,\n                           @JsonProperty(\"aciSignedPreKey\") @NotNull @Valid ECSignedPreKey aciSignedPreKey,\n                           @JsonProperty(\"pniSignedPreKey\") @NotNull @Valid ECSignedPreKey pniSignedPreKey,\n                           @JsonProperty(\"aciPqLastResortPreKey\") @NotNull @Valid KEMSignedPreKey aciPqLastResortPreKey,\n                           @JsonProperty(\"pniPqLastResortPreKey\") @NotNull @Valid KEMSignedPreKey pniPqLastResortPreKey,\n                           @JsonProperty(\"apnToken\") Optional<@Valid ApnRegistrationId> apnToken,\n                           @JsonProperty(\"gcmToken\") Optional<@Valid GcmRegistrationId> gcmToken) {\n\n    this(verificationCode, accountAttributes,\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, apnToken, gcmToken));\n  }\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isExactlyOneMessageDeliveryChannel() {\n    if (accountAttributes != null && accountAttributes.getFetchesMessages()) {\n      return deviceActivationRequest().apnToken().isEmpty() && deviceActivationRequest().gcmToken().isEmpty();\n    } else {\n      return deviceActivationRequest().apnToken().isPresent() ^ deviceActivationRequest().gcmToken().isPresent();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/LinkDeviceResponse.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport java.util.UUID;\n\npublic record LinkDeviceResponse(UUID uuid, UUID pni, byte deviceId) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/MismatchedDevicesResponse.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\nimport java.util.Set;\n\npublic record MismatchedDevicesResponse(@JsonProperty\n                                        @Schema(description = \"Devices present on the account but absent in the request\")\n                                        Set<Byte> missingDevices,\n\n                                        @JsonProperty\n                                        @Schema(description = \"Devices absent on the request but present in the account\")\n                                        Set<Byte> extraDevices) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntity.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.protobuf.ByteString;\nimport java.util.Arrays;\nimport java.util.Objects;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record OutgoingMessageEntity(UUID guid,\n                                    int type,\n                                    long timestamp,\n\n                                    @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n                                    @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n                                    @Nullable\n                                    ServiceIdentifier sourceUuid,\n\n                                    int sourceDevice,\n\n                                    @JsonSerialize(using = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n                                    @JsonDeserialize(using = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n                                    ServiceIdentifier destinationUuid,\n\n                                    @Nullable UUID updatedPni,\n                                    byte[] content,\n                                    long serverTimestamp,\n                                    boolean urgent,\n                                    boolean story,\n                                    @Nullable byte[] reportSpamToken) {\n\n  @VisibleForTesting\n  MessageProtos.Envelope toEnvelope() {\n    final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()\n        .setType(MessageProtos.Envelope.Type.forNumber(type()))\n        .setClientTimestamp(timestamp())\n        .setServerTimestamp(serverTimestamp())\n        .setDestinationServiceId(destinationUuid().toServiceIdentifierString())\n        .setServerGuid(guid().toString())\n        .setUrgent(urgent);\n\n    if (story) {\n      // Avoid sending this field if it's false.\n      builder.setStory(true);\n    }\n\n    if (sourceUuid() != null) {\n      builder.setSourceServiceId(sourceUuid().toServiceIdentifierString());\n      builder.setSourceDevice(sourceDevice());\n    }\n\n    if (content() != null) {\n      builder.setContent(ByteString.copyFrom(content()));\n    }\n\n    if (updatedPni() != null) {\n      builder.setUpdatedPni(updatedPni().toString());\n    }\n\n    if (reportSpamToken != null) {\n      builder.setReportSpamToken(ByteString.copyFrom(reportSpamToken));\n    }\n\n    return builder.build();\n  }\n\n  public static OutgoingMessageEntity fromEnvelope(final MessageProtos.Envelope envelope) {\n    ByteString token = envelope.getReportSpamToken();\n    return new OutgoingMessageEntity(\n        UUID.fromString(envelope.getServerGuid()),\n        envelope.getType().getNumber(),\n        envelope.getClientTimestamp(),\n        envelope.hasSourceServiceId() ? ServiceIdentifier.valueOf(envelope.getSourceServiceId()) : null,\n        envelope.getSourceDevice(),\n        envelope.hasDestinationServiceId() ? ServiceIdentifier.valueOf(envelope.getDestinationServiceId()) : null,\n        envelope.hasUpdatedPni() ? UUID.fromString(envelope.getUpdatedPni()) : null,\n        envelope.getContent().toByteArray(),\n        envelope.getServerTimestamp(),\n        envelope.getUrgent(),\n        envelope.getStory(),\n        token.isEmpty() ? null : token.toByteArray());\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n    final OutgoingMessageEntity that = (OutgoingMessageEntity) o;\n    return guid.equals(that.guid) &&\n        type == that.type &&\n        timestamp == that.timestamp &&\n        Objects.equals(sourceUuid, that.sourceUuid) &&\n        sourceDevice == that.sourceDevice &&\n        destinationUuid.equals(that.destinationUuid) &&\n        Objects.equals(updatedPni, that.updatedPni) &&\n        Arrays.equals(content, that.content) &&\n        serverTimestamp == that.serverTimestamp &&\n        urgent == that.urgent &&\n        story == that.story &&\n        Arrays.equals(reportSpamToken, that.reportSpamToken);\n  }\n\n  @Override\n  public int hashCode() {\n    int result = Objects.hash(\n        guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, serverTimestamp, urgent, story);\n    result = 31 * result + Arrays.hashCode(content);\n    result = 71 * result + Arrays.hashCode(reportSpamToken);\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityList.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport java.util.List;\n\npublic record OutgoingMessageEntityList(List<OutgoingMessageEntity> messages, boolean more) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberDiscoverabilityRequest.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport jakarta.validation.constraints.NotNull;\n\npublic record PhoneNumberDiscoverabilityRequest(@NotNull Boolean discoverableByPhoneNumber) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneNumberIdentityKeyDistributionRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport io.swagger.v3.oas.annotations.media.ArraySchema;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport javax.annotation.Nullable;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\n\npublic record PhoneNumberIdentityKeyDistributionRequest(\n    @NotNull\n    @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n    @Schema(description=\"the new identity key for this account's phone-number identity\")\n    IdentityKey pniIdentityKey,\n\n    @NotNull\n    @Valid\n    @ArraySchema(\n        arraySchema=@Schema(description=\"\"\"\n            A list of synchronization messages to send to companion devices to supply the private keys\n            associated with the new identity key and their new prekeys.\n            Exactly one message must be supplied for each device other than the sending (primary) device.\n            \"\"\"))\n    List<@NotNull @Valid IncomingMessage> deviceMessages,\n\n    @NotNull\n    @NotEmpty\n    @Valid\n    @Schema(description=\"\"\"\n        A new signed elliptic-curve prekey for each device on the account, including this one.\n        Each must be accompanied by a valid signature from the new identity key in this request.\"\"\")\n    Map<Byte, @NotNull @Valid ECSignedPreKey> devicePniSignedPrekeys,\n\n    @NotNull\n    @NotEmpty\n    @Valid\n    @Schema(description=\"\"\"\n        A new signed post-quantum last-resort prekey for each device on the account, including this one.\n        Each must be accompanied by a valid signature from the new identity key in this request.\"\"\")\n    Map<Byte, @NotNull @Valid KEMSignedPreKey> devicePniPqLastResortPrekeys,\n\n    @NotNull\n    @NotEmpty\n    @Valid\n    @Schema(description=\"The new registration ID to use for the phone-number identity of each device, including this one.\")\n    Map<Byte, Integer> pniRegistrationIds) {\n\n  public boolean isSignatureValidOnEachSignedPreKey(@Nullable final String userAgent) {\n    final List<SignedPreKey<?>> signedPreKeys = new ArrayList<>(devicePniSignedPrekeys.values());\n    signedPreKeys.addAll(devicePniPqLastResortPrekeys.values());\n\n    return PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey, signedPreKeys, userAgent, \"distribute-pni-keys\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PhoneVerificationRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.apache.commons.lang3.StringUtils.isNotBlank;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.ws.rs.ClientErrorException;\nimport java.util.Base64;\nimport org.apache.http.HttpStatus;\n\npublic interface PhoneVerificationRequest {\n\n  enum VerificationType {\n    SESSION,\n    RECOVERY_PASSWORD\n  }\n\n  String sessionId();\n\n  byte[] recoveryPassword();\n\n  // for the @AssertTrue to work with bean validation, method name must follow 'isSmth()'/'getSmth()' naming convention\n  @AssertTrue\n  @Schema(hidden = true)\n  default boolean isValid() {\n    // checking that exactly one of sessionId/recoveryPassword is non-empty\n    return isNotBlank(sessionId()) ^ (recoveryPassword() != null && recoveryPassword().length > 0);\n  }\n\n  default PhoneVerificationRequest.VerificationType verificationType() {\n    return isNotBlank(sessionId()) ? PhoneVerificationRequest.VerificationType.SESSION\n        : PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD;\n  }\n\n  default byte[] decodeSessionId() {\n    try {\n      return Base64.getUrlDecoder().decode(sessionId());\n    } catch (final IllegalArgumentException e) {\n      throw new ClientErrorException(\"Malformed session ID\", HttpStatus.SC_UNPROCESSABLE_ENTITY);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKey.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\npublic interface PreKey<K> {\n\n  long keyId();\n\n  K publicKey();\n\n  byte[] serializedPublicKey();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyCount.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\npublic class PreKeyCount {\n\n  @Schema(description=\"the number of stored unsigned elliptic-curve prekeys for this device\")\n  @JsonProperty\n  private int count;\n\n  @Schema(description=\"the number of stored one-time post-quantum prekeys for this device\")\n  @JsonProperty\n  private int pqCount;\n\n  public PreKeyCount(int ecCount, int pqCount) {\n    this.count = ecCount;\n    this.pqCount = pqCount;\n  }\n\n  public PreKeyCount() {}\n\n  public int getCount() {\n    return count;\n  }\n\n  public int getPqCount() {\n    return pqCount;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponse.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.List;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\n\npublic class PreKeyResponse {\n\n  @JsonProperty\n  @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n  @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n  @Schema(description=\"the public identity key for the requested identity\")\n  private IdentityKey identityKey;\n\n  @JsonProperty\n  @Schema(description=\"information about each requested device\")\n  private List<PreKeyResponseItem> devices;\n\n  public PreKeyResponse() {}\n\n  public PreKeyResponse(IdentityKey identityKey, List<PreKeyResponseItem> devices) {\n    this.identityKey = identityKey;\n    this.devices = devices;\n  }\n\n  @VisibleForTesting\n  public IdentityKey getIdentityKey() {\n    return identityKey;\n  }\n\n  @VisibleForTesting\n  @JsonIgnore\n  public PreKeyResponseItem getDevice(byte deviceId) {\n    for (PreKeyResponseItem device : devices) {\n      if (device.getDeviceId() == deviceId) return device;\n    }\n\n    return null;\n  }\n\n  @VisibleForTesting\n  @JsonIgnore\n  public int getDevicesCount() {\n    return devices.size();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeyResponseItem.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\npublic class PreKeyResponseItem {\n\n  @JsonProperty\n  @Schema(description=\"the device ID of the device to which this item pertains\")\n  private byte deviceId;\n\n  @JsonProperty\n  @Schema(description=\"the registration ID for the device\")\n  private int registrationId;\n\n  @JsonProperty\n  @Schema(description=\"the signed elliptic-curve prekey for the device\")\n  private ECSignedPreKey signedPreKey;\n\n  @JsonProperty\n  @Schema(description=\"an unsigned elliptic-curve prekey for the device, if any remain\")\n  private ECPreKey preKey;\n\n  @JsonProperty\n  @Schema(description=\"a signed post-quantum prekey for the device \" +\n      \"(a one-time prekey if any remain, otherwise the last-resort prekey)\")\n  private KEMSignedPreKey pqPreKey;\n\n  public PreKeyResponseItem() {}\n\n  public PreKeyResponseItem(byte deviceId, int registrationId, ECSignedPreKey signedPreKey, ECPreKey preKey,\n      KEMSignedPreKey pqPreKey) {\n    this.deviceId = deviceId;\n    this.registrationId = registrationId;\n    this.signedPreKey = signedPreKey;\n    this.preKey = preKey;\n    this.pqPreKey = pqPreKey;\n  }\n\n  @VisibleForTesting\n  public ECSignedPreKey getSignedPreKey() {\n    return signedPreKey;\n  }\n\n  @VisibleForTesting\n  public ECPreKey getPreKey() {\n    return preKey;\n  }\n\n  @VisibleForTesting\n  public KEMSignedPreKey getPqPreKey() {\n    return pqPreKey;\n  }\n\n  @VisibleForTesting\n  public int getRegistrationId() {\n    return registrationId;\n  }\n\n  @VisibleForTesting\n  public byte getDeviceId() {\n    return deviceId;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PreKeySignatureValidator.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\n\nimport javax.annotation.Nullable;\nimport java.util.Collection;\n\npublic abstract class PreKeySignatureValidator {\n  public static final String INVALID_SIGNATURE_COUNTER_NAME =\n      MetricsUtil.name(PreKeySignatureValidator.class, \"invalidPreKeySignature\");\n\n  public static boolean validatePreKeySignatures(final IdentityKey identityKey,\n      final Collection<SignedPreKey<?>> signedPreKeys,\n      @Nullable final String userAgent,\n      final String context) {\n\n    final boolean success = signedPreKeys.stream().allMatch(signedPreKey -> signedPreKey.signatureValid(identityKey));\n\n    if (!success) {\n      Metrics.counter(INVALID_SIGNATURE_COUNTER_NAME,\n              Tags.of(UserAgentTagUtil.getPlatformTag(userAgent), Tag.of(\"context\", context)))\n          .increment();\n    }\n\n    return success;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileAvatarUploadAttributes.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\n@Schema(description = \"Profile avatar upload form (S3 Post Policy, AWS Signature version 4)\")\npublic class ProfileAvatarUploadAttributes {\n\n  @Schema(description = \"Object key for the avatar\")\n  @JsonProperty\n  private String key;\n\n  @Schema(description = \"Credential for the upload\")\n  @JsonProperty\n  private String credential;\n\n  @Schema(description = \"Access control list setting\")\n  @JsonProperty\n  private String acl;\n\n  @Schema(description = \"Signing algorithm\")\n  @JsonProperty\n  private String algorithm;\n\n  @Schema(description = \"Date in AWS format\")\n  @JsonProperty\n  private String date;\n\n  @Schema(description = \"Base64-encoded upload policy\")\n  @JsonProperty\n  private String policy;\n\n  @Schema(description = \"Signature calculated over the policy\")\n  @JsonProperty\n  private String signature;\n\n  public ProfileAvatarUploadAttributes() {}\n\n  public ProfileAvatarUploadAttributes(String key, String credential, String acl, String algorithm, String date,\n      String policy, String signature) {\n\n    this.key = key;\n    this.credential = credential;\n    this.acl = acl;\n    this.algorithm = algorithm;\n    this.date = date;\n    this.policy = policy;\n    this.signature = signature;\n  }\n\n  public String getKey() {\n    return key;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ProfileKeyCommitmentAdapter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;\n\npublic class ProfileKeyCommitmentAdapter {\n\n  public static class Serializing extends JsonSerializer<ProfileKeyCommitment> {\n    @Override\n    public void serialize(ProfileKeyCommitment value, JsonGenerator gen, SerializerProvider serializers) throws IOException {\n      gen.writeString(Base64.getEncoder().encodeToString(value.serialize()));\n    }\n  }\n\n  public static class Deserializing extends JsonDeserializer<ProfileKeyCommitment> {\n\n    @Override\n    public ProfileKeyCommitment deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {\n      try {\n        return new ProfileKeyCommitment(Base64.getDecoder().decode(p.getValueAsString()));\n      } catch (InvalidInputException e) {\n        throw new IOException(e);\n      }\n    }\n  }\n}\n\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ProvisioningMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotEmpty;\n\npublic record ProvisioningMessage(\n    @Schema(description = \"The MIME base64-encoded body of the provisioning message to send to the destination device\")\n    @NotEmpty\n    String body) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/PurchasableBadge.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport com.fasterxml.jackson.annotation.JsonFormat.Shape;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport java.time.Duration;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\nimport java.util.Objects;\n\npublic class PurchasableBadge extends Badge {\n  private final Duration duration;\n\n  @JsonCreator\n  public PurchasableBadge(\n      @JsonProperty(\"id\") final String id,\n      @JsonProperty(\"category\") final String category,\n      @JsonProperty(\"name\") final String name,\n      @JsonProperty(\"description\") final String description,\n      @JsonProperty(\"sprites6\") final List<String> sprites6,\n      @JsonProperty(\"svg\") final String svg,\n      @JsonProperty(\"svgs\") final List<BadgeSvg> svgs,\n      @JsonProperty(\"duration\") final Duration duration) {\n    super(id, category, name, description, sprites6, svg, svgs);\n    this.duration = duration != null ? duration.truncatedTo(ChronoUnit.SECONDS) : null;\n  }\n\n  public PurchasableBadge(final Badge badge, final Duration duration) {\n    super(\n        badge.getId(),\n        badge.getCategory(),\n        badge.getName(),\n        badge.getDescription(),\n        badge.getSprites6(),\n        badge.getSvg(),\n        badge.getSvgs());\n    this.duration = duration != null ? duration.truncatedTo(ChronoUnit.SECONDS) : null;\n  }\n\n  @JsonFormat(shape = Shape.NUMBER_INT)\n  public Duration getDuration() {\n    return duration;\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n    if (!super.equals(o)) {\n      return false;\n    }\n    PurchasableBadge that = (PurchasableBadge) o;\n    return Objects.equals(duration, that.duration);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(super.hashCode(), duration);\n  }\n\n  @Override\n  public String toString() {\n    return \"PurchasableBadge{\" +\n        \"super=\" + super.toString() +\n        \", duration=\" + duration +\n        '}';\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RateLimitChallenge.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\n\npublic class RateLimitChallenge {\n\n  @JsonProperty\n  @NotNull\n  private final String token;\n\n  @JsonProperty\n  @NotNull\n  private final List<String> options;\n\n  @JsonCreator\n  public RateLimitChallenge(@JsonProperty(\"token\") final String token, @JsonProperty(\"options\") final List<String> options) {\n\n    this.token = token;\n    this.options = options;\n  }\n\n  public String getToken() {\n    return token;\n  }\n\n  public List<String> getOptions() {\n    return options;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RedeemReceiptRequest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotEmpty;\n\npublic class RedeemReceiptRequest {\n\n  @Schema(description = \"Presentation of a ZK receipt encoded in standard padded base64\", implementation = String.class)\n  private final byte[] receiptCredentialPresentation;\n  @Schema(description = \"If true, the corresponding badge should be visible on the profile\")\n  private final boolean visible;\n  @Schema(description = \"if true, and the new badge is visible, it should be the primary badge on the profile\")\n  private final boolean primary;\n\n  @JsonCreator\n  public RedeemReceiptRequest(\n      @JsonProperty(\"receiptCredentialPresentation\") byte[] receiptCredentialPresentation,\n      @JsonProperty(\"visible\") boolean visible,\n      @JsonProperty(\"primary\") boolean primary) {\n    this.receiptCredentialPresentation = receiptCredentialPresentation;\n    this.visible = visible;\n    this.primary = primary;\n  }\n\n  @NotEmpty\n  public byte[] getReceiptCredentialPresentation() {\n    return receiptCredentialPresentation;\n  }\n\n  public boolean isVisible() {\n    return visible;\n  }\n\n  public boolean isPrimary() {\n    return primary;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLock.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.Size;\n\npublic record RegistrationLock(@JsonProperty @Size(min = 64, max = 64) @NotEmpty String registrationLock) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationLockFailure.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\n\n@Schema(description = \"\"\"\n    Information about the current Registration lock and SVR credentials. With a correct PIN, the credentials can\n    be used to recover the secret used to derive the registration lock password.\n    \"\"\")\npublic record RegistrationLockFailure(\n    @Schema(description = \"Time remaining in milliseconds before the existing registration lock expires\")\n    long timeRemaining,\n    @Schema(description = \"Credentials that can be used with SVR2\")\n    @Nullable\n    ExternalServiceCredentials svr2Credentials) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonUnwrapped;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.List;\nimport javax.annotation.Nullable;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\n\npublic record RegistrationRequest(@Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"\"\"\n                                  The ID of an existing verification session as it appears in a verification session\n                                  metadata object. Must be provided if `recoveryPassword` is not provided; must not be\n                                  provided if `recoveryPassword` is provided.\n                                  \"\"\")\n                                  String sessionId,\n\n                                  @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                  @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"\"\"\n                                  A base64-encoded registration recovery password. Must be provided if `sessionId` is\n                                  not provided; must not be provided if `sessionId` is provided\n                                  \"\"\")\n                                  byte[] recoveryPassword,\n\n                                  @NotNull\n                                  @Valid\n                                  @Schema(requiredMode = Schema.RequiredMode.REQUIRED)\n                                  AccountAttributes accountAttributes,\n\n                                  @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n                                  If true, indicates that the end user has elected not to transfer data from another\n                                  device even though a device transfer is technically possible given the capabilities of\n                                  the calling device and the device associated with the existing account (if any). If\n                                  false and if a device transfer is technically possible, the registration request will\n                                  fail with an HTTP/409 response indicating that the client should prompt the user to\n                                  transfer data from an existing device.\n                                  \"\"\")\n                                  boolean skipDeviceTransfer,\n\n                                  @NotNull\n                                  @Valid\n                                  @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n                                  The ACI-associated identity key for the account, encoded as a base64 string.\n                                  \"\"\")\n                                  @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n                                  @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n                                  IdentityKey aciIdentityKey,\n\n                                  @NotNull\n                                  @Valid\n                                  @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"\"\"\n                                  The PNI-associated identity key for the account, encoded as a base64 string.\n                                  \"\"\")\n                                  @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n                                  @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n                                  IdentityKey pniIdentityKey,\n\n                                  @NotNull\n                                  @Valid\n                                  @JsonUnwrapped\n                                  @JsonProperty\n                                  DeviceActivationRequest deviceActivationRequest) implements PhoneVerificationRequest {\n\n  public boolean isEverySignedKeyValid(@Nullable final String userAgent) {\n    if (deviceActivationRequest().aciSignedPreKey() == null ||\n        deviceActivationRequest().pniSignedPreKey() == null ||\n        deviceActivationRequest().aciPqLastResortPreKey() == null ||\n        deviceActivationRequest().pniPqLastResortPreKey() == null) {\n      return false;\n    }\n\n    return PreKeySignatureValidator.validatePreKeySignatures(aciIdentityKey(), List.of(deviceActivationRequest().aciSignedPreKey(), deviceActivationRequest().aciPqLastResortPreKey()), userAgent, \"register\")\n        && PreKeySignatureValidator.validatePreKeySignatures(pniIdentityKey(), List.of(deviceActivationRequest().pniSignedPreKey(), deviceActivationRequest().pniPqLastResortPreKey()), userAgent, \"register\");\n  }\n\n  @VisibleForTesting\n  @AssertTrue\n  @Schema(hidden = true)\n  boolean isExactlyOneMessageDeliveryChannel() {\n    if (deviceActivationRequest == null || accountAttributes == null) {\n      return false;\n    }\n    if (accountAttributes.getFetchesMessages()) {\n      return deviceActivationRequest().apnToken().isEmpty() && deviceActivationRequest().gcmToken().isEmpty();\n    } else {\n      return deviceActivationRequest().apnToken().isPresent() ^ deviceActivationRequest().gcmToken().isPresent();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RegistrationServiceSession.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport java.util.Base64;\nimport javax.annotation.Nullable;\nimport org.signal.registration.rpc.RegistrationSessionMetadata;\n\npublic record RegistrationServiceSession(byte[] id,\n                                         String number,\n                                         boolean verified,\n                                         @Nullable Long nextSms,\n                                         @Nullable Long nextVoiceCall,\n                                         @Nullable Long nextVerificationAttempt,\n                                         long expiration) {\n\n\n  public String encodedSessionId() {\n    return encodeSessionId(id);\n  }\n\n  public static String encodeSessionId(final byte[] sessionId) {\n    return Base64.getUrlEncoder().encodeToString(sessionId);\n  }\n\n  public RegistrationServiceSession(byte[] id, String number, RegistrationSessionMetadata remoteSession) {\n    this(id, number, remoteSession.getVerified(),\n        remoteSession.getMayRequestSms() ? remoteSession.getNextSmsSeconds() : null,\n        remoteSession.getMayRequestVoiceCall() ? remoteSession.getNextVoiceCallSeconds() : null,\n        remoteSession.getMayCheckCode() ? remoteSession.getNextCodeCheckSeconds() : null,\n        remoteSession.getExpirationSeconds());\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RemoteAttachment.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotBlank;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport org.whispersystems.textsecuregcm.util.ValidBase64URLString;\n\npublic record RemoteAttachment(\n    @Schema(description = \"The attachment cdn\")\n    @NotNull\n    Integer cdn,\n\n    @NotBlank\n    @ValidBase64URLString\n    @Size(max = 64)\n    @Schema(description = \"The attachment key\", maxLength = 64)\n    String key) implements TransferArchiveResult {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RemoteAttachmentError.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\n\n@Schema(description = \"Indicates an attachment failed to upload\")\npublic record RemoteAttachmentError(\n    @Schema(description = \"The type of error encountered\")\n    @Valid @NotNull ErrorType error)\n    implements TransferArchiveResult {\n\n  public enum ErrorType {\n    RELINK_REQUESTED,\n    CONTINUE_WITHOUT_UPLOAD;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RemoteConfigurationResponse.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.Map;\n\npublic record RemoteConfigurationResponse(\n  @JsonProperty\n  @Schema(description = \"Remote configurations applicable to the user and client\")\n  Map<String, String> config) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashRequest.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\n\npublic record ReserveUsernameHashRequest(\n    @NotNull\n    @Valid\n    @Size(min=1, max=AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH)\n    @JsonSerialize(contentUsing = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(contentUsing = ByteArrayBase64UrlAdapter.Deserializing.class)\n    List<byte[]> usernameHashes\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/ReserveUsernameHashResponse.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport java.util.UUID;\n\npublic record ReserveUsernameHashResponse(\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @ExactlySize(AccountController.USERNAME_HASH_LENGTH)\n    byte[] usernameHash\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/RestoreAccountRequest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\n\n@Schema(description = \"\"\"\n    Represents a request from a new device to restore account data by some method.\n    \"\"\")\npublic record RestoreAccountRequest(\n    @NotNull\n    @Schema(description = \"The method by which the new device has requested account data restoration\")\n    Method method,\n\n    @Schema(description = \"Additional data to use to bootstrap a connection between devices, in standard unpadded base64.\",\n        implementation = String.class)\n    @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n    @Size(max = 4096)\n    @Nullable byte[] deviceTransferBootstrap) {\n\n  public enum Method {\n    @Schema(description = \"Restore account data from a remote message history backup\")\n    REMOTE_BACKUP,\n\n    @Schema(description = \"Restore account data from a local backup archive\")\n    LOCAL_BACKUP,\n\n    @Schema(description = \"Restore account data via direct device-to-device transfer\")\n    DEVICE_TRANSFER,\n\n    @Schema(description = \"Do not restore account data\")\n    DECLINE,\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SelfBadge.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Objects;\n\n/**\n * Extension of the Badge object returned when asking for one's own badges.\n */\npublic class SelfBadge extends Badge {\n  private final Instant expiration;\n  private final boolean visible;\n\n  public SelfBadge(\n      @JsonProperty(\"id\") final String id,\n      @JsonProperty(\"category\") final String category,\n      @JsonProperty(\"name\") final String name,\n      @JsonProperty(\"description\") final String description,\n      @JsonProperty(\"sprites6\") final List<String> sprites6,\n      @JsonProperty(\"svg\") final String svg,\n      @JsonProperty(\"svgs\") final List<BadgeSvg> svgs,\n      @JsonProperty(\"expiration\") final Instant expiration,\n      @JsonProperty(\"visible\") final boolean visible) {\n    super(id, category, name, description, sprites6, svg, svgs);\n    this.expiration = expiration;\n    this.visible = visible;\n  }\n\n  public Instant getExpiration() {\n    return expiration;\n  }\n\n  public boolean isVisible() {\n    return visible;\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n    if (!super.equals(o)) {\n      return false;\n    }\n    SelfBadge selfBadge = (SelfBadge) o;\n    return visible == selfBadge.visible && Objects.equals(expiration, selfBadge.expiration);\n  }\n\n  @Override\n  public int hashCode() {\n    return Objects.hash(super.hashCode(), expiration, visible);\n  }\n\n  @Override\n  public String toString() {\n    return \"SelfBadge{\" +\n        \"super=\" + super.toString() +\n        \", expiration=\" + expiration +\n        \", visible=\" + visible +\n        '}';\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMessageResponse.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\npublic class SendMessageResponse {\n\n  @JsonProperty\n  private boolean needsSync;\n\n  public SendMessageResponse() {}\n\n  public SendMessageResponse(boolean needsSync) {\n    this.needsSync = needsSync;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SendMultiRecipientMessageResponse.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\n\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ServiceIdentifierAdapter;\n\npublic record SendMultiRecipientMessageResponse(\n    @Schema(description = \"a list of the service identifiers in the request that do not correspond to registered Signal users; will only be present if a group send endorsement was supplied for the request\")\n    @JsonSerialize(contentUsing = ServiceIdentifierAdapter.ServiceIdentifierSerializer.class)\n    @JsonDeserialize(contentUsing = ServiceIdentifierAdapter.ServiceIdentifierDeserializer.class)\n    List<ServiceIdentifier> uuids404) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SetKeysRequest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Size;\nimport jakarta.validation.Valid;\nimport java.util.List;\n\npublic record SetKeysRequest(\n    @NotNull\n    @Valid\n    @Size(max=100)\n    @Schema(description = \"\"\"\n        A list of unsigned elliptic-curve prekeys to use for this device. If present and not empty, replaces all stored\n        unsigned EC prekeys for the device; if absent or empty, any stored unsigned EC prekeys for the device are not\n        deleted.\n        \"\"\")\n    List<@Valid ECPreKey> preKeys,\n\n    @Valid\n    @Schema(description = \"\"\"\n        An optional signed elliptic-curve prekey to use for this device. If present, replaces the stored signed\n        elliptic-curve prekey for the device; if absent, the stored signed prekey is not deleted. If present, must have\n        a valid signature from the identity key in this request.\n        \"\"\")\n    ECSignedPreKey signedPreKey,\n\n    @NotNull\n    @Valid\n    @Size(max=100)\n    @Schema(description = \"\"\"\n        A list of signed post-quantum one-time prekeys to use for this device. Each key must have a valid signature from\n        the identity key in this request. If present and not empty, replaces all stored unsigned PQ prekeys for the\n        device; if absent or empty, any stored unsigned PQ prekeys for the device are not deleted.\n        \"\"\")\n    List<@Valid KEMSignedPreKey> pqPreKeys,\n\n    @Valid\n    @Schema(description = \"\"\"\n        An optional signed last-resort post-quantum prekey to use for this device. If present, replaces the stored\n        signed post-quantum last-resort prekey for the device; if absent, a stored last-resort prekey will *not* be\n        deleted. If present, must have a valid signature from the identity key in this request.\n        \"\"\")\n    KEMSignedPreKey pqLastResortPreKey) {\n  public SetKeysRequest {\n    // It’s a little counter-intuitive, but this compact constructor allows a default value\n    // to be used when one isn’t specified, allowing the field to still be\n    // validated as @NotNull\n    if (preKeys == null) {\n      preKeys = List.of();\n    }\n\n    if (pqPreKeys == null) {\n      pqPreKeys = List.of();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SetPublicKeyRequest.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.whispersystems.textsecuregcm.util.ECPublicKeyAdapter;\n\npublic record SetPublicKeyRequest(\n    @JsonSerialize(using = ECPublicKeyAdapter.Serializer.class)\n    @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class)\n    @Schema(type=\"string\", description=\"\"\"\n        The public key, serialized in libsignal's elliptic-curve public key format and then encoded as a standard (i.e.\n        not URL-safe), padded, base64-encoded string.\n        \"\"\")\n    ECPublicKey publicKey) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SignedPreKey.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport org.signal.libsignal.protocol.IdentityKey;\n\npublic interface SignedPreKey<K> extends PreKey<K> {\n\n  byte[] signature();\n\n  default boolean signatureValid(final IdentityKey identityKey) {\n    try {\n      return identityKey.getPublicKey().verifySignature(serializedPublicKey(), signature());\n    } catch (final Exception e) {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SpamReport.java",
    "content": "package org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\n\npublic record SpamReport(@JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                         @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                         @Nullable byte[] token) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/StaleDevicesResponse.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\nimport java.util.Set;\n\npublic record StaleDevicesResponse(@JsonProperty\n                                   @Schema(description = \"Devices that are no longer active\")\n                                   Set<Byte> staleDevices) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/StickerPackFormUploadAttributes.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\nimport java.util.List;\n\npublic class StickerPackFormUploadAttributes {\n\n  @JsonProperty\n  private StickerPackFormUploadItem       manifest;\n\n  @JsonProperty\n  private List<StickerPackFormUploadItem> stickers;\n\n  @JsonProperty\n  private String                          packId;\n\n  public StickerPackFormUploadAttributes() {}\n\n  public StickerPackFormUploadAttributes(String packId, StickerPackFormUploadItem manifest, List<StickerPackFormUploadItem> stickers) {\n    this.packId   = packId;\n    this.manifest = manifest;\n    this.stickers = stickers;\n  }\n\n  public StickerPackFormUploadItem getManifest() {\n    return manifest;\n  }\n\n  public List<StickerPackFormUploadItem> getStickers() {\n    return stickers;\n  }\n\n  public String getPackId() {\n    return packId;\n  }\n\n  public static class StickerPackFormUploadItem {\n    @JsonProperty\n    private int id;\n\n    @JsonProperty\n    private String key;\n\n    @JsonProperty\n    private String credential;\n\n    @JsonProperty\n    private String acl;\n\n    @JsonProperty\n    private String algorithm;\n\n    @JsonProperty\n    private String date;\n\n    @JsonProperty\n    private String policy;\n\n    @JsonProperty\n    private String signature;\n\n    public StickerPackFormUploadItem() {}\n\n    public StickerPackFormUploadItem(int id, String key, String credential, String acl, String algorithm, String date, String policy, String signature) {\n      this.key        = key;\n      this.credential = credential;\n      this.acl        = acl;\n      this.algorithm  = algorithm;\n      this.date       = date;\n      this.policy     = policy;\n      this.signature  = signature;\n      this.id         = id;\n    }\n\n    public String getKey() {\n      return key;\n    }\n\n    public String getCredential() {\n      return credential;\n    }\n\n    public String getAcl() {\n      return acl;\n    }\n\n    public String getAlgorithm() {\n      return algorithm;\n    }\n\n    public String getDate() {\n      return date;\n    }\n\n    public String getPolicy() {\n      return policy;\n    }\n\n    public String getSignature() {\n      return signature;\n    }\n\n    public int getId() {\n      return id;\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/SubmitVerificationCodeRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport jakarta.validation.constraints.NotBlank;\n\npublic record SubmitVerificationCodeRequest(@NotBlank String code) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/TransferArchiveResult.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonSubTypes;\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\n@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)\n@JsonSubTypes({\n    @JsonSubTypes.Type(value = RemoteAttachment.class, name = \"success\"),\n    @JsonSubTypes.Type(value = RemoteAttachmentError.class, name = \"error\"),\n})\npublic sealed interface TransferArchiveResult permits RemoteAttachment, RemoteAttachmentError {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/TransferArchiveUploadedRequest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.AssertTrue;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Positive;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\nimport java.util.Optional;\n\npublic record TransferArchiveUploadedRequest(\n    @Min(1)\n    @Max(Device.MAXIMUM_DEVICE_ID)\n    @Schema(description = \"The ID of the device for which the transfer archive has been prepared\")\n    byte destinationDeviceId,\n\n    @Schema(description = \"The registration ID of the destination device\")\n    @Min(0) @Max(Device.MAX_REGISTRATION_ID) int destinationDeviceRegistrationId,\n\n    @NotNull\n    @Valid\n    @Schema(description = \"\"\"\n          The location of the transfer archive if the archive was successfully uploaded, otherwise a error indicating that\n           the upload has failed and the destination device should stop waiting\n          \"\"\", oneOf = {RemoteAttachment.class, RemoteAttachmentError.class})\n    TransferArchiveResult transferArchive) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/UpdateVerificationSessionRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport javax.annotation.Nullable;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.push.PushNotification;\n\npublic record UpdateVerificationSessionRequest(\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"The APNs or FCM device token to which a push challenge can be sent\")\n    @Nullable String pushToken,\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"The type of push token\")\n    @Nullable PushTokenType pushTokenType,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Value received by the device in the push challenge\")\n    @Nullable String pushChallenge,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Captcha token returned after solving a captcha challenge\")\n    @Nullable String captcha,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Mobile country code of the phone subscriber\")\n    @Nullable String mcc,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Mobile network code of the phone subscriber\")\n    @Nullable String mnc) {\n\n  public enum PushTokenType {\n    @JsonProperty(\"apn\")\n    APN,\n    @JsonProperty(\"fcm\")\n    FCM;\n\n    public PushNotification.TokenType toTokenType() {\n      return switch (this) {\n\n        case APN -> PushNotification.TokenType.APN;\n        case FCM -> PushNotification.TokenType.FCM;\n      };\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfig.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\npublic class UserRemoteConfig {\n\n  @JsonProperty\n  @Schema(description = \"Name of the configuration\", example = \"android.exampleFeature\")\n  private String name;\n\n  @JsonProperty\n  @Schema(description = \"Whether the configuration is enabled for the user\")\n  private boolean enabled;\n\n  @JsonProperty\n  @Schema(description = \"The value to be used for the configuration, if it is a non-boolean type\")\n  private String value;\n\n  public UserRemoteConfig() {\n  }\n\n  public UserRemoteConfig(String name, boolean enabled, String value) {\n    this.name = name;\n    this.enabled = enabled;\n    this.value = value;\n  }\n\n  public String getName() {\n    return name;\n  }\n\n  public boolean isEnabled() {\n    return enabled;\n  }\n\n  public String getValue() {\n    return value;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/UserRemoteConfigList.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonFormat;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.time.Instant;\nimport java.util.List;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.util.InstantAdapter;\n\npublic class UserRemoteConfigList {\n\n  @JsonProperty\n  @Schema(description = \"List of remote configurations applicable to the user\")\n  private List<UserRemoteConfig> config;\n\n  @JsonProperty\n  @JsonSerialize(using = InstantAdapter.EpochSecondSerializer.class)\n  @JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)\n  @Schema(description = \"\"\"\n    Timestamp when the configuration was generated. Deprecated in favor of `X-Signal-Timestamp` response header.\n    \"\"\", deprecated = true)\n  @Deprecated\n  private Instant serverEpochTime;\n\n  public UserRemoteConfigList() {}\n\n  public UserRemoteConfigList(List<UserRemoteConfig> config, Instant serverEpochTime) {\n    this.config = config;\n    this.serverEpochTime = serverEpochTime;\n  }\n\n  public List<UserRemoteConfig> getConfig() {\n    return config;\n  }\n\n  public Instant getServerEpochTime() {\n    return serverEpochTime;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/UsernameHashResponse.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.Valid;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\n\npublic record UsernameHashResponse(\n    @Valid\n    @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n    @ExactlySize(AccountController.USERNAME_HASH_LENGTH)\n    @Schema(type = \"string\", description = \"The hash of the confirmed username, as supplied in the request\")\n    byte[] usernameHash,\n\n    @Nullable\n    @Valid\n    @Schema(type = \"string\", description = \"A handle that can be included in username links to retrieve the stored encrypted username\")\n    UUID usernameLinkHandle\n) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/UsernameLinkHandle.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.UUID;\n\npublic record UsernameLinkHandle(\n    @Schema(description = \"A handle that can be included in username links to retrieve the stored encrypted username\")\n    @NotNull\n    UUID usernameLinkHandle) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationCodeRequest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.textsecuregcm.registration.MessageTransport;\n\npublic record VerificationCodeRequest(@Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"Transport via which to send the verification code\")\n                                      @NotNull Transport transport,\n\n                                      @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"Client type to facilitate platform-specific SMS verification\")\n                                      @NotNull String client) {\n\n  public enum Transport {\n    @JsonProperty(\"sms\")\n    SMS,\n    @JsonProperty(\"voice\")\n    VOICE;\n\n    public MessageTransport toMessageTransport() {\n      return switch (this) {\n        case SMS -> MessageTransport.SMS;\n        case VOICE -> MessageTransport.VOICE;\n      };\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/VerificationSessionResponse.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport java.util.List;\nimport javax.annotation.Nullable;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\n\npublic record VerificationSessionResponse(\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"A URL-safe ID for the session\")\n    String id,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Duration in seconds after which next SMS can be requested for this session\")\n    @Nullable Long nextSms,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Duration in seconds after which next voice call can be requested for this session\")\n    @Nullable Long nextCall,\n\n    @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = \"Duration in seconds after which the client can submit a verification code for this session\")\n    @Nullable Long nextVerificationAttempt,\n\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"Whether it is allowed to request a verification code for this session\")\n    boolean allowedToRequestCode,\n\n    @Schema(description = \"A list of requested information that the client needs to submit before requesting code delivery\")\n    List<VerificationSession.Information> requestedInformation,\n\n    @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = \"Whether this session is verified\")\n    boolean verified) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/entities/VersionedProfileResponse.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonUnwrapped;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;\n\n@Schema(description = \"Versioned profile containing encrypted fields. Versioned fields may be empty if the version was not found.\")\npublic record VersionedProfileResponse(\n\n    @Schema(description = \"Base profile information\")\n    @JsonUnwrapped\n    BaseProfileResponse baseProfileResponse,\n\n    @Schema(description = \"Encrypted profile name. Padded base64.\")\n    @JsonProperty\n    @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n    byte[] name,\n\n    @Schema(description = \"Encrypted about text. Padded base64.\")\n    @JsonProperty\n    @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n    byte[] about,\n\n    @Schema(description = \"Encrypted about emoji. Padded base64.\")\n    @JsonProperty\n    @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n    byte[] aboutEmoji,\n\n    @Schema(description = \"Avatar CDN path\")\n    @JsonProperty\n    String avatar,\n\n    @Schema(description = \"Encrypted payment address. Padded base64.\")\n    @JsonProperty\n    @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n    byte[] paymentAddress,\n\n    @Schema(description = \"Encrypted phone number sharing preference. Padded base64.\")\n    @JsonProperty\n    @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n    @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n    byte[] phoneNumberSharing) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/DeviceLastSeenState.java",
    "content": "package org.whispersystems.textsecuregcm.experiment;\n\nimport javax.annotation.Nullable;\n\npublic record DeviceLastSeenState(boolean deviceExists,\n                                  // Registration IDs are not guaranteed to be unique across devices and re-registrations.\n                                  // However, for this use case, we accept the possibility of collisions in order to\n                                  // avoid storing plaintext device creation timestamps on the server.\n                                  // This tradeoff is intentional and aligned with our privacy goals.\n                                  int registrationId,\n                                  boolean hasPushToken,\n                                  long lastSeenMillis,\n                                  @Nullable PushTokenType pushTokenType) {\n\n  public static DeviceLastSeenState MISSING_DEVICE_STATE = new DeviceLastSeenState(false, 0, false, 0, null);\n\n  public enum PushTokenType {\n    APNS,\n    FCM\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/Experiment.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.experiment;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.Executor;\nimport java.util.function.Supplier;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport reactor.core.publisher.Mono;\n\n/**\n * An experiment compares the results of two operations and records metrics to assess how frequently they match.\n */\npublic class Experiment {\n\n  private final String name;\n\n  private final Timer matchTimer;\n  private final Timer errorTimer;\n\n  private final Timer bothPresentMismatchTimer;\n  private final Timer controlNullMismatchTimer;\n  private final Timer experimentNullMismatchTimer;\n\n  private static final String OUTCOME_TAG = \"outcome\";\n  private static final String MATCH_OUTCOME = \"match\";\n  private static final String MISMATCH_OUTCOME = \"mismatch\";\n  private static final String ERROR_OUTCOME = \"error\";\n\n  private static final String MISMATCH_TYPE_TAG = \"mismatchType\";\n  private static final String BOTH_PRESENT_MISMATCH = \"bothPresent\";\n  private static final String CONTROL_NULL_MISMATCH = \"controlResultNull\";\n  private static final String EXPERIMENT_NULL_MISMATCH = \"experimentResultNull\";\n\n  private static final Logger log = LoggerFactory.getLogger(Experiment.class);\n\n  public Experiment(final String... names) {\n    this(name(Experiment.class, names),\n        Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MATCH_OUTCOME),\n        Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, ERROR_OUTCOME),\n        Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MISMATCH_OUTCOME, MISMATCH_TYPE_TAG,\n            BOTH_PRESENT_MISMATCH),\n        Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MISMATCH_OUTCOME, MISMATCH_TYPE_TAG,\n            CONTROL_NULL_MISMATCH),\n        Metrics.timer(name(Experiment.class, names), OUTCOME_TAG, MISMATCH_OUTCOME, MISMATCH_TYPE_TAG,\n            EXPERIMENT_NULL_MISMATCH));\n  }\n\n  @VisibleForTesting\n  Experiment(final String name, final Timer matchTimer, final Timer errorTimer, final Timer bothPresentMismatchTimer,\n      final Timer controlNullMismatchTimer, final Timer experimentNullMismatchTimer) {\n    this.name = name;\n\n    this.matchTimer = matchTimer;\n    this.errorTimer = errorTimer;\n\n    this.bothPresentMismatchTimer = bothPresentMismatchTimer;\n    this.controlNullMismatchTimer = controlNullMismatchTimer;\n    this.experimentNullMismatchTimer = experimentNullMismatchTimer;\n  }\n\n  public <T> void compareMonoResult(final T expected, final Mono<T> experimentMono) {\n    final Timer.Sample sample = Timer.start();\n\n    experimentMono.subscribe(\n        actual -> recordResult(expected, actual, sample),\n        cause -> recordError(cause, sample));\n  }\n\n  public <T> void compareFutureResult(final T expected, final CompletionStage<T> experimentStage) {\n    final Timer.Sample sample = Timer.start();\n\n    experimentStage.whenComplete((actual, cause) -> {\n      if (cause != null) {\n        recordError(cause, sample);\n      } else {\n        recordResult(expected, actual, sample);\n      }\n    });\n  }\n\n  public <T> void compareSupplierResult(final T expected, final Supplier<T> experimentSupplier) {\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      final T result = experimentSupplier.get();\n\n      recordResult(expected, result, sample);\n    } catch (final Exception e) {\n      recordError(e, sample);\n    }\n  }\n\n  public <T> void compareSupplierResultAsync(final T expected, final Supplier<T> experimentSupplier, final Executor executor) {\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      compareFutureResult(expected, CompletableFuture.supplyAsync(experimentSupplier, executor));\n    } catch (final Exception e) {\n      recordError(e, sample);\n    }\n  }\n\n  private void recordError(final Throwable cause, final Timer.Sample sample) {\n    log.warn(\"Experiment {} threw an exception.\", name, cause);\n    sample.stop(errorTimer);\n  }\n\n  @VisibleForTesting\n  <T> void recordResult(final T expected, final T actual, final Timer.Sample sample) {\n    if (expected instanceof Optional && actual instanceof Optional) {\n      recordResult(((Optional) expected).orElse(null), ((Optional) actual).orElse(null), sample);\n    } else {\n      final Timer timer;\n\n      if (Objects.equals(expected, actual)) {\n        timer = matchTimer;\n      } else if (expected == null) {\n        timer = controlNullMismatchTimer;\n      } else if (actual == null) {\n        timer = experimentNullMismatchTimer;\n      } else {\n        timer = bothPresentMismatchTimer;\n      }\n\n      sample.stop(timer);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManager.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.experiment;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.util.Optional;\nimport java.util.Random;\nimport java.util.UUID;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.function.Supplier;\n\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicE164ExperimentEnrollmentConfiguration;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic class ExperimentEnrollmentManager {\n\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  private final Supplier<Random> random;\n\n\n  public ExperimentEnrollmentManager(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n    this(dynamicConfigurationManager, ThreadLocalRandom::current);\n  }\n\n  @VisibleForTesting\n  ExperimentEnrollmentManager(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final Supplier<Random> random) {\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n    this.random = random;\n  }\n\n  public boolean isEnrolled(final UUID accountUuid, final String experimentName) {\n\n    final Optional<DynamicExperimentEnrollmentConfiguration> maybeConfiguration = dynamicConfigurationManager\n        .getConfiguration().getExperimentEnrollmentConfiguration(experimentName);\n\n    return maybeConfiguration\n        .map(config -> isAccountEnrolled(accountUuid, config, experimentName).orElse(false))\n        .orElse(false);\n  }\n\n  private Optional<Boolean> isAccountEnrolled(final UUID accountUuid, DynamicExperimentEnrollmentConfiguration config, String experimentName) {\n    if (config.getExcludedUuids().contains(accountUuid)) {\n      return Optional.of(false);\n    }\n    if (config.getUuidSelector().getUuids().contains(accountUuid)) {\n      final int r = random.get().nextInt(100);\n      return Optional.of(r < config.getUuidSelector().getUuidEnrollmentPercentage());\n    }\n\n    if (isEnrolled(accountUuid, config.getEnrollmentPercentage(), experimentName)) {\n      return Optional.of(true);\n    }\n\n    return Optional.empty();\n  }\n\n  public boolean isEnrolled(final String e164, final UUID accountUuid, final String experimentName) {\n\n    final Optional<DynamicExperimentEnrollmentConfiguration> maybeConfiguration = dynamicConfigurationManager\n        .getConfiguration().getExperimentEnrollmentConfiguration(experimentName);\n\n    return maybeConfiguration\n        .flatMap(config -> isAccountEnrolled(accountUuid, config, experimentName))\n        .orElse(isEnrolled(e164, experimentName));\n  }\n\n  public boolean isEnrolled(final String e164, final String experimentName) {\n\n    final Optional<DynamicE164ExperimentEnrollmentConfiguration> maybeConfiguration = dynamicConfigurationManager\n        .getConfiguration().getE164ExperimentEnrollmentConfiguration(experimentName);\n\n    return maybeConfiguration.map(config -> {\n\n      if (config.getEnrolledE164s().contains(e164)) {\n        return true;\n      }\n\n      if (config.getExcludedE164s().contains(e164)) {\n        return false;\n      }\n\n      {\n        final String countryCode = Util.getCountryCode(e164);\n\n        if (config.getIncludedCountryCodes().contains(countryCode)) {\n          return true;\n        }\n\n        if (config.getExcludedCountryCodes().contains(countryCode)) {\n          return false;\n        }\n      }\n\n      return isEnrolled(e164, config.getEnrollmentPercentage(), experimentName);\n\n    }).orElse(false);\n  }\n\n  private static boolean isEnrolled(final Object entity, final int enrollmentPercentage, final String experimentName) {\n    final int enrollmentHash = ((entity.hashCode() ^ experimentName.hashCode()) & Integer.MAX_VALUE) % 100;\n\n    return enrollmentHash < enrollmentPercentage;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperiment.java",
    "content": "package org.whispersystems.textsecuregcm.experiment;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.workers.IdleWakeupEligibilityChecker;\nimport reactor.core.publisher.Flux;\nimport javax.annotation.Nullable;\nimport java.util.Collections;\nimport java.util.EnumMap;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\n\nabstract class IdleDevicePushNotificationExperiment implements PushNotificationExperiment<DeviceLastSeenState> {\n\n  private final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker;\n\n  private final Logger log = LoggerFactory.getLogger(getClass());\n\n  @VisibleForTesting\n  enum Population {\n    APNS_CONTROL,\n    APNS_EXPERIMENT,\n    FCM_CONTROL,\n    FCM_EXPERIMENT\n  }\n\n  @VisibleForTesting\n  enum Outcome {\n    DELETED,\n    UNINSTALLED,\n    REACTIVATED,\n    UNCHANGED\n  }\n\n  protected IdleDevicePushNotificationExperiment(final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker) {\n    this.idleWakeupEligibilityChecker = idleWakeupEligibilityChecker;\n  }\n\n  @VisibleForTesting\n  boolean hasPushToken(final Device device) {\n    return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId());\n  }\n\n  abstract boolean isIdleDeviceEligible(final Account account, final Device idleDevice, final DeviceLastSeenState state);\n\n  @Override\n  public CompletableFuture<Boolean> isDeviceEligible(final Account account, final Device device) {\n    return idleWakeupEligibilityChecker.isDeviceEligible(account, device).thenApply(idle ->\n        idle && isIdleDeviceEligible(account, device, getState(account, device)));\n  }\n\n  @Override\n  public DeviceLastSeenState getState(@Nullable final Account account, @Nullable final Device device) {\n    if (account != null && device != null) {\n      final DeviceLastSeenState.PushTokenType pushTokenType;\n      if (StringUtils.isNotBlank(device.getApnId())) {\n        pushTokenType = DeviceLastSeenState.PushTokenType.APNS;\n      } else if (StringUtils.isNotBlank(device.getGcmId())) {\n        pushTokenType = DeviceLastSeenState.PushTokenType.FCM;\n      } else {\n        pushTokenType = null;\n      }\n      return new DeviceLastSeenState(true, device.getRegistrationId(IdentityType.ACI), hasPushToken(device), device.getLastSeen(), pushTokenType);\n    } else {\n      return DeviceLastSeenState.MISSING_DEVICE_STATE;\n    }\n  }\n\n  @Override\n  public void analyzeResults(final Flux<PushNotificationExperimentSample<DeviceLastSeenState>> samples) {\n    final Map<Population, Map<Outcome, Integer>> contingencyTable = new EnumMap<>(Population.class);\n\n    samples.doOnNext(sample ->\n            contingencyTable.computeIfAbsent(getPopulation(sample), ignored -> new EnumMap<>(Outcome.class))\n                .merge(getOutcome(sample), 1, Integer::sum))\n        .then()\n        .block();\n\n    final StringBuilder reportBuilder = new StringBuilder(\"population,deleted,uninstalled,reactivated,unchanged\\n\");\n\n    for (final Population population : Population.values()) {\n      final Map<Outcome, Integer> countsByOutcome = contingencyTable.getOrDefault(population, Collections.emptyMap());\n\n      reportBuilder.append(population.name());\n      reportBuilder.append(\",\");\n      reportBuilder.append(countsByOutcome.getOrDefault(Outcome.DELETED, 0));\n      reportBuilder.append(\",\");\n      reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNINSTALLED, 0));\n      reportBuilder.append(\",\");\n      reportBuilder.append(countsByOutcome.getOrDefault(Outcome.REACTIVATED, 0));\n      reportBuilder.append(\",\");\n      reportBuilder.append(countsByOutcome.getOrDefault(Outcome.UNCHANGED, 0));\n      reportBuilder.append(\"\\n\");\n    }\n\n    log.info(reportBuilder.toString());\n  }\n\n  @VisibleForTesting\n  static Population getPopulation(final PushNotificationExperimentSample<DeviceLastSeenState> sample) {\n    assert sample.initialState() != null && sample.initialState().pushTokenType() != null;\n\n    return switch (sample.initialState().pushTokenType()) {\n      case APNS -> sample.inExperimentGroup() ? Population.APNS_EXPERIMENT : Population.APNS_CONTROL;\n      case FCM -> sample.inExperimentGroup() ? Population.FCM_EXPERIMENT : Population.FCM_CONTROL;\n    };\n  }\n\n  @VisibleForTesting\n  static Outcome getOutcome(final PushNotificationExperimentSample<DeviceLastSeenState> sample) {\n    final Outcome outcome;\n\n    assert sample.finalState() != null;\n\n    if (!sample.finalState().deviceExists() || sample.initialState().registrationId() != sample.finalState().registrationId()) {\n      outcome = Outcome.DELETED;\n    } else if (!sample.finalState().hasPushToken()) {\n      outcome = Outcome.UNINSTALLED;\n    } else if (sample.initialState().lastSeenMillis() != sample.finalState().lastSeenMillis()) {\n      outcome = Outcome.REACTIVATED;\n    } else {\n      outcome = Outcome.UNCHANGED;\n    }\n\n    return outcome;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/PushNotificationExperiment.java",
    "content": "\npackage org.whispersystems.textsecuregcm.experiment;\n\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\nimport javax.annotation.Nullable;\nimport java.util.concurrent.CompletableFuture;\n\n/**\n * A push notification selects for eligible devices, applies a control or experimental treatment, and provides a\n * mechanism for comparing device states before and after receiving the treatment.\n *\n * @param <T> the type of state object stored for this experiment\n */\npublic interface PushNotificationExperiment<T> {\n\n  /**\n   * Returns the unique name of this experiment.\n   *\n   * @return the unique name of this experiment\n   */\n  String getExperimentName();\n\n  /**\n   * Tests whether a device is eligible for this experiment. An eligible device may be assigned to either the control\n   * or experiment group within an experiment. Ineligible devices will not participate in the experiment in any way.\n   *\n   * @param account the account to which the device belongs\n   * @param device the device to test for eligibility in this experiment\n   *\n   * @return a future that yields a boolean value indicating whether the target device is eligible for this experiment\n   */\n  CompletableFuture<Boolean> isDeviceEligible(Account account, Device device);\n\n  /**\n   * Returns the class of the state object stored for this experiment.\n   *\n   * @return the class of the state object stored for this experiment\n   */\n  Class<T> getStateClass();\n\n  /**\n   * Generates an experiment specific state \"snapshot\" of the given device. Experiment results are generally evaluated\n   * by comparing a device's state before a treatment is applied and its state after the treatment is applied.\n   *\n   * @param account the account to which the device belongs\n   * @param device the device for which to generate a state \"snapshot\"\n   *\n   * @return an experiment-specific state \"snapshot\" of the given device\n   */\n  T getState(@Nullable Account account, @Nullable Device device);\n\n  /**\n   * Applies a control treatment to the given device. In many cases (and by default) no action is taken for devices in\n   * the control group.\n   *\n   * @param account the account to which the device belongs\n   * @param device the device to which to apply the control treatment for this experiment\n   *\n   * @return a future that completes when the control treatment has been applied for the given device\n   */\n  default CompletableFuture<Void> applyControlTreatment(Account account, Device device) {\n    return CompletableFuture.completedFuture(null);\n  };\n\n  /**\n   * Applies an experimental treatment to the given device. This generally involves sending or scheduling a specific\n   * type of push notification for the given device.\n   *\n   * @param account the account to which the device belongs\n   * @param device the device to which to apply the experimental treatment for this experiment\n   *\n   * @return a future that completes when the experimental treatment has been applied for the given device\n   */\n  CompletableFuture<Void> applyExperimentTreatment(Account account, Device device);\n\n  /**\n   * Consumes a stream of finished samples and emits an analysis of the results via an implementation-specific channel\n   * (e.g. a log message). Implementations must block until all samples have been consumed and analyzed.\n   *\n   * @param samples a stream of finished samples from this experiment\n   */\n  void analyzeResults(Flux<PushNotificationExperimentSample<T>> samples);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/PushNotificationExperimentSample.java",
    "content": "package org.whispersystems.textsecuregcm.experiment;\n\nimport javax.annotation.Nullable;\nimport java.util.UUID;\n\npublic record PushNotificationExperimentSample<T>(UUID accountIdentifier,\n                                                  byte deviceId,\n                                                  boolean inExperimentGroup,\n                                                  T initialState,\n                                                  @Nullable T finalState) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/experiment/PushNotificationExperimentSamples.java",
    "content": "package org.whispersystems.textsecuregcm.experiment;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport java.nio.ByteBuffer;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport com.google.common.annotations.VisibleForTesting;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.function.Tuple2;\nimport reactor.util.function.Tuples;\nimport reactor.util.retry.Retry;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\npublic class PushNotificationExperimentSamples {\n\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n  private final Clock clock;\n\n  // Experiment name; DynamoDB string; partition key\n  public static final String KEY_EXPERIMENT_NAME = \"N\";\n\n  // Combined ACI and device ID; DynamoDB byte array; sort key\n  public static final String ATTR_ACI_AND_DEVICE_ID = \"AD\";\n\n  // Whether the device is enrolled in the experiment group (as opposed to control group); DynamoDB boolean\n  static final String ATTR_IN_EXPERIMENT_GROUP = \"X\";\n\n  // The experiment-specific state of the device at the start of the experiment, represented as a JSON blob; DynamoDB\n  // string\n  static final String ATTR_INITIAL_STATE = \"I\";\n\n  // The experiment-specific state of the device at the end of the experiment, represented as a JSON blob; DynamoDB\n  // string\n  static final String ATTR_FINAL_STATE = \"F\";\n\n  // The time, in seconds since the epoch, at which this sample should be deleted automatically\n  static final String ATTR_TTL = \"E\";\n\n  private static final Duration FINAL_SAMPLE_TTL = Duration.ofDays(7);\n\n  private static final Logger log = LoggerFactory.getLogger(PushNotificationExperimentSamples.class);\n\n  public PushNotificationExperimentSamples(final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final String tableName,\n      final Clock clock) {\n\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n    this.clock = clock;\n  }\n\n  /**\n   * Writes the initial state of a device participating in a push notification experiment.\n   *\n   * @param accountIdentifier the account identifier for the account to which the target device is linked\n   * @param deviceId the identifier for the device within the given account\n   * @param experimentName the name of the experiment\n   * @param inExperimentGroup whether the given device is in the experiment group (as opposed to control group)\n   * @param initialState the initial state of the object; must be serializable as a JSON text\n   *\n   * @return a future that completes when the record has been stored; the future yields {@code true} if a new record\n   * was stored or {@code false} if a conflicting record already exists\n   *\n   * @param <T> the type of state object for this sample\n   *\n   * @throws JsonProcessingException if the given {@code initialState} could not be serialized as a JSON text\n   */\n  public <T> CompletableFuture<Boolean> recordInitialState(final UUID accountIdentifier,\n      final byte deviceId,\n      final String experimentName,\n      final boolean inExperimentGroup,\n      final T initialState) throws JsonProcessingException {\n\n    final AttributeValue initialStateAttributeValue =\n        AttributeValue.fromS(SystemMapper.jsonMapper().writeValueAsString(initialState));\n\n    final AttributeValue inExperimentGroupAttributeValue = AttributeValue.fromBool(inExperimentGroup);\n\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .item(Map.of(\n                KEY_EXPERIMENT_NAME, AttributeValue.fromS(experimentName),\n                ATTR_ACI_AND_DEVICE_ID, buildSortKey(accountIdentifier, deviceId),\n                ATTR_IN_EXPERIMENT_GROUP, inExperimentGroupAttributeValue,\n                ATTR_INITIAL_STATE, initialStateAttributeValue,\n                ATTR_TTL, AttributeValue.fromN(String.valueOf(clock.instant().plus(FINAL_SAMPLE_TTL).getEpochSecond()))))\n            .conditionExpression(\"(attribute_not_exists(#inExperimentGroup) OR #inExperimentGroup = :inExperimentGroup) AND (attribute_not_exists(#initialState) OR #initialState = :initialState) AND attribute_not_exists(#finalState)\")\n            .expressionAttributeNames(Map.of(\n                \"#inExperimentGroup\", ATTR_IN_EXPERIMENT_GROUP,\n                \"#initialState\", ATTR_INITIAL_STATE,\n                \"#finalState\", ATTR_FINAL_STATE))\n            .expressionAttributeValues(Map.of(\n                \":inExperimentGroup\", inExperimentGroupAttributeValue,\n                \":initialState\", initialStateAttributeValue))\n            .build())\n        .thenApply(ignored -> true)\n        .exceptionally(throwable -> {\n          if (ExceptionUtils.unwrap(throwable) instanceof ConditionalCheckFailedException) {\n            return false;\n          }\n\n          throw ExceptionUtils.wrap(throwable);\n        });\n  }\n\n  /**\n   * Writes the final state of a device participating in a push notification experiment.\n   *\n   * @param accountIdentifier the account identifier for the account to which the target device is linked\n   * @param deviceId the identifier for the device within the given account\n   * @param experimentName the name of the experiment\n   * @param finalState the final state of the object; must be serializable as a JSON text and of the same type as the\n   *                   previously-stored initial state\n\n   * @return A future that completes when the final state has been stored; yields a finished sample if an initial sample\n   * was found or empty if no initial sample was found for the given account, device, and experiment. The future may\n   * with a {@link JsonProcessingException} if the initial state could not be read or the final state could not be\n   * written as a JSON text.\n   *\n   * @param <T> the type of state object for this sample\n   */\n  public <T> CompletableFuture<PushNotificationExperimentSample<T>> recordFinalState(final UUID accountIdentifier,\n      final byte deviceId,\n      final String experimentName,\n      final T finalState) {\n\n    CompletableFuture<String> finalStateJsonFuture;\n\n    // Process the final state JSON on the calling thread, but inside a CompletionStage so there's just one \"channel\"\n    // for reporting JSON exceptions. The alternative is to `throw JsonProcessingException`, but then callers would have\n    // to both catch the exception when calling this method and also watch the returned future for the same exception.\n    try {\n      finalStateJsonFuture =\n          CompletableFuture.completedFuture(SystemMapper.jsonMapper().writeValueAsString(finalState));\n    } catch (final JsonProcessingException e) {\n      finalStateJsonFuture = CompletableFuture.failedFuture(e);\n    }\n\n    final AttributeValue aciAndDeviceIdAttributeValue = buildSortKey(accountIdentifier, deviceId);\n\n    return finalStateJsonFuture.thenCompose(finalStateJson -> {\n      return dynamoDbAsyncClient.updateItem(UpdateItemRequest.builder()\n              .tableName(tableName)\n              .key(Map.of(\n                  KEY_EXPERIMENT_NAME, AttributeValue.fromS(experimentName),\n                  ATTR_ACI_AND_DEVICE_ID, aciAndDeviceIdAttributeValue))\n              // `UpdateItem` will, by default, create a new item if one does not already exist for the given primary key. We\n              // want update-only-if-exists behavior, though, and so check that there's already an existing item for this ACI\n              // and device ID.\n              .conditionExpression(\"#aciAndDeviceId = :aciAndDeviceId\")\n              .updateExpression(\"SET #finalState = if_not_exists(#finalState, :finalState)\")\n              .expressionAttributeNames(Map.of(\n                  \"#aciAndDeviceId\", ATTR_ACI_AND_DEVICE_ID,\n                  \"#finalState\", ATTR_FINAL_STATE))\n              .expressionAttributeValues(Map.of(\n                  \":aciAndDeviceId\", aciAndDeviceIdAttributeValue,\n                  \":finalState\", AttributeValue.fromS(finalStateJson)))\n              .returnValues(ReturnValue.ALL_NEW)\n              .build())\n          .thenApply(updateItemResponse -> {\n            try {\n              final boolean inExperimentGroup = updateItemResponse.attributes().get(ATTR_IN_EXPERIMENT_GROUP).bool();\n\n              @SuppressWarnings(\"unchecked\") final T parsedInitialState =\n                  (T) parseState(updateItemResponse.attributes().get(ATTR_INITIAL_STATE).s(), finalState.getClass());\n\n              @SuppressWarnings(\"unchecked\") final T parsedFinalState =\n                  (T) parseState(updateItemResponse.attributes().get(ATTR_FINAL_STATE).s(), finalState.getClass());\n\n              return new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, parsedInitialState, parsedFinalState);\n            } catch (final JsonProcessingException e) {\n              throw ExceptionUtils.wrap(e);\n            }\n          });\n    });\n  }\n\n  /**\n   * Returns a publisher across all samples for a given experiment.\n   *\n   * @param experimentName the name of the experiment for which to fetch samples\n   * @param stateClass the type of state object for sample in the given experiment\n   *\n   * @return a publisher of tuples of ACI, device ID, and sample for all samples associated with the given experiment\n   *\n   * @param <T> the type of the sample's state objects\n   *\n   * @see <a href=\"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Scan.html#Scan.ParallelScan\">Working with scans - Parallel scan</a>\n   */\n  public <T> Flux<PushNotificationExperimentSample<T>> getSamples(final String experimentName, final Class<T> stateClass) {\n\n    return Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n                .tableName(tableName)\n                .keyConditionExpression(\"#experiment = :experiment\")\n                .expressionAttributeNames(Map.of(\"#experiment\", KEY_EXPERIMENT_NAME))\n                .expressionAttributeValues(Map.of(\":experiment\", AttributeValue.fromS(experimentName)))\n                .build())\n            .items())\n        .handle((item, sink) -> {\n          try {\n            final Tuple2<UUID, Byte> aciAndDeviceId = parseSortKey(item.get(ATTR_ACI_AND_DEVICE_ID));\n\n            final boolean inExperimentGroup = item.get(ATTR_IN_EXPERIMENT_GROUP).bool();\n            final T initialState = parseState(item.get(ATTR_INITIAL_STATE).s(), stateClass);\n            final T finalState = item.get(ATTR_FINAL_STATE) != null\n                ? parseState(item.get(ATTR_FINAL_STATE).s(), stateClass)\n                : null;\n\n            sink.next(new PushNotificationExperimentSample<>(aciAndDeviceId.getT1(), aciAndDeviceId.getT2(), inExperimentGroup, initialState, finalState));\n          } catch (final JsonProcessingException e) {\n            sink.error(e);\n          }\n        });\n  }\n\n  public CompletableFuture<Void> discardSamples(final String experimentName, final int maxConcurrency) {\n    final AttributeValue experimentNameAttributeValue = AttributeValue.fromS(experimentName);\n\n    return Flux.from(dynamoDbAsyncClient.scanPaginator(ScanRequest.builder()\n                .tableName(tableName)\n                .filterExpression(\"#experiment = :experiment\")\n                .expressionAttributeNames(Map.of(\"#experiment\", KEY_EXPERIMENT_NAME))\n                .expressionAttributeValues(Map.of(\":experiment\", experimentNameAttributeValue))\n                .projectionExpression(ATTR_ACI_AND_DEVICE_ID)\n                .build())\n            .items())\n        .map(item -> item.get(ATTR_ACI_AND_DEVICE_ID))\n        .flatMap(aciAndDeviceId -> Mono.fromFuture(() -> dynamoDbAsyncClient.deleteItem(DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_EXPERIMENT_NAME, experimentNameAttributeValue,\n                ATTR_ACI_AND_DEVICE_ID, aciAndDeviceId))\n            .build()))\n            .retryWhen(Retry.backoff(5, Duration.ofSeconds(5)))\n            .onErrorResume(throwable -> {\n              log.warn(\"Failed to delete sample for experiment {}\", experimentName, throwable);\n              return Mono.empty();\n            }), maxConcurrency)\n        .then()\n        .toFuture();\n  }\n\n  @VisibleForTesting\n  static AttributeValue buildSortKey(final UUID accountIdentifier, final byte deviceId) {\n    return AttributeValue.fromB(SdkBytes.fromByteBuffer(ByteBuffer.allocate(17)\n        .putLong(accountIdentifier.getMostSignificantBits())\n        .putLong(accountIdentifier.getLeastSignificantBits())\n        .put(deviceId)\n        .flip()));\n  }\n\n  private static Tuple2<UUID, Byte> parseSortKey(final AttributeValue sortKey) {\n    final ByteBuffer byteBuffer = sortKey.b().asByteBuffer();\n\n    return Tuples.of(new UUID(byteBuffer.getLong(), byteBuffer.getLong()), byteBuffer.get());\n  }\n\n  private static <T> T parseState(final String state, final Class<T> clazz) throws JsonProcessingException {\n    return SystemMapper.jsonMapper().readValue(state, clazz);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilter.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.grpc.Metadata;\nimport io.grpc.MethodDescriptor;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.servlet.Filter;\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.ServletRequest;\nimport jakarta.servlet.ServletResponse;\nimport jakarta.servlet.http.HttpServletResponse;\nimport java.io.IOException;\nimport java.net.InetAddress;\nimport java.util.Set;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil;\nimport org.whispersystems.textsecuregcm.util.InetAddressRange;\n\npublic class ExternalRequestFilter implements Filter, ServerInterceptor {\n\n  private static final Logger logger = LoggerFactory.getLogger(ExternalRequestFilter.class);\n\n  private static final String REQUESTS_COUNTER_NAME = name(ExternalRequestFilter.class, \"requests\");\n  private static final String PROTOCOL_TAG_NAME = \"protocol\";\n  private static final String BLOCKED_TAG_NAME = \"blocked\";\n\n  private final Set<InetAddressRange> permittedInternalAddressRanges;\n  private final Set<String> filteredGrpcMethodNames;\n\n  public ExternalRequestFilter(final Set<InetAddressRange> permittedInternalAddressRanges,\n      final Set<String> filteredGrpcMethodNames) {\n      this.permittedInternalAddressRanges = permittedInternalAddressRanges;\n    this.filteredGrpcMethodNames = filteredGrpcMethodNames;\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,\n      final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {\n\n    final MethodDescriptor<ReqT, RespT> methodDescriptor = call.getMethodDescriptor();\n    final boolean shouldFilterMethod = filteredGrpcMethodNames.contains(methodDescriptor.getFullMethodName());\n\n    final InetAddress remoteAddress = RequestAttributesUtil.getRemoteAddress();\n    final boolean blocked = shouldFilterMethod && shouldBlock(remoteAddress);\n\n    Metrics.counter(REQUESTS_COUNTER_NAME,\n            PROTOCOL_TAG_NAME, \"grpc\",\n            BLOCKED_TAG_NAME, String.valueOf(blocked))\n        .increment();\n\n    if (blocked) {\n      call.close(Status.NOT_FOUND, new Metadata());\n      return new ServerCall.Listener<>() {};\n    }\n\n    return next.startCall(call, headers);\n  }\n\n  @Override\n  public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)\n      throws IOException, ServletException {\n\n    final InetAddress remoteInetAddress = InetAddress.getByName(\n        (String) request.getAttribute(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME));\n    final boolean restricted = shouldBlock(remoteInetAddress);\n\n    Metrics.counter(REQUESTS_COUNTER_NAME,\n            PROTOCOL_TAG_NAME, \"http\",\n            BLOCKED_TAG_NAME, String.valueOf(restricted))\n        .increment();\n\n    if (restricted) {\n      if (response instanceof HttpServletResponse hsr) {\n        hsr.setStatus(404);\n      } else {\n        logger.warn(\"response was an unexpected type: {}\", response.getClass());\n      }\n      return;\n    }\n\n    chain.doFilter(request, response);\n  }\n\n  public boolean shouldBlock(InetAddress remoteAddress) {\n    return permittedInternalAddressRanges.stream()\n        .noneMatch(range -> range.contains(remoteAddress));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteAddressFilter.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport jakarta.servlet.Filter;\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.ServletRequest;\nimport jakarta.servlet.ServletResponse;\nimport jakarta.servlet.http.HttpServletRequest;\nimport java.io.IOException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.HttpServletRequestUtil;\n\n/**\n * Sets a {@link HttpServletRequest} attribute (that will also be available as a\n * {@link jakarta.ws.rs.container.ContainerRequestContext} property) with the remote address of the connection, using\n * {@link HttpServletRequest#getRemoteAddr()}.\n */\npublic class RemoteAddressFilter implements Filter {\n\n  public static final String REMOTE_ADDRESS_ATTRIBUTE_NAME = RemoteAddressFilter.class.getName() + \".remoteAddress\";\n  private static final Logger logger = LoggerFactory.getLogger(RemoteAddressFilter.class);\n\n\n  public RemoteAddressFilter() {\n  }\n\n  @Override\n  public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)\n      throws ServletException, IOException {\n\n    if (request instanceof HttpServletRequest httpServletRequest) {\n\n      final String remoteAddress = HttpServletRequestUtil.getRemoteAddress(httpServletRequest);\n      request.setAttribute(REMOTE_ADDRESS_ATTRIBUTE_NAME, remoteAddress);\n\n    } else {\n      logger.warn(\"request was of unexpected type: {}\", request.getClass());\n    }\n\n    chain.doFilter(request, response);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilter.java",
    "content": "/*\n * Copyright 2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.net.HttpHeaders;\nimport com.vdurmont.semver4j.Semver;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.servlet.Filter;\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.ServletRequest;\nimport jakarta.servlet.ServletResponse;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport java.io.IOException;\nimport java.util.Map;\nimport java.util.Set;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;\nimport org.whispersystems.textsecuregcm.grpc.GrpcExceptions;\nimport org.whispersystems.textsecuregcm.grpc.RequestAttributesUtil;\nimport org.whispersystems.textsecuregcm.grpc.ServerInterceptorUtil;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n/**\n * The remote deprecation filter rejects traffic from clients older than a configured minimum\n * version. It may optionally also reject traffic from clients with unrecognized User-Agent strings.\n * If a client platform does not have a configured minimum version, all traffic from that client\n * platform is allowed.\n */\npublic class RemoteDeprecationFilter implements Filter, ServerInterceptor {\n\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n\n  private static final String DEPRECATED_CLIENT_COUNTER_NAME = name(RemoteDeprecationFilter.class, \"deprecated\");\n  private static final String PENDING_DEPRECATION_COUNTER_NAME = name(RemoteDeprecationFilter.class, \"pendingDeprecation\");\n  private static final String PLATFORM_TAG = \"platform\";\n  private static final String REASON_TAG_NAME = \"reason\";\n  private static final String EXPIRED_CLIENT_REASON = \"expired\";\n  private static final String BLOCKED_CLIENT_REASON = \"blocked\";\n  private static final String UNRECOGNIZED_UA_REASON = \"unrecognized_user_agent\";\n\n  public RemoteDeprecationFilter(final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n  }\n\n  @Override\n  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {\n    final String userAgentString = ((HttpServletRequest) request).getHeader(HttpHeaders.USER_AGENT);\n\n    UserAgent userAgent;\n    try {\n      userAgent = UserAgentUtil.parseUserAgentString(userAgentString);\n    } catch (final UnrecognizedUserAgentException e) {\n      userAgent = null;\n    }\n\n    if (shouldBlock(userAgent)) {\n      ((HttpServletResponse) response).sendError(499);\n    } else {\n      chain.doFilter(request, response);\n    }\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(\n      final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n\n    @Nullable final UserAgent userAgent = RequestAttributesUtil.getUserAgent()\n        .map(userAgentString -> {\n          try {\n            return UserAgentUtil.parseUserAgentString(userAgentString);\n          } catch (final UnrecognizedUserAgentException e) {\n            return null;\n          }\n        }).orElse(null);\n\n    if (shouldBlock(userAgent)) {\n      return ServerInterceptorUtil.closeWithStatusException(call, GrpcExceptions.upgradeRequired());\n    } else {\n      return next.startCall(call, headers);\n    }\n  }\n\n  private boolean shouldBlock(@Nullable final UserAgent userAgent) {\n    final DynamicRemoteDeprecationConfiguration configuration = dynamicConfigurationManager\n        .getConfiguration().getRemoteDeprecationConfiguration();\n    final Map<ClientPlatform, Semver> minimumVersionsByPlatform = configuration.getMinimumVersions();\n    final Map<ClientPlatform, Semver> versionsPendingDeprecationByPlatform = configuration\n        .getVersionsPendingDeprecation();\n    final Map<ClientPlatform, Set<Semver>> blockedVersionsByPlatform = configuration.getBlockedVersions();\n    final Map<ClientPlatform, Set<Semver>> versionsPendingBlockByPlatform = configuration.getVersionsPendingBlock();\n\n    boolean shouldBlock = false;\n\n    if (userAgent == null) {\n      if  (configuration.isUnrecognizedUserAgentAllowed()) {\n        return false;\n      }\n      recordDeprecation(null, UNRECOGNIZED_UA_REASON);\n      return true;\n    }\n\n    if (blockedVersionsByPlatform.containsKey(userAgent.platform())) {\n      if (blockedVersionsByPlatform.get(userAgent.platform()).contains(userAgent.version())) {\n        recordDeprecation(userAgent, BLOCKED_CLIENT_REASON);\n        shouldBlock = true;\n      }\n    }\n\n    if (minimumVersionsByPlatform.containsKey(userAgent.platform())) {\n      if (userAgent.version().isLowerThan(minimumVersionsByPlatform.get(userAgent.platform()))) {\n        recordDeprecation(userAgent, EXPIRED_CLIENT_REASON);\n        shouldBlock = true;\n      }\n    }\n\n    if (versionsPendingBlockByPlatform.containsKey(userAgent.platform())) {\n      if (versionsPendingBlockByPlatform.get(userAgent.platform()).contains(userAgent.version())) {\n        recordPendingDeprecation(userAgent, BLOCKED_CLIENT_REASON);\n      }\n    }\n\n    if (versionsPendingDeprecationByPlatform.containsKey(userAgent.platform())) {\n      if (userAgent.version().isLowerThan(versionsPendingDeprecationByPlatform.get(userAgent.platform()))) {\n        recordPendingDeprecation(userAgent, EXPIRED_CLIENT_REASON);\n      }\n    }\n\n    return shouldBlock;\n  }\n\n  private void recordDeprecation(final UserAgent userAgent, final String reason) {\n    Metrics.counter(DEPRECATED_CLIENT_COUNTER_NAME,\n        PLATFORM_TAG, userAgent != null ? userAgent.platform().name().toLowerCase() : \"unrecognized\",\n        REASON_TAG_NAME, reason).increment();\n  }\n\n  private void recordPendingDeprecation(final UserAgent userAgent, final String reason) {\n    Metrics.counter(PENDING_DEPRECATION_COUNTER_NAME,\n        PLATFORM_TAG, userAgent.platform().name().toLowerCase(),\n        REASON_TAG_NAME, reason).increment();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilter.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.net.InetAddresses;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerRequestFilter;\nimport java.io.IOException;\nimport java.net.Inet4Address;\nimport java.net.Inet6Address;\nimport java.net.InetAddress;\nimport javax.annotation.Nonnull;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.TrafficSource;\n\npublic class RequestStatisticsFilter implements ContainerRequestFilter {\n\n  private static final Logger logger = LoggerFactory.getLogger(RequestStatisticsFilter.class);\n\n  private static final String CONTENT_LENGTH_DISTRIBUTION_NAME = name(RequestStatisticsFilter.class, \"contentLength\");\n\n  private static final String IP_VERSION_METRIC = MetricsUtil.name(RequestStatisticsFilter.class, \"ipVersion\");\n\n  private static final String TRAFFIC_SOURCE_TAG = \"trafficSource\";\n\n  private static final String IP_VERSION_TAG = \"ipVersion\";\n\n  @Nonnull\n  private final String trafficSourceTag;\n\n\n  public RequestStatisticsFilter(@Nonnull final TrafficSource trafficeSource) {\n    this.trafficSourceTag = requireNonNull(trafficeSource).name().toLowerCase();\n  }\n\n  @Override\n  public void filter(final ContainerRequestContext requestContext) throws IOException {\n    try {\n      Metrics.summary(CONTENT_LENGTH_DISTRIBUTION_NAME, TRAFFIC_SOURCE_TAG, trafficSourceTag)\n          .record(requestContext.getLength());\n      Metrics.counter(IP_VERSION_METRIC, TRAFFIC_SOURCE_TAG, trafficSourceTag, IP_VERSION_TAG,\n              resolveIpVersion(requestContext))\n          .increment();\n    } catch (final Exception e) {\n      logger.warn(\"Error recording request statistics\", e);\n    }\n  }\n\n  @Nonnull\n  private static String resolveIpVersion(@Nonnull final ContainerRequestContext ctx) {\n    final String ipString = (String) ctx.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n    try {\n      final InetAddress addr = InetAddresses.forString(ipString);\n      if (addr instanceof Inet4Address) {\n        return \"IPv4\";\n      }\n      if (addr instanceof Inet6Address) {\n        return \"IPv6\";\n      }\n    } catch (IllegalArgumentException e) {\n      // ignore illegal argument exception\n    }\n    return \"unresolved\";\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilter.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport com.vdurmont.semver4j.Semver;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerRequestFilter;\nimport jakarta.ws.rs.core.SecurityContext;\nimport java.io.IOException;\nimport java.util.Random;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.function.Supplier;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRestDeprecationConfiguration.PlatformConfiguration;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\npublic class RestDeprecationFilter implements ContainerRequestFilter {\n\n  private static final String AUTHENTICATED_EXPERIMENT_NAME = \"restDeprecation\";\n  private static final String DEPRECATED_REST_COUNTER_NAME = MetricsUtil.name(RestDeprecationFilter.class, \"blockedRestRequest\");\n\n  private static final Logger log = LoggerFactory.getLogger(RestDeprecationFilter.class);\n\n  final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  final ExperimentEnrollmentManager experimentEnrollmentManager;\n  final Supplier<Random> random;\n\n  public RestDeprecationFilter(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) {\n    this(dynamicConfigurationManager, experimentEnrollmentManager, ThreadLocalRandom::current);\n  }\n\n  @VisibleForTesting\n  public RestDeprecationFilter(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final ExperimentEnrollmentManager experimentEnrollmentManager,\n      final Supplier<Random> random) {\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n    this.random = random;\n  }\n\n  @Override\n  public void filter(final ContainerRequestContext requestContext) throws IOException {\n\n    final String userAgentString = requestContext.getHeaderString(HttpHeaders.USER_AGENT);\n\n    try {\n      final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);\n      final ClientPlatform platform = userAgent.platform();\n      final Semver version = userAgent.version();\n      final PlatformConfiguration config = dynamicConfigurationManager.getConfiguration().restDeprecation().platforms().get(platform);\n      if (config == null) {\n        return;\n      }\n      if (!isEnrolled(requestContext, config.universalRolloutPercent())) {\n        return;\n      }\n      if (version.isGreaterThanOrEqualTo(config.minimumRestFreeVersion())) {\n        Metrics.counter(\n            DEPRECATED_REST_COUNTER_NAME, Tags.of(\"platform\", platform.name().toLowerCase(), \"version\", version.toString()))\n            .increment();\n        throw new WebApplicationException(\"use websockets\", 498);\n      }\n    } catch (final UnrecognizedUserAgentException e) {\n      return;                   // at present we're only interested in experimenting on known clients\n    }\n  }\n\n  private boolean isEnrolled(final ContainerRequestContext requestContext, int universalRolloutPercent) {\n    if (random.get().nextInt(100) < universalRolloutPercent) {\n      return true;\n    }\n\n    final SecurityContext securityContext = requestContext.getSecurityContext();\n\n    if (securityContext == null || securityContext.getUserPrincipal() == null) {\n      return false;\n    }\n\n    if (securityContext.getUserPrincipal() instanceof AuthenticatedDevice authenticatedDevice) {\n      return experimentEnrollmentManager.isEnrolled(authenticatedDevice.accountIdentifier(), AUTHENTICATED_EXPERIMENT_NAME);\n    } else {\n      log.error(\"Security context was not null but user principal was of type {}\", securityContext.getUserPrincipal().getClass().getName());\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport jakarta.servlet.Filter;\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.ServletRequest;\nimport jakarta.servlet.ServletResponse;\nimport jakarta.servlet.http.HttpServletResponse;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerResponseContext;\nimport jakarta.ws.rs.container.ContainerResponseFilter;\nimport java.io.IOException;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\n\n/**\n * Injects a timestamp header into all outbound responses.\n */\npublic class TimestampResponseFilter implements Filter, ContainerResponseFilter {\n\n  @Override\n  public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)\n      throws ServletException, IOException {\n\n    if (response instanceof HttpServletResponse httpServletResponse) {\n      httpServletResponse.setHeader(HeaderUtils.TIMESTAMP_HEADER, String.valueOf(System.currentTimeMillis()));\n    }\n\n    chain.doFilter(request, response);\n  }\n\n  @Override\n  public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {\n    // not using add() - it's ok to overwrite any existing header, and we don't want a multi-value\n    responseContext.getHeaders().putSingle(HeaderUtils.TIMESTAMP_HEADER, System.currentTimeMillis());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.gcp;\n\nimport javax.annotation.Nonnull;\n\npublic class CanonicalRequest {\n\n  @Nonnull\n  private final String canonicalRequest;\n\n  @Nonnull\n  private final String resourcePath;\n\n  @Nonnull\n  private final String canonicalQuery;\n\n  @Nonnull\n  private final String activeDatetime;\n\n  @Nonnull\n  private final String credentialScope;\n\n  @Nonnull\n  private final String domain;\n\n  private final int maxSizeInBytes;\n\n  public CanonicalRequest(@Nonnull String canonicalRequest, @Nonnull String resourcePath, @Nonnull String canonicalQuery, @Nonnull String activeDatetime, @Nonnull String credentialScope, @Nonnull String domain, int maxSizeInBytes) {\n    this.canonicalRequest = canonicalRequest;\n    this.resourcePath     = resourcePath;\n    this.canonicalQuery   = canonicalQuery;\n    this.activeDatetime   = activeDatetime;\n    this.credentialScope  = credentialScope;\n    this.domain           = domain;\n    this.maxSizeInBytes   = maxSizeInBytes;\n  }\n\n  @Nonnull\n  String getCanonicalRequest() {\n    return canonicalRequest;\n  }\n\n  @Nonnull\n  public String getResourcePath() {\n    return resourcePath;\n  }\n\n  @Nonnull\n  public String getCanonicalQuery() {\n    return canonicalQuery;\n  }\n\n  @Nonnull\n  String getActiveDatetime() {\n    return activeDatetime;\n  }\n\n  @Nonnull\n  String getCredentialScope() {\n    return credentialScope;\n  }\n\n  @Nonnull\n  public String getDomain() {\n    return domain;\n  }\n\n  public int getMaxSizeInBytes() {\n    return maxSizeInBytes;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestGenerator.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.gcp;\n\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.ZoneOffset;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Locale;\nimport javax.annotation.Nonnull;\nimport org.apache.commons.lang3.StringUtils;\n\npublic class CanonicalRequestGenerator {\n  private static final DateTimeFormatter SIMPLE_UTC_DATE = DateTimeFormatter.ofPattern(\"yyyyMMdd\", Locale.US).withZone(ZoneOffset.UTC);\n  private static final DateTimeFormatter SIMPLE_UTC_DATE_TIME = DateTimeFormatter.ofPattern(\"yyyyMMdd'T'HHmmss'Z'\", Locale.US).withZone(ZoneOffset.UTC);\n\n  @Nonnull\n  private final String domain;\n\n  @Nonnull\n  private final String email;\n\n  private final int maxSizeBytes;\n\n  @Nonnull\n  private final String pathPrefix;\n\n  public CanonicalRequestGenerator(@Nonnull String domain, @Nonnull String email, int maxSizeBytes, @Nonnull String pathPrefix) {\n    this.domain       = domain;\n    this.email        = email;\n    this.maxSizeBytes = maxSizeBytes;\n    this.pathPrefix   = pathPrefix;\n  }\n\n  public CanonicalRequest createFor(@Nonnull final String key, @Nonnull final ZonedDateTime now) {\n    final StringBuilder result = new StringBuilder(\"POST\\n\");\n\n    final StringBuilder resourcePathBuilder = new StringBuilder();\n    if (StringUtils.isNotEmpty(pathPrefix)) {\n      resourcePathBuilder.append(pathPrefix);\n    }\n    resourcePathBuilder.append('/').append(URLEncoder.encode(key, StandardCharsets.UTF_8));\n    final String resourcePath = resourcePathBuilder.toString();\n    result.append(resourcePath).append('\\n');\n\n    final String activeDatetime = SIMPLE_UTC_DATE_TIME.format(now);\n    final String canonicalQuery = \"X-Goog-Algorithm=GOOG4-RSA-SHA256\" +\n            \"&X-Goog-Credential=\" + URLEncoder.encode(makeCredential(email, now), StandardCharsets.UTF_8) +\n            \"&X-Goog-Date=\" + URLEncoder.encode(activeDatetime, StandardCharsets.UTF_8) +\n            \"&X-Goog-Expires=\" + Duration.of(25, ChronoUnit.HOURS).toSeconds() +\n            \"&X-Goog-SignedHeaders=host%3Bx-goog-content-length-range%3Bx-goog-resumable\";\n    result.append(canonicalQuery).append('\\n');\n\n    result.append(\"host:\").append(domain).append('\\n');\n    result.append(\"x-goog-content-length-range:1,\").append(maxSizeBytes).append('\\n');\n    result.append(\"x-goog-resumable:start\\n\");\n    result.append('\\n');\n\n    result.append(\"host;x-goog-content-length-range;x-goog-resumable\\n\");\n\n    result.append(\"UNSIGNED-PAYLOAD\");\n\n    return new CanonicalRequest(result.toString(), resourcePath, canonicalQuery, activeDatetime, makeCredentialScope(now), domain, maxSizeBytes);\n  }\n\n  private String makeCredentialScope(@Nonnull ZonedDateTime now) {\n    return SIMPLE_UTC_DATE.format(now) + \"/auto/storage/goog4_request\";\n  }\n\n  private String makeCredential(@Nonnull String email, @Nonnull ZonedDateTime now) {\n    return email + '/' + makeCredentialScope(now);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/gcp/CanonicalRequestSigner.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.gcp;\n\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.KeyFactory;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.PrivateKey;\nimport java.security.Signature;\nimport java.security.SignatureException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.security.spec.PKCS8EncodedKeySpec;\nimport java.util.Base64;\nimport java.util.HexFormat;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport javax.annotation.Nonnull;\n\npublic class CanonicalRequestSigner {\n\n  @Nonnull\n  private final PrivateKey rsaSigningKey;\n\n  private static final Pattern PRIVATE_KEY_PATTERN =\n      Pattern.compile(\"^-+BEGIN PRIVATE KEY-+\\\\s*(.+)\\\\n-+END PRIVATE KEY-+\\\\s*$\", Pattern.DOTALL);\n\n  public CanonicalRequestSigner(@Nonnull String rsaSigningKey) throws IOException, InvalidKeyException, InvalidKeySpecException {\n    this.rsaSigningKey = initializeRsaSigningKey(rsaSigningKey);\n  }\n\n  public String sign(@Nonnull CanonicalRequest canonicalRequest) {\n    return sign(makeStringToSign(canonicalRequest));\n  }\n\n  private String makeStringToSign(@Nonnull final CanonicalRequest canonicalRequest) {\n    final StringBuilder result = new StringBuilder(\"GOOG4-RSA-SHA256\\n\");\n\n    result.append(canonicalRequest.getActiveDatetime()).append('\\n');\n\n    result.append(canonicalRequest.getCredentialScope()).append('\\n');\n\n    final MessageDigest sha256;\n    try {\n      sha256 = MessageDigest.getInstance(\"SHA-256\");\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n    sha256.update(canonicalRequest.getCanonicalRequest().getBytes(StandardCharsets.UTF_8));\n    result.append(HexFormat.of().formatHex(sha256.digest()));\n\n    return result.toString();\n  }\n\n  private String sign(@Nonnull String stringToSign) {\n    final byte[] signature;\n    try {\n      final Signature sha256rsa = Signature.getInstance(\"SHA256WITHRSA\");\n      sha256rsa.initSign(rsaSigningKey);\n      sha256rsa.update(stringToSign.getBytes(StandardCharsets.UTF_8));\n      signature = sha256rsa.sign();\n    } catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {\n      throw new AssertionError(e);\n    }\n    return HexFormat.of().formatHex(signature);\n  }\n\n  private static PrivateKey initializeRsaSigningKey(String rsaSigningKey) throws IOException, InvalidKeyException, InvalidKeySpecException {\n    final Matcher matcher = PRIVATE_KEY_PATTERN.matcher(rsaSigningKey);\n\n    if (matcher.matches()) {\n      try {\n        final KeyFactory keyFactory = KeyFactory.getInstance(\"RSA\");\n        final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getMimeDecoder().decode(matcher.group(1)));\n        final PrivateKey key = keyFactory.generatePrivate(keySpec);\n\n        testKeyIsValidForSigning(key);\n        return key;\n      } catch (NoSuchAlgorithmException e) {\n        throw new AssertionError(e);\n      }\n    }\n\n    throw new IOException(\"Invalid RSA key\");\n  }\n\n  private static void testKeyIsValidForSigning(PrivateKey key) throws InvalidKeyException {\n    final Signature sha256rsa;\n    try {\n      sha256rsa = Signature.getInstance(\"SHA256WITHRSA\");\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n    sha256rsa.initSign(key);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport java.util.UUID;\nimport org.signal.chat.account.CheckAccountExistenceRequest;\nimport org.signal.chat.account.CheckAccountExistenceResponse;\nimport org.signal.chat.account.LookupUsernameHashRequest;\nimport org.signal.chat.account.LookupUsernameHashResponse;\nimport org.signal.chat.account.LookupUsernameLinkRequest;\nimport org.signal.chat.account.LookupUsernameLinkResponse;\nimport org.signal.chat.account.SimpleAccountsAnonymousGrpc;\nimport org.signal.chat.errors.NotFound;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class AccountsAnonymousGrpcService extends SimpleAccountsAnonymousGrpc.AccountsAnonymousImplBase {\n\n  private final AccountsManager accountsManager;\n  private final RateLimiters rateLimiters;\n\n  public AccountsAnonymousGrpcService(final AccountsManager accountsManager, final RateLimiters rateLimiters) {\n    this.accountsManager = accountsManager;\n    this.rateLimiters = rateLimiters;\n  }\n\n  @Override\n  public CheckAccountExistenceResponse checkAccountExistence(final CheckAccountExistenceRequest request)\n      throws RateLimitExceededException {\n\n    final ServiceIdentifier serviceIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());\n\n    RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getCheckAccountExistenceLimiter());\n\n    return CheckAccountExistenceResponse.newBuilder()\n        .setAccountExists(accountsManager.getByServiceIdentifier(serviceIdentifier).isPresent())\n        .build();\n  }\n\n  @Override\n  public LookupUsernameHashResponse lookupUsernameHash(final LookupUsernameHashRequest request)\n      throws RateLimitExceededException {\n\n    RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLookupLimiter());\n\n    return accountsManager.getByUsernameHash(request.getUsernameHash().toByteArray()).join()\n        .map(account -> LookupUsernameHashResponse.newBuilder()\n            .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))\n            .build())\n        .orElseGet(() -> LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build());\n  }\n\n  @Override\n  public LookupUsernameLinkResponse lookupUsernameLink(final LookupUsernameLinkRequest request)\n      throws RateLimitExceededException {\n    final UUID linkHandle;\n\n    try {\n      linkHandle = UUIDUtil.fromByteString(request.getUsernameLinkHandle());\n    } catch (final IllegalArgumentException e) {\n      throw GrpcExceptions.fieldViolation(\"username_link_handle\", \"Could not interpret link handle as UUID\");\n    }\n\n    RateLimitUtil.rateLimitByRemoteAddress(rateLimiters.getUsernameLinkLookupLimiter());\n\n    return accountsManager.getByUsernameLinkHandle(linkHandle).join()\n        .flatMap(Account::getEncryptedUsername)\n        .map(usernameCiphertext -> LookupUsernameLinkResponse.newBuilder()\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .build())\n        .orElseGet(() -> LookupUsernameLinkResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport java.util.ArrayList;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.UUID;\nimport org.signal.chat.account.ClearRegistrationLockRequest;\nimport org.signal.chat.account.ClearRegistrationLockResponse;\nimport org.signal.chat.account.ConfigureUnidentifiedAccessRequest;\nimport org.signal.chat.account.ConfigureUnidentifiedAccessResponse;\nimport org.signal.chat.account.ConfirmUsernameHashRequest;\nimport org.signal.chat.account.ConfirmUsernameHashResponse;\nimport org.signal.chat.account.DeleteAccountRequest;\nimport org.signal.chat.account.DeleteAccountResponse;\nimport org.signal.chat.account.DeleteUsernameHashRequest;\nimport org.signal.chat.account.DeleteUsernameHashResponse;\nimport org.signal.chat.account.DeleteUsernameLinkRequest;\nimport org.signal.chat.account.DeleteUsernameLinkResponse;\nimport org.signal.chat.account.GetAccountIdentityRequest;\nimport org.signal.chat.account.GetAccountIdentityResponse;\nimport org.signal.chat.account.ReserveUsernameHashRequest;\nimport org.signal.chat.account.ReserveUsernameHashResponse;\nimport org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;\nimport org.signal.chat.account.SetDiscoverableByPhoneNumberResponse;\nimport org.signal.chat.account.SetRegistrationLockRequest;\nimport org.signal.chat.account.SetRegistrationLockResponse;\nimport org.signal.chat.account.SetRegistrationRecoveryPasswordRequest;\nimport org.signal.chat.account.SetRegistrationRecoveryPasswordResponse;\nimport org.signal.chat.account.SetUsernameLinkRequest;\nimport org.signal.chat.account.SetUsernameLinkResponse;\nimport org.signal.chat.account.SimpleAccountsGrpc;\nimport org.signal.chat.account.UsernameNotAvailable;\nimport org.signal.chat.common.AccountIdentifiers;\nimport org.signal.chat.errors.FailedPrecondition;\nimport org.signal.libsignal.usernames.BaseUsernameException;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;\nimport org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;\n\npublic class AccountsGrpcService extends SimpleAccountsGrpc.AccountsImplBase {\n\n  private final AccountsManager accountsManager;\n  private final RateLimiters rateLimiters;\n  private final UsernameHashZkProofVerifier usernameHashZkProofVerifier;\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n\n  public AccountsGrpcService(final AccountsManager accountsManager,\n      final RateLimiters rateLimiters,\n      final UsernameHashZkProofVerifier usernameHashZkProofVerifier,\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager) {\n\n    this.accountsManager = accountsManager;\n    this.rateLimiters = rateLimiters;\n    this.usernameHashZkProofVerifier = usernameHashZkProofVerifier;\n    this.registrationRecoveryPasswordsManager = registrationRecoveryPasswordsManager;\n  }\n\n  @Override\n  public GetAccountIdentityResponse getAccountIdentity(final GetAccountIdentityRequest request) {\n    final Account account = getAuthenticatedAccount();\n\n    final AccountIdentifiers.Builder accountIdentifiersBuilder = AccountIdentifiers.newBuilder()\n        .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(account.getUuid())))\n        .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(account.getPhoneNumberIdentifier())))\n        .setE164(account.getNumber());\n\n    account.getUsernameHash().ifPresent(usernameHash ->\n        accountIdentifiersBuilder.setUsernameHash(ByteString.copyFrom(usernameHash)));\n\n    return GetAccountIdentityResponse.newBuilder()\n        .setAccountIdentifiers(accountIdentifiersBuilder)\n        .build();\n  }\n\n  @Override\n  public DeleteAccountResponse deleteAccount(final DeleteAccountRequest request) {\n    accountsManager.delete(getAuthenticatedAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice()),\n            AccountsManager.DeletionReason.USER_REQUEST);\n\n    return DeleteAccountResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetRegistrationLockResponse setRegistrationLock(final SetRegistrationLockRequest request) {\n    // In the previous REST-based API, clients would send hex strings directly. For backward compatibility, we\n    // convert the registration lock secret to a lowercase hex string before turning it into a salted hash.\n    final SaltedTokenHash credentials =\n        SaltedTokenHash.generateFor(HexFormat.of().withLowerCase().formatHex(request.getRegistrationLock().toByteArray()));\n\n    accountsManager.update(getAuthenticatedAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice()),\n        account -> account.setRegistrationLock(credentials.hash(), credentials.salt()));\n\n    return SetRegistrationLockResponse.getDefaultInstance();\n  }\n\n  @Override\n  public ClearRegistrationLockResponse clearRegistrationLock(final ClearRegistrationLockRequest request) {\n    accountsManager.update(getAuthenticatedAccount(AuthenticationUtil.requireAuthenticatedPrimaryDevice()),\n        account -> account.setRegistrationLock(null, null));\n\n    return ClearRegistrationLockResponse.getDefaultInstance();\n  }\n\n  @Override\n  public ReserveUsernameHashResponse reserveUsernameHash(final ReserveUsernameHashRequest request)\n      throws RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    final List<byte[]> usernameHashes = new ArrayList<>(request.getUsernameHashesCount());\n\n    for (final ByteString usernameHash : request.getUsernameHashesList()) {\n      if (usernameHash.size() != AccountController.USERNAME_HASH_LENGTH) {\n        throw GrpcExceptions.fieldViolation(\"username_hashes\",\n          String.format(\"Username hash length must be %d bytes, but was actually %d\",\n                AccountController.USERNAME_HASH_LENGTH, usernameHash.size()));\n      }\n      usernameHashes.add(usernameHash.toByteArray());\n    }\n\n    rateLimiters.getUsernameReserveLimiter().validate(authenticatedDevice.accountIdentifier());\n\n    final Account account = getAuthenticatedAccount();\n\n    try {\n      final AccountsManager.UsernameReservation usernameReservation =\n          accountsManager.reserveUsernameHash(account, usernameHashes);\n\n      return ReserveUsernameHashResponse.newBuilder()\n          .setUsernameHash(ByteString.copyFrom(usernameReservation.reservedUsernameHash()))\n          .build();\n    } catch (final UsernameHashNotAvailableException e) {\n        return ReserveUsernameHashResponse.newBuilder()\n            .setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())\n            .build();\n    }\n  }\n\n  @Override\n  public ConfirmUsernameHashResponse confirmUsernameHash(final ConfirmUsernameHashRequest request)\n      throws RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    try {\n      usernameHashZkProofVerifier.verifyProof(request.getZkProof().toByteArray(), request.getUsernameHash().toByteArray());\n    } catch (final BaseUsernameException e) {\n      throw GrpcExceptions.constraintViolation(\"Could not verify proof\");\n    }\n\n    rateLimiters.getUsernameSetLimiter().validate(authenticatedDevice.accountIdentifier());\n\n    try {\n      final Account updatedAccount = accountsManager.confirmReservedUsernameHash(getAuthenticatedAccount(),\n              request.getUsernameHash().toByteArray(),\n              request.getUsernameCiphertext().toByteArray());\n\n      return ConfirmUsernameHashResponse.newBuilder()\n          .setConfirmedUsernameHash(ConfirmUsernameHashResponse.ConfirmedUsernameHash.newBuilder()\n              .setUsernameHash(ByteString.copyFrom(updatedAccount.getUsernameHash().orElseThrow()))\n              .setUsernameLinkHandle(UUIDUtil.toByteString(updatedAccount.getUsernameLinkHandle())))\n          .build();\n    } catch (final UsernameHashNotAvailableException e) {\n      return ConfirmUsernameHashResponse\n          .newBuilder()\n          .setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())\n          .build();\n    } catch (final UsernameReservationNotFoundException e) {\n      return ConfirmUsernameHashResponse\n          .newBuilder()\n          .setReservationNotFound(FailedPrecondition.getDefaultInstance())\n          .build();\n    }\n  }\n\n  @Override\n  public DeleteUsernameHashResponse deleteUsernameHash(final DeleteUsernameHashRequest request) {\n    accountsManager.clearUsernameHash(getAuthenticatedAccount());\n\n    return DeleteUsernameHashResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetUsernameLinkResponse setUsernameLink(final SetUsernameLinkRequest request)\n      throws RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    rateLimiters.getUsernameLinkOperationLimiter().validate(authenticatedDevice.accountIdentifier());\n\n    final Account account = getAuthenticatedAccount();\n\n    final SetUsernameLinkResponse.Builder responseBuilder = SetUsernameLinkResponse.newBuilder();\n\n    if (account.getUsernameHash().isEmpty()) {\n      return responseBuilder.setNoUsernameSet(FailedPrecondition.getDefaultInstance()).build();\n    }\n\n    final UUID linkHandle = (request.getKeepLinkHandle() && account.getUsernameLinkHandle() != null)\n        ? account.getUsernameLinkHandle()\n        : UUID.randomUUID();\n\n    accountsManager.update(account, a -> a.setUsernameLinkDetails(linkHandle, request.getUsernameCiphertext().toByteArray()));\n\n    return responseBuilder.setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle)).build();\n  }\n\n  @Override\n  public DeleteUsernameLinkResponse deleteUsernameLink(final DeleteUsernameLinkRequest request)\n      throws RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    rateLimiters.getUsernameLinkOperationLimiter().validate(authenticatedDevice.accountIdentifier());\n\n    accountsManager.update(getAuthenticatedAccount(), a -> a.setUsernameLinkDetails(null, null));\n\n    return DeleteUsernameLinkResponse.getDefaultInstance();\n  }\n\n  @Override\n  public ConfigureUnidentifiedAccessResponse configureUnidentifiedAccess(final ConfigureUnidentifiedAccessRequest request) {\n    if (!request.getAllowUnrestrictedUnidentifiedAccess() && request.getUnidentifiedAccessKey().size() != UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH) {\n      throw GrpcExceptions.fieldViolation(\"unidentified_access_key\",\n          String.format(\"Unidentified access key must be %d bytes, but was actually %d\",\n              UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH, request.getUnidentifiedAccessKey().size()));\n    }\n\n    accountsManager.update(getAuthenticatedAccount(), account -> {\n      account.setUnrestrictedUnidentifiedAccess(request.getAllowUnrestrictedUnidentifiedAccess());\n      account.setUnidentifiedAccessKey(request.getAllowUnrestrictedUnidentifiedAccess() ? null : request.getUnidentifiedAccessKey().toByteArray());\n    });\n\n    return ConfigureUnidentifiedAccessResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetDiscoverableByPhoneNumberResponse setDiscoverableByPhoneNumber(final SetDiscoverableByPhoneNumberRequest request) {\n    accountsManager.update(getAuthenticatedAccount(),\n        account -> account.setDiscoverableByPhoneNumber(request.getDiscoverableByPhoneNumber()));\n\n    return SetDiscoverableByPhoneNumberResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetRegistrationRecoveryPasswordResponse setRegistrationRecoveryPassword(final SetRegistrationRecoveryPasswordRequest request) {\n    registrationRecoveryPasswordsManager.store(getAuthenticatedAccount().getIdentifier(IdentityType.PNI),\n            request.getRegistrationRecoveryPassword().toByteArray())\n        .join();\n\n    return SetRegistrationRecoveryPasswordResponse.getDefaultInstance();\n  }\n\n  private Account getAuthenticatedAccount() {\n    return getAuthenticatedAccount(AuthenticationUtil.requireAuthenticatedDevice());\n  }\n\n  private Account getAuthenticatedAccount(final AuthenticatedDevice authenticatedDevice) {\n    return accountsManager.getByAccountIdentifier(authenticatedDevice.accountIdentifier())\n        .orElseThrow(() -> GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/AttachmentsGrpcService.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport java.security.SecureRandom;\nimport java.util.Map;\nimport org.signal.chat.attachments.GetUploadFormRequest;\nimport org.signal.chat.attachments.GetUploadFormResponse;\nimport org.signal.chat.attachments.SimpleAttachmentsGrpc;\nimport org.signal.chat.common.UploadForm;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentUtil;\n\npublic class AttachmentsGrpcService extends SimpleAttachmentsGrpc.AttachmentsImplBase {\n\n  private final ExperimentEnrollmentManager experimentEnrollmentManager;\n  private final RateLimiter rateLimiter;\n  private final Map<Integer, AttachmentGenerator> attachmentGenerators;\n  private final SecureRandom secureRandom;\n\n  public AttachmentsGrpcService(\n      final ExperimentEnrollmentManager experimentEnrollmentManager,\n      final RateLimiters rateLimiters,\n      final GcsAttachmentGenerator gcsAttachmentGenerator,\n      final TusAttachmentGenerator tusAttachmentGenerator) {\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n    this.rateLimiter = rateLimiters.getAttachmentLimiter();\n    this.secureRandom = new SecureRandom();\n    this.attachmentGenerators = Map.of(\n        2, gcsAttachmentGenerator,\n        3, tusAttachmentGenerator);\n  }\n\n  @Override\n  public GetUploadFormResponse getUploadForm(final GetUploadFormRequest request) throws RateLimitExceededException {\n    final AuthenticatedDevice auth = AuthenticationUtil.requireAuthenticatedDevice();\n    rateLimiter.validate(auth.accountIdentifier());\n    final String key = AttachmentUtil.generateAttachmentKey(secureRandom);\n    final boolean useCdn3 = this.experimentEnrollmentManager.isEnrolled(auth.accountIdentifier(),\n        AttachmentUtil.CDN3_EXPERIMENT_NAME);\n    final int cdn = useCdn3 ? 3 : 2;\n    final AttachmentGenerator.Descriptor descriptor = this.attachmentGenerators.get(cdn).generateAttachment(key);\n    return GetUploadFormResponse.newBuilder().setUploadForm(UploadForm.newBuilder()\n        .setCdn(cdn)\n        .setKey(key)\n        .putAllHeaders(descriptor.headers())\n        .setSignedUploadLocation(descriptor.signedUploadLocation()))\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/AvatarChangeUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Status;\nimport org.whispersystems.textsecuregcm.entities.AvatarChange;\n\npublic class AvatarChangeUtil {\n  public static AvatarChange fromGrpcAvatarChange(final org.signal.chat.profile.SetProfileRequest.AvatarChange avatarChangeType) {\n    return switch (avatarChangeType) {\n      case AVATAR_CHANGE_UNCHANGED -> AvatarChange.AVATAR_CHANGE_UNCHANGED;\n      case AVATAR_CHANGE_CLEAR -> AvatarChange.AVATAR_CHANGE_CLEAR;\n      case AVATAR_CHANGE_UPDATE -> AvatarChange.AVATAR_CHANGE_UPDATE;\n      case UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.withDescription(\"Invalid avatar change value\").asRuntimeException();\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcService.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport java.util.Optional;\nimport java.util.concurrent.Flow;\nimport org.signal.chat.backup.CopyMediaRequest;\nimport org.signal.chat.backup.CopyMediaResponse;\nimport org.signal.chat.backup.DeleteAllRequest;\nimport org.signal.chat.backup.DeleteAllResponse;\nimport org.signal.chat.backup.DeleteMediaItem;\nimport org.signal.chat.backup.DeleteMediaRequest;\nimport org.signal.chat.backup.DeleteMediaResponse;\nimport org.signal.chat.backup.GetBackupInfoRequest;\nimport org.signal.chat.backup.GetCdnCredentialsRequest;\nimport org.signal.chat.backup.GetCdnCredentialsResponse;\nimport org.signal.chat.backup.GetMediaBackupInfoResponse;\nimport org.signal.chat.backup.GetMessageBackupInfoResponse;\nimport org.signal.chat.backup.GetSvrBCredentialsRequest;\nimport org.signal.chat.backup.GetSvrBCredentialsResponse;\nimport org.signal.chat.backup.GetUploadFormRequest;\nimport org.signal.chat.backup.GetUploadFormResponse;\nimport org.signal.chat.backup.ListMediaRequest;\nimport org.signal.chat.backup.ListMediaResponse;\nimport org.signal.chat.backup.RefreshRequest;\nimport org.signal.chat.backup.RefreshResponse;\nimport org.signal.chat.backup.SetPublicKeyRequest;\nimport org.signal.chat.backup.SetPublicKeyResponse;\nimport org.signal.chat.backup.SignedPresentation;\nimport org.signal.chat.backup.SimpleBackupsAnonymousGrpc;\nimport org.signal.chat.errors.FailedPrecondition;\nimport org.signal.chat.errors.FailedZkAuthentication;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;\nimport org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.BackupPermissionException;\nimport org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;\nimport org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;\nimport org.whispersystems.textsecuregcm.backup.CopyParameters;\nimport org.whispersystems.textsecuregcm.backup.MediaEncryptionParameters;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport reactor.adapter.JdkFlowAdapter;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\npublic class BackupsAnonymousGrpcService extends SimpleBackupsAnonymousGrpc.BackupsAnonymousImplBase {\n\n  private final BackupManager backupManager;\n  private final BackupMetrics backupMetrics;\n\n  public BackupsAnonymousGrpcService(final BackupManager backupManager, final BackupMetrics backupMetrics) {\n    this.backupManager = backupManager;\n    this.backupMetrics = backupMetrics;\n  }\n\n  @Override\n  public GetCdnCredentialsResponse getCdnCredentials(final GetCdnCredentialsRequest request)\n      throws BackupInvalidArgumentException, BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      return GetCdnCredentialsResponse.newBuilder()\n          .setCdnCredentials(GetCdnCredentialsResponse.CdnCredentials.newBuilder()\n              .putAllHeaders(backupManager.generateReadAuth(backupUser, request.getCdn())))\n          .build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return GetCdnCredentialsResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())\n          .build();\n    }\n  }\n\n  @Override\n  public GetSvrBCredentialsResponse getSvrBCredentials(final GetSvrBCredentialsRequest request)\n      throws BackupWrongCredentialTypeException, BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      final ExternalServiceCredentials credentials = backupManager.generateSvrbAuth(backupUser);\n      return GetSvrBCredentialsResponse.newBuilder()\n          .setSvrbCredentials(GetSvrBCredentialsResponse.SvrBCredentials.newBuilder()\n              .setUsername(credentials.username())\n              .setPassword(credentials.password()))\n          .build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return GetSvrBCredentialsResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())\n          .build();\n    }\n  }\n\n  @Override\n  public GetMessageBackupInfoResponse getMessageBackupInfo(final GetBackupInfoRequest request) throws BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      if (backupUser.credentialType() != BackupCredentialType.MESSAGES) {\n        throw GrpcExceptions.badAuthentication(\"credential type for message backup info must be 'messages'\");\n      }\n      final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser);\n      return GetMessageBackupInfoResponse.newBuilder().setBackupInfo(GetMessageBackupInfoResponse.MessageBackupInfo.newBuilder()\n              .setBackupName(info.messageBackupKey())\n              .setCdn(info.cdn())\n              .setBackupDir(info.backupSubdir())\n          .build()).build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return GetMessageBackupInfoResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())\n          .build();\n    }\n  }\n\n  @Override\n  public GetMediaBackupInfoResponse getMediaBackupInfo(final GetBackupInfoRequest request) throws BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      if (backupUser.credentialType() != BackupCredentialType.MEDIA) {\n        throw GrpcExceptions.badAuthentication(\"credential type for media backup info must be 'media'\");\n      }\n      final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser);\n      return GetMediaBackupInfoResponse.newBuilder().setBackupInfo(GetMediaBackupInfoResponse.MediaBackupInfo.newBuilder()\n              .setBackupDir(info.backupSubdir())\n              .setMediaDir(info.mediaSubdir())\n              .setUsedSpace(info.mediaUsedSpace().orElse(0L))\n          .build()).build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return GetMediaBackupInfoResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())\n          .build();\n    }\n  }\n\n  @Override\n  public RefreshResponse refresh(final RefreshRequest request) throws BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      backupManager.ttlRefresh(backupUser);\n      return RefreshResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return RefreshResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())\n          .build();\n    }\n  }\n\n  @Override\n  public SetPublicKeyResponse setPublicKey(final SetPublicKeyRequest request)\n      throws BackupFailedZkAuthenticationException {\n    final ECPublicKey publicKey = deserialize(ECPublicKey::new, request.getPublicKey().toByteArray());\n    final BackupAuthCredentialPresentation presentation = deserialize(\n        BackupAuthCredentialPresentation::new,\n        request.getSignedPresentation().getPresentation().toByteArray());\n    final byte[] signature = request.getSignedPresentation().getPresentationSignature().toByteArray();\n\n    backupManager.setPublicKey(presentation, signature, publicKey);\n    return SetPublicKeyResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();\n  }\n\n\n  @Override\n  public GetUploadFormResponse getUploadForm(final GetUploadFormRequest request)\n      throws RateLimitExceededException, BackupWrongCredentialTypeException, BackupPermissionException {\n    final AuthenticatedBackupUser backupUser;\n    try {\n      backupUser = authenticateBackupUser(request.getSignedPresentation());\n    } catch (BackupFailedZkAuthenticationException e) {\n      return GetUploadFormResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()).build())\n          .build();\n    }\n    final GetUploadFormResponse.Builder builder = GetUploadFormResponse.newBuilder();\n    switch (request.getUploadTypeCase()) {\n      case MESSAGES -> {\n        final long uploadLength = request.getMessages().getUploadLength();\n        final boolean oversize = uploadLength > BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE;\n        backupMetrics.updateMessageBackupSizeDistribution(backupUser, oversize, Optional.of(uploadLength));\n        if (oversize) {\n          builder.setExceedsMaxUploadLength(FailedPrecondition.getDefaultInstance());\n        } else {\n          final BackupUploadDescriptor uploadDescriptor = backupManager.createMessageBackupUploadDescriptor(backupUser);\n          builder.setUploadForm(builder.getUploadFormBuilder()\n              .setCdn(uploadDescriptor.cdn())\n              .setKey(uploadDescriptor.key())\n              .setSignedUploadLocation(uploadDescriptor.signedUploadLocation())\n              .putAllHeaders(uploadDescriptor.headers())).build();\n        }\n      }\n      case MEDIA -> {\n        final BackupUploadDescriptor uploadDescriptor = backupManager.createTemporaryAttachmentUploadDescriptor(\n            backupUser);\n        builder.setUploadForm(builder.getUploadFormBuilder()\n            .setCdn(uploadDescriptor.cdn())\n            .setKey(uploadDescriptor.key())\n            .setSignedUploadLocation(uploadDescriptor.signedUploadLocation())\n            .putAllHeaders(uploadDescriptor.headers())).build();\n      }\n      case UPLOADTYPE_NOT_SET -> throw GrpcExceptions.fieldViolation(\"upload_type\", \"Must set upload_type\");\n    }\n    return builder.build();\n  }\n\n  @Override\n  public Flow.Publisher<CopyMediaResponse> copyMedia(final CopyMediaRequest request)\n      throws BackupWrongCredentialTypeException, BackupPermissionException, BackupInvalidArgumentException {\n    final BackupManager.CopyQuota copyQuota;\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      copyQuota = backupManager.getCopyQuota(backupUser,\n          request.getItemsList().stream().map(item -> new CopyParameters(\n              item.getSourceAttachmentCdn(), item.getSourceKey(),\n              // uint32 in proto, make sure it fits in a signed int\n              fromUnsignedExact(item.getObjectLength()),\n              new MediaEncryptionParameters(item.getEncryptionKey().toByteArray(), item.getHmacKey().toByteArray()),\n              item.getMediaId().toByteArray())).toList());\n    } catch (BackupFailedZkAuthenticationException e) {\n      return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(CopyMediaResponse\n          .newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))\n          .build()));\n    }\n    return JdkFlowAdapter.publisherToFlowPublisher(backupManager.copyToBackup(copyQuota)\n        .doOnNext(result -> backupMetrics.updateCopyCounter(\n            result,\n            UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null))))\n        .map(copyResult -> {\n          CopyMediaResponse.Builder builder = CopyMediaResponse\n              .newBuilder()\n              .setMediaId(ByteString.copyFrom(copyResult.mediaId()));\n          builder = switch (copyResult.outcome()) {\n            case SUCCESS -> builder\n                .setSuccess(CopyMediaResponse.CopySuccess.newBuilder().setCdn(copyResult.cdn()).build());\n            case OUT_OF_QUOTA -> builder\n                .setOutOfSpace(CopyMediaResponse.OutOfSpace.getDefaultInstance());\n            case SOURCE_WRONG_LENGTH -> builder\n                .setWrongSourceLength(CopyMediaResponse.WrongSourceLength.getDefaultInstance());\n            case SOURCE_NOT_FOUND -> builder\n                .setSourceNotFound(CopyMediaResponse.SourceNotFound.getDefaultInstance());\n          };\n          return builder.build();\n        }));\n  }\n\n  @Override\n  public ListMediaResponse listMedia(final ListMediaRequest request) throws BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      final BackupManager.ListMediaResult listResult = backupManager.list(\n          backupUser,\n          request.hasCursor() ? Optional.of(request.getCursor()) : Optional.empty(),\n          request.getLimit());\n      final ListMediaResponse.ListResult.Builder builder = ListMediaResponse.ListResult.newBuilder();\n      for (BackupManager.StorageDescriptorWithLength sd : listResult.media()) {\n        builder.addPage(ListMediaResponse.ListEntry.newBuilder()\n            .setMediaId(ByteString.copyFrom(sd.key()))\n            .setCdn(sd.cdn())\n            .setLength(sd.length())\n            .build());\n      }\n      builder\n          .setBackupDir(backupUser.backupDir())\n          .setMediaDir(backupUser.mediaDir());\n      listResult.cursor().ifPresent(builder::setCursor);\n      return ListMediaResponse.newBuilder().setListResult(builder).build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return ListMediaResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))\n          .build();\n    }\n  }\n\n  @Override\n  public DeleteAllResponse deleteAll(final DeleteAllRequest request) throws BackupPermissionException {\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      backupManager.deleteEntireBackup(backupUser);\n      return DeleteAllResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();\n    } catch (BackupFailedZkAuthenticationException e) {\n      return DeleteAllResponse.newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))\n          .build();\n    }\n  }\n\n  @Override\n  public Flow.Publisher<DeleteMediaResponse> deleteMedia(final DeleteMediaRequest request)\n      throws BackupWrongCredentialTypeException, BackupPermissionException {\n    final Flux<BackupManager.StorageDescriptor> deleteItems;\n    try {\n      final AuthenticatedBackupUser backupUser = authenticateBackupUser(request.getSignedPresentation());\n      deleteItems = backupManager.deleteMedia(backupUser, request\n          .getItemsList()\n          .stream()\n          .map(item -> new BackupManager.StorageDescriptor(item.getCdn(), item.getMediaId().toByteArray()))\n          .toList());\n    } catch (BackupFailedZkAuthenticationException e) {\n      return JdkFlowAdapter.publisherToFlowPublisher(Mono.just(DeleteMediaResponse\n          .newBuilder()\n          .setFailedAuthentication(FailedZkAuthentication.newBuilder().setDescription(e.getMessage()))\n          .build()));\n    }\n    return JdkFlowAdapter.publisherToFlowPublisher(deleteItems\n        .map(storageDescriptor -> DeleteMediaResponse.newBuilder()\n            .setDeletedItem(DeleteMediaItem.newBuilder()\n                .setMediaId(ByteString.copyFrom(storageDescriptor.key()))\n                .setCdn(storageDescriptor.cdn()))\n            .build()));\n  }\n\n  @Override\n  public Throwable mapException(final Throwable throwable) {\n    return switch (throwable) {\n      case BackupInvalidArgumentException e -> GrpcExceptions.invalidArguments(e.getMessage());\n      case BackupPermissionException e -> GrpcExceptions.badAuthentication(e.getMessage());\n      case BackupWrongCredentialTypeException e -> GrpcExceptions.badAuthentication(e.getMessage());\n      default -> throwable;\n    };\n  }\n\n  private AuthenticatedBackupUser authenticateBackupUser(final SignedPresentation signedPresentation)\n      throws BackupFailedZkAuthenticationException {\n    if (signedPresentation == null) {\n      throw GrpcExceptions.badAuthentication(\"Missing required signedPresentation\");\n    }\n    try {\n      return backupManager.authenticateBackupUser(\n          new BackupAuthCredentialPresentation(signedPresentation.getPresentation().toByteArray()),\n          signedPresentation.getPresentationSignature().toByteArray(),\n          RequestAttributesUtil.getUserAgent().orElse(null));\n    } catch (InvalidInputException e) {\n      throw GrpcExceptions.badAuthentication(\"Could not deserialize presentation\");\n    }\n  }\n\n  /**\n   * Convert an int from a proto uint32 to a signed positive integer, throwing if the value exceeds\n   * {@link Integer#MAX_VALUE}. To convert to a long, see {@link Integer#toUnsignedLong(int)}\n   */\n  private static int fromUnsignedExact(final int i) {\n    if (i < 0) {\n      throw GrpcExceptions.invalidArguments(\"integer length too large\");\n    }\n    return i;\n  }\n\n  private interface Deserializer<T> {\n\n    T deserialize(byte[] bytes) throws InvalidInputException, InvalidKeyException;\n  }\n\n  private static <T> T deserialize(Deserializer<T> deserializer, byte[] bytes) {\n    try {\n      return deserializer.deserialize(bytes);\n    } catch (InvalidInputException | InvalidKeyException e) {\n      throw GrpcExceptions.invalidArguments(\"invalid serialization\");\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcService.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport io.grpc.Status;\nimport io.micrometer.core.instrument.Tag;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport org.signal.chat.backup.GetBackupAuthCredentialsRequest;\nimport org.signal.chat.backup.GetBackupAuthCredentialsResponse;\nimport org.signal.chat.backup.RedeemReceiptRequest;\nimport org.signal.chat.backup.RedeemReceiptResponse;\nimport org.signal.chat.backup.SetBackupIdRequest;\nimport org.signal.chat.backup.SetBackupIdResponse;\nimport org.signal.chat.backup.SimpleBackupsGrpc;\nimport org.signal.chat.common.ZkCredential;\nimport org.signal.chat.errors.FailedPrecondition;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.backup.BackupBadReceiptException;\nimport org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;\nimport org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;\nimport org.whispersystems.textsecuregcm.backup.BackupNotFoundException;\nimport org.whispersystems.textsecuregcm.backup.BackupPermissionException;\nimport org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class BackupsGrpcService extends SimpleBackupsGrpc.BackupsImplBase {\n\n  private final AccountsManager accountManager;\n  private final BackupAuthManager backupAuthManager;\n  private final BackupMetrics backupMetrics;\n\n  public BackupsGrpcService(final AccountsManager accountManager, final BackupAuthManager backupAuthManager, final BackupMetrics backupMetrics) {\n    this.accountManager = accountManager;\n    this.backupAuthManager = backupAuthManager;\n    this.backupMetrics = backupMetrics;\n  }\n\n  @Override\n  public SetBackupIdResponse setBackupId(SetBackupIdRequest request)\n      throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n\n    final Optional<BackupAuthCredentialRequest> messagesCredentialRequest = deserializeWithEmptyPresenceCheck(\n        BackupAuthCredentialRequest::new,\n        request.getMessagesBackupAuthCredentialRequest());\n\n    final Optional<BackupAuthCredentialRequest> mediaCredentialRequest = deserializeWithEmptyPresenceCheck(\n        BackupAuthCredentialRequest::new,\n        request.getMediaBackupAuthCredentialRequest());\n\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final Account account = authenticatedAccount();\n    final Device device = account\n        .getDevice(authenticatedDevice.deviceId())\n        .orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);\n    backupAuthManager.commitBackupId(account, device, messagesCredentialRequest, mediaCredentialRequest);\n    return SetBackupIdResponse.getDefaultInstance();\n  }\n\n  public RedeemReceiptResponse redeemReceipt(RedeemReceiptRequest request) throws BackupInvalidArgumentException {\n    final ReceiptCredentialPresentation receiptCredentialPresentation = deserialize(\n        ReceiptCredentialPresentation::new,\n        request.getPresentation().toByteArray());\n    final Account account = authenticatedAccount();\n    final RedeemReceiptResponse.Builder builder = RedeemReceiptResponse.newBuilder();\n    try {\n      backupAuthManager.redeemReceipt(account, receiptCredentialPresentation);\n      builder.setSuccess(Empty.getDefaultInstance());\n    } catch (BackupBadReceiptException e) {\n      builder.setInvalidReceipt(FailedPrecondition.newBuilder().setDescription(e.getMessage()).build());\n    } catch (BackupMissingIdCommitmentException e) {\n      builder.setAccountMissingCommitment(FailedPrecondition.newBuilder().build());\n    }\n    return builder.build();\n  }\n\n  @Override\n  public GetBackupAuthCredentialsResponse getBackupAuthCredentials(GetBackupAuthCredentialsRequest request) {\n    final Tag platformTag = UserAgentTagUtil.getPlatformTag(RequestAttributesUtil.getUserAgent().orElse(null));\n    final RedemptionRange redemptionRange;\n    try {\n      redemptionRange = RedemptionRange.inclusive(Clock.systemUTC(),\n          Instant.ofEpochSecond(request.getRedemptionStart()),\n          Instant.ofEpochSecond(request.getRedemptionStop()));\n    } catch (IllegalArgumentException e) {\n      throw Status.INVALID_ARGUMENT.withDescription(e.getMessage()).asRuntimeException();\n    }\n    final Account account = authenticatedAccount();\n    try {\n\n      final Map<BackupCredentialType, List<BackupAuthManager.Credential>> credentials =\n          backupAuthManager.getBackupAuthCredentials(account, redemptionRange);\n\n      credentials.forEach((type, credentialList) ->\n          backupMetrics.updateGetCredentialCounter(platformTag, type, credentialList.size()));\n      final List<BackupAuthManager.Credential> messageCredentials = credentials.get(BackupCredentialType.MESSAGES);\n      final List<BackupAuthManager.Credential> mediaCredentials = credentials.get(BackupCredentialType.MEDIA);\n\n      return GetBackupAuthCredentialsResponse.newBuilder()\n          .setCredentials(GetBackupAuthCredentialsResponse.Credentials.newBuilder()\n              .putAllMessageCredentials(messageCredentials.stream().collect(Collectors.toMap(\n                  c -> c.redemptionTime().getEpochSecond(),\n                  c -> ZkCredential.newBuilder()\n                      .setCredential(ByteString.copyFrom(c.credential().serialize()))\n                      .setRedemptionTime(c.redemptionTime().getEpochSecond())\n                      .build())))\n              .putAllMediaCredentials(mediaCredentials.stream().collect(Collectors.toMap(\n                  c -> c.redemptionTime().getEpochSecond(),\n                  c -> ZkCredential.newBuilder()\n                      .setCredential(ByteString.copyFrom(c.credential().serialize()))\n                      .setRedemptionTime(c.redemptionTime().getEpochSecond())\n                      .build())))\n              .build())\n          .build();\n    } catch (BackupNotFoundException _) {\n      // Return an empty response to indicate that the authenticated account had no associated blinded backup-id\n      return GetBackupAuthCredentialsResponse.getDefaultInstance();\n    }\n  }\n\n  @Override\n  public Throwable mapException(final Throwable throwable) {\n    return switch (throwable) {\n      case BackupInvalidArgumentException e -> GrpcExceptions.invalidArguments(e.getMessage());\n      case BackupPermissionException e -> GrpcExceptions.badAuthentication(e.getMessage());\n      case BackupWrongCredentialTypeException e -> GrpcExceptions.badAuthentication(e.getMessage());\n      default -> throwable;\n    };\n  }\n\n  private Account authenticatedAccount() {\n    return accountManager\n        .getByAccountIdentifier(AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier())\n        .orElseThrow(Status.UNAUTHENTICATED::asRuntimeException);\n  }\n\n  private interface Deserializer<T> {\n\n    T deserialize(byte[] bytes) throws InvalidInputException;\n  }\n\n  private <T> Optional<T> deserializeWithEmptyPresenceCheck(Deserializer<T> deserializer, ByteString byteString) {\n    if (byteString.isEmpty()) {\n      return Optional.empty();\n    }\n    return Optional.of(deserialize(deserializer, byteString.toByteArray()));\n  }\n\n  private <T> T deserialize(Deserializer<T> deserializer, byte[] bytes) {\n    try {\n      return deserializer.deserialize(bytes);\n    } catch (InvalidInputException e) {\n      throw GrpcExceptions.invalidArguments(\"invalid serialization\");\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcService.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport org.signal.chat.calling.quality.SimpleCallQualityGrpc;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyResponse;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.CallQualityInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager;\n\npublic class CallQualitySurveyGrpcService extends SimpleCallQualityGrpc.CallQualityImplBase {\n\n  private final CallQualitySurveyManager callQualitySurveyManager;\n  private final RateLimiters rateLimiters;\n\n  public CallQualitySurveyGrpcService(final CallQualitySurveyManager callQualitySurveyManager,\n      final RateLimiters rateLimiters) {\n\n    this.callQualitySurveyManager = callQualitySurveyManager;\n    this.rateLimiters = rateLimiters;\n  }\n\n  @Override\n  public SubmitCallQualitySurveyResponse submitCallQualitySurvey(final SubmitCallQualitySurveyRequest request)\n      throws RateLimitExceededException {\n\n    final String remoteAddress = RequestAttributesUtil.getRemoteAddress().getHostAddress();\n\n    rateLimiters.getSubmitCallQualitySurveyLimiter().validate(remoteAddress);\n\n    try {\n      callQualitySurveyManager.submitCallQualitySurvey(request,\n          remoteAddress,\n          RequestAttributesUtil.getUserAgent().orElse(null));\n    } catch (final CallQualityInvalidArgumentsException e) {\n      throw e.getField()\n          .map(fieldName -> GrpcExceptions.fieldViolation(fieldName, e.getMessage()))\n          .orElseGet(() -> GrpcExceptions.invalidArguments(e.getMessage()));\n    }\n\n    return SubmitCallQualitySurveyResponse.getDefaultInstance();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ChannelNotFoundException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\n/**\n * Indicates that a remote channel was not found for a given server call or remote address.\n */\npublic class ChannelNotFoundException extends Exception {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ConvertibleToGrpcStatus.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Metadata;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport java.util.Optional;\n\n/**\n * Interface to be implemented by our custom exceptions that are consistently mapped to a gRPC status.\n */\npublic interface ConvertibleToGrpcStatus {\n  StatusRuntimeException toStatusRuntimeException();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/DeviceCapabilityUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\n\npublic class DeviceCapabilityUtil {\n\n  private DeviceCapabilityUtil() {\n  }\n\n  public static DeviceCapability fromGrpcDeviceCapability(final org.signal.chat.common.DeviceCapability grpcDeviceCapability) {\n    return switch (grpcDeviceCapability) {\n      case DEVICE_CAPABILITY_STORAGE -> DeviceCapability.STORAGE;\n      case DEVICE_CAPABILITY_TRANSFER -> DeviceCapability.TRANSFER;\n      case DEVICE_CAPABILITY_ATTACHMENT_BACKFILL -> DeviceCapability.ATTACHMENT_BACKFILL;\n      case DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET -> DeviceCapability.SPARSE_POST_QUANTUM_RATCHET;\n      case DEVICE_CAPABILITY_UNSPECIFIED, UNRECOGNIZED ->\n          throw GrpcExceptions.invalidArguments(\"unrecognized device capability\");\n    };\n  }\n\n  public static org.signal.chat.common.DeviceCapability toGrpcDeviceCapability(final DeviceCapability deviceCapability) {\n    return switch (deviceCapability) {\n      case STORAGE -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_STORAGE;\n      case TRANSFER -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_TRANSFER;\n      case ATTACHMENT_BACKFILL -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_ATTACHMENT_BACKFILL;\n      case SPARSE_POST_QUANTUM_RATCHET -> org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET;\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/DeviceIdUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Status;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class DeviceIdUtil {\n\n  public static boolean isValid(int deviceId) {\n    return deviceId >= Device.PRIMARY_ID && deviceId <= Byte.MAX_VALUE;\n  }\n\n  static byte validate(int deviceId) {\n    if (!isValid(deviceId)) {\n      throw GrpcExceptions.invalidArguments(\"device ID is out of range\");\n    }\n\n    return (byte) deviceId;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport java.util.Objects;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.chat.device.ClearPushTokenRequest;\nimport org.signal.chat.device.ClearPushTokenResponse;\nimport org.signal.chat.device.GetDevicesRequest;\nimport org.signal.chat.device.GetDevicesResponse;\nimport org.signal.chat.device.RemoveDeviceRequest;\nimport org.signal.chat.device.RemoveDeviceResponse;\nimport org.signal.chat.device.SetCapabilitiesRequest;\nimport org.signal.chat.device.SetCapabilitiesResponse;\nimport org.signal.chat.device.SetDeviceNameRequest;\nimport org.signal.chat.device.SetDeviceNameResponse;\nimport org.signal.chat.device.SetPushTokenRequest;\nimport org.signal.chat.device.SetPushTokenResponse;\nimport org.signal.chat.device.SimpleDevicesGrpc;\nimport org.signal.chat.errors.NotFound;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\n\npublic class DevicesGrpcService extends SimpleDevicesGrpc.DevicesImplBase {\n\n  private final AccountsManager accountsManager;\n\n  public DevicesGrpcService(final AccountsManager accountsManager) {\n    this.accountsManager = accountsManager;\n  }\n\n  @Override\n  public GetDevicesResponse getDevices(final GetDevicesRequest request) {\n    final Account account = getAuthenticatedAccount();\n\n    final GetDevicesResponse.Builder responseBuilder = GetDevicesResponse.newBuilder();\n\n    account.getDevices().stream()\n        .map(device -> {\n          final GetDevicesResponse.LinkedDevice.Builder linkedDeviceBuilder =\n              GetDevicesResponse.LinkedDevice.newBuilder()\n                  .setId(device.getId())\n                  .setLastSeen(device.getLastSeen())\n                  .setRegistrationId(device.getRegistrationId(IdentityType.ACI))\n                  .setCreatedAtCiphertext(ByteString.copyFrom(device.getCreatedAtCiphertext()));\n\n          if (device.getName() != null) {\n            linkedDeviceBuilder.setName(ByteString.copyFrom(device.getName()));\n          }\n\n          return linkedDeviceBuilder.build();\n        })\n        .forEach(responseBuilder::addDevices);\n\n    return responseBuilder.build();\n  }\n\n  @Override\n  public RemoveDeviceResponse removeDevice(final RemoveDeviceRequest request) {\n    if (request.getId() == Device.PRIMARY_ID) {\n      throw GrpcExceptions.invalidArguments(\"cannot remove primary device\");\n    }\n\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    if (authenticatedDevice.deviceId() != Device.PRIMARY_ID && request.getId() != authenticatedDevice.deviceId()) {\n      throw GrpcExceptions.badAuthentication(\"linked devices cannot remove devices other than themselves\");\n    }\n\n    final byte deviceId = DeviceIdUtil.validate(request.getId());\n\n    accountsManager.removeDevice(getAuthenticatedAccount(), deviceId);\n\n    return RemoveDeviceResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetDeviceNameResponse setDeviceName(final SetDeviceNameRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    final byte deviceId = DeviceIdUtil.validate(request.getId());\n\n    final boolean mayChangeName = authenticatedDevice.deviceId() == Device.PRIMARY_ID ||\n        authenticatedDevice.deviceId() == deviceId;\n\n    if (!mayChangeName) {\n      throw GrpcExceptions.badAuthentication(\"linked device is not authorized to change target device name\");\n    }\n\n    final Account account = getAuthenticatedAccount();\n\n    if (account.getDevice(deviceId).isEmpty()) {\n      return SetDeviceNameResponse.newBuilder().setTargetDeviceNotFound(NotFound.getDefaultInstance()).build();\n    }\n\n    accountsManager.updateDevice(account, deviceId, device -> device.setName(request.getName().toByteArray()));\n\n    return SetDeviceNameResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();\n  }\n\n  @Override\n  public SetPushTokenResponse setPushToken(final SetPushTokenRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    @Nullable final String apnsToken;\n    @Nullable final String fcmToken;\n\n    switch (request.getTokenRequestCase()) {\n\n      case APNS_TOKEN_REQUEST -> {\n        final SetPushTokenRequest.ApnsTokenRequest apnsTokenRequest = request.getApnsTokenRequest();\n        apnsToken = StringUtils.stripToNull(apnsTokenRequest.getApnsToken());\n        fcmToken = null;\n      }\n\n      case FCM_TOKEN_REQUEST -> {\n        final SetPushTokenRequest.FcmTokenRequest fcmTokenRequest = request.getFcmTokenRequest();\n        apnsToken = null;\n        fcmToken = StringUtils.stripToNull(fcmTokenRequest.getFcmToken());\n      }\n\n      default -> throw GrpcExceptions.fieldViolation(\"token_request\", \"No tokens specified\");\n    }\n\n    final Account account = getAuthenticatedAccount();\n\n    final Device device = account.getDevice(authenticatedDevice.deviceId())\n        .orElseThrow(() -> GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n\n    if (!Objects.equals(device.getApnId(), apnsToken) || !Objects.equals(device.getGcmId(), fcmToken)) {\n      accountsManager.updateDevice(account, authenticatedDevice.deviceId(), d -> {\n        d.setApnId(apnsToken);\n        d.setGcmId(fcmToken);\n        d.setFetchesMessages(false);\n      });\n    }\n\n    return SetPushTokenResponse.getDefaultInstance();\n  }\n\n  @Override\n  public ClearPushTokenResponse clearPushToken(final ClearPushTokenRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final Account account = getAuthenticatedAccount();\n\n    accountsManager.updateDevice(account, authenticatedDevice.deviceId(), device -> {\n      if (StringUtils.isNotBlank(device.getApnId())) {\n        device.setUserAgent(device.isPrimary() ? \"OWI\" : \"OWP\");\n      } else if (StringUtils.isNotBlank(device.getGcmId())) {\n        device.setUserAgent(\"OWA\");\n      }\n\n      device.setApnId(null);\n      device.setGcmId(null);\n      device.setFetchesMessages(true);\n    });\n\n    return ClearPushTokenResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetCapabilitiesResponse setCapabilities(final SetCapabilitiesRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    final Set<DeviceCapability> capabilities = request.getCapabilitiesList().stream()\n        .map(DeviceCapabilityUtil::fromGrpcDeviceCapability)\n        .collect(Collectors.toSet());\n\n    accountsManager.updateDevice(getAuthenticatedAccount(), authenticatedDevice.deviceId(),\n        device -> device.setCapabilities(capabilities));\n\n    return SetCapabilitiesResponse.getDefaultInstance();\n  }\n\n  private Account getAuthenticatedAccount() {\n    return accountsManager.getByAccountIdentifier(AuthenticationUtil.requireAuthenticatedDevice().accountIdentifier())\n        .orElseThrow(() -> GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ErrorConformanceInterceptor.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.ForwardingServerCall;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class ErrorConformanceInterceptor implements ServerInterceptor {\n\n  private static final Logger log = LoggerFactory.getLogger(ErrorConformanceInterceptor.class);\n\n  private static final Metadata.Key<byte[]> DETAILS_HEADER_KEY =\n      Metadata.Key.of(\"grpc-status-details-bin\", Metadata.BINARY_BYTE_MARSHALLER);\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(\n      final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n    return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<>(call) {\n      @Override\n      public void close(final Status status, final Metadata trailers) {\n        if (status.getCode() == Status.Code.OK) {\n          super.close(status, trailers);\n          return;\n        }\n        if (!trailers.containsKey(DETAILS_HEADER_KEY)) {\n          log.error(\"Intercepted call {} returned status {} but did not include status details\",\n              call.getMethodDescriptor().getFullMethodName(), status);\n          assert false;\n        }\n\n        switch (status.getCode()) {\n          case UNAUTHENTICATED, UNAVAILABLE, INVALID_ARGUMENT, RESOURCE_EXHAUSTED -> {\n          }\n          default -> {\n            log.error(\"Intercepted call {} returned illegal application status {}: {}\",\n                call.getMethodDescriptor().getFullMethodName(), status, status.getDescription());\n            assert false;\n          }\n        }\n        super.close(status, trailers);\n      }\n    }, headers);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ErrorMappingInterceptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.ForwardingServerCall;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\n\n/**\n * This interceptor observes responses from the service and if the response status is {@link Status#UNKNOWN}\n * and there is a non-null cause which is an instance of {@link ConvertibleToGrpcStatus},\n * then status and metadata to be returned to the client is resolved from that object.\n * </p>\n * This eliminates the need of having each service to override {@code `onErrorMap()`} method for commonly used exceptions.\n */\npublic class ErrorMappingInterceptor implements ServerInterceptor {\n\n  private static final Logger log = LoggerFactory.getLogger(ErrorMappingInterceptor.class);\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(\n      final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n    return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<>(call) {\n      @Override\n      public void close(final Status status, final Metadata trailers) {\n        // The idea is to only apply the automatic conversion logic in the cases\n        // when there was no explicit decision by the service to provide a status.\n        // I.e. if at this point we see anything but the `UNKNOWN`,\n        // that means that some logic in the service made this decision already\n        // and automatic conversion may conflict with it.\n        if (!status.getCode().equals(Status.Code.UNKNOWN)) {\n          super.close(status, trailers);\n          return;\n        }\n\n        final Throwable cause = ExceptionUtils.unwrap(status.getCause());\n\n        final StatusRuntimeException statusException = switch (cause) {\n          case ConvertibleToGrpcStatus e -> e.toStatusRuntimeException();\n          case UncheckedIOException e -> {\n            log.warn(\"RPC {} encountered UncheckedIOException\", call.getMethodDescriptor().getFullMethodName(), e.getCause());\n            yield GrpcExceptions.unavailable(e.getCause().getMessage());\n          }\n          case IOException e -> {\n            log.warn(\"RPC {} encountered IOException\", call.getMethodDescriptor().getFullMethodName(), e);\n            yield GrpcExceptions.unavailable(e.getMessage());\n          }\n          case null -> {\n            log.error(\"RPC {} finished with status UNKNOWN: {}\",\n                call.getMethodDescriptor().getFullMethodName(), status.getDescription());\n            yield GrpcExceptions.unavailable(status.getDescription());\n          }\n          default -> {\n            log.error(\"RPC {} finished with status UNKNOWN\",\n                call.getMethodDescriptor().getFullMethodName(), status.getCause());\n            yield GrpcExceptions.unavailable(status.getCause().getMessage());\n          }\n        };\n        super.close(statusException.getStatus(), statusException.getTrailers());\n      }\n    }, headers);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.TimeUnit;\nimport org.signal.chat.credentials.AuthCheckResult;\nimport org.signal.chat.credentials.CheckSvrCredentialsRequest;\nimport org.signal.chat.credentials.CheckSvrCredentialsResponse;\nimport org.signal.chat.credentials.SimpleExternalServiceCredentialsAnonymousGrpc;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\n\npublic class ExternalServiceCredentialsAnonymousGrpcService extends\n    SimpleExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousImplBase {\n\n  private static final long MAX_SVR_PASSWORD_AGE_SECONDS = TimeUnit.DAYS.toSeconds(30);\n\n  private final ExternalServiceCredentialsGenerator svrCredentialsGenerator;\n\n  private final AccountsManager accountsManager;\n\n\n  public static ExternalServiceCredentialsAnonymousGrpcService create(\n      final AccountsManager accountsManager,\n      final WhisperServerConfiguration chatConfiguration) {\n    return new ExternalServiceCredentialsAnonymousGrpcService(\n        accountsManager,\n        ExternalServiceDefinitions.SVR.generatorFactory().apply(chatConfiguration, Clock.systemUTC())\n    );\n  }\n\n  @VisibleForTesting\n  ExternalServiceCredentialsAnonymousGrpcService(\n      final AccountsManager accountsManager,\n      final ExternalServiceCredentialsGenerator svrCredentialsGenerator) {\n    this.accountsManager = requireNonNull(accountsManager);\n    this.svrCredentialsGenerator = requireNonNull(svrCredentialsGenerator);\n  }\n\n  @Override\n  public CheckSvrCredentialsResponse checkSvrCredentials(final CheckSvrCredentialsRequest request) {\n    final List<String> tokens = request.getPasswordsList();\n    final List<ExternalServiceCredentialsSelector.CredentialInfo> credentials = ExternalServiceCredentialsSelector.check(\n        tokens,\n        svrCredentialsGenerator,\n        MAX_SVR_PASSWORD_AGE_SECONDS);\n    // the username associated with the provided number\n    final Optional<String> maybeUsername = accountsManager.getByE164(request.getNumber())\n        .map(Account::getUuid)\n        .map(svrCredentialsGenerator::generateForUuid)\n        .map(ExternalServiceCredentials::username);\n    final CheckSvrCredentialsResponse.Builder builder = CheckSvrCredentialsResponse.newBuilder();\n    for (ExternalServiceCredentialsSelector.CredentialInfo credentialInfo : credentials) {\n      final AuthCheckResult authCheckResult;\n      if (!credentialInfo.valid()) {\n        authCheckResult = AuthCheckResult.AUTH_CHECK_RESULT_INVALID;\n      } else {\n        final String username = credentialInfo.credentials().username();\n        // does this credential match the account id for the e164 provided in the request?\n        authCheckResult = maybeUsername.map(username::equals).orElse(false)\n            ? AuthCheckResult.AUTH_CHECK_RESULT_MATCH\n            : AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH;\n      }\n      builder.putMatches(credentialInfo.token(), authCheckResult);\n    }\n    return builder.build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.util.Map;\nimport org.signal.chat.credentials.ExternalServiceType;\nimport org.signal.chat.credentials.GetExternalServiceCredentialsRequest;\nimport org.signal.chat.credentials.GetExternalServiceCredentialsResponse;\nimport org.signal.chat.credentials.SimpleExternalServiceCredentialsGrpc;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\n\npublic class ExternalServiceCredentialsGrpcService extends SimpleExternalServiceCredentialsGrpc.ExternalServiceCredentialsImplBase {\n\n  private final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType;\n\n  private final RateLimiters rateLimiters;\n\n\n  public static ExternalServiceCredentialsGrpcService createForAllExternalServices(\n      final WhisperServerConfiguration chatConfiguration,\n      final RateLimiters rateLimiters) {\n    return new ExternalServiceCredentialsGrpcService(\n        ExternalServiceDefinitions.createExternalServiceList(chatConfiguration, Clock.systemUTC()),\n        rateLimiters\n    );\n  }\n\n  @VisibleForTesting\n  ExternalServiceCredentialsGrpcService(\n      final Map<ExternalServiceType, ExternalServiceCredentialsGenerator> credentialsGeneratorByType,\n      final RateLimiters rateLimiters) {\n    this.credentialsGeneratorByType = requireNonNull(credentialsGeneratorByType);\n    this.rateLimiters = requireNonNull(rateLimiters);\n  }\n\n  @Override\n  public GetExternalServiceCredentialsResponse getExternalServiceCredentials(final GetExternalServiceCredentialsRequest request)\n      throws RateLimitExceededException {\n    final ExternalServiceCredentialsGenerator credentialsGenerator = this.credentialsGeneratorByType\n        .get(request.getExternalService());\n    if (credentialsGenerator == null) {\n      throw GrpcExceptions.fieldViolation(\"externalService\", \"Invalid external service type\");\n    }\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    rateLimiters.forDescriptor(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS).validate(authenticatedDevice.accountIdentifier());\n    final ExternalServiceCredentials externalServiceCredentials = credentialsGenerator\n        .generateForUuid(authenticatedDevice.accountIdentifier());\n    return GetExternalServiceCredentialsResponse.newBuilder()\n        .setUsername(externalServiceCredentials.username())\n        .setPassword(externalServiceCredentials.password())\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceDefinitions.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static java.util.Objects.requireNonNull;\n\nimport java.time.Clock;\nimport java.util.Arrays;\nimport java.util.Map;\nimport java.util.function.BiFunction;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.signal.chat.credentials.ExternalServiceType;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.PaymentsServiceConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\n\nenum ExternalServiceDefinitions {\n  DIRECTORY(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, (chatConfig, clock) -> {\n    final DirectoryV2ClientConfiguration cfg = chatConfig.getDirectoryV2Configuration().getDirectoryV2ClientConfiguration();\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .withUserDerivationKey(cfg.userIdTokenSharedSecret())\n        .prependUsername(false)\n        .withClock(clock)\n        .build();\n  }),\n  PAYMENTS(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, (chatConfig, clock) -> {\n    final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .prependUsername(true)\n        .build();\n  }),\n  SVR(ExternalServiceType.EXTERNAL_SERVICE_TYPE_SVR, (chatConfig, clock) -> {\n    final SecureValueRecoveryConfiguration cfg = chatConfig.getSvr2Configuration();\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .withUserDerivationKey(cfg.userIdTokenSharedSecret().value())\n        .prependUsername(false)\n        .withDerivedUsernameTruncateLength(16)\n        .withClock(clock)\n        .build();\n  }),\n  STORAGE(ExternalServiceType.EXTERNAL_SERVICE_TYPE_STORAGE, (chatConfig, clock) -> {\n    final PaymentsServiceConfiguration cfg = chatConfig.getPaymentsServiceConfiguration();\n    return ExternalServiceCredentialsGenerator\n        .builder(cfg.userAuthenticationTokenSharedSecret())\n        .prependUsername(true)\n        .build();\n  }),\n  ;\n\n  private final ExternalServiceType externalService;\n\n  private final BiFunction<WhisperServerConfiguration, Clock, ExternalServiceCredentialsGenerator> generatorFactory;\n\n  ExternalServiceDefinitions(\n      final ExternalServiceType externalService,\n      final BiFunction<WhisperServerConfiguration, Clock, ExternalServiceCredentialsGenerator> factory) {\n    this.externalService = requireNonNull(externalService);\n    this.generatorFactory = requireNonNull(factory);\n  }\n\n  public static Map<ExternalServiceType, ExternalServiceCredentialsGenerator> createExternalServiceList(\n      final WhisperServerConfiguration chatConfiguration,\n      final Clock clock) {\n    return Arrays.stream(values())\n        .map(esd -> Pair.of(esd.externalService, esd.generatorFactory().apply(chatConfiguration, clock)))\n        .collect(Collectors.toMap(Pair::getKey, Pair::getValue));\n  }\n\n  public BiFunction<WhisperServerConfiguration, Clock, ExternalServiceCredentialsGenerator> generatorFactory() {\n    return generatorFactory;\n  }\n\n  ExternalServiceType externalService() {\n    return externalService;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/GroupSendTokenUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport io.grpc.StatusException;\nimport java.time.Clock;\nimport java.util.Collection;\nimport java.util.List;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\n\npublic class GroupSendTokenUtil {\n\n  private final ServerSecretParams serverSecretParams;\n  private final Clock clock;\n\n  public GroupSendTokenUtil(final ServerSecretParams serverSecretParams, final Clock clock) {\n    this.serverSecretParams = serverSecretParams;\n    this.clock = clock;\n  }\n\n\n  public boolean checkGroupSendToken(final ByteString groupSendToken, final ServiceIdentifier serviceIdentifier) {\n    return checkGroupSendToken(groupSendToken, List.of(serviceIdentifier.toLibsignal()));\n  }\n\n  public boolean checkGroupSendToken(final ByteString groupSendToken, final Collection<ServiceId> serviceIds) {\n    try {\n      final GroupSendFullToken token = new GroupSendFullToken(groupSendToken.toByteArray());\n      final GroupSendDerivedKeyPair groupSendKeyPair =\n          GroupSendDerivedKeyPair.forExpiration(token.getExpiration(), serverSecretParams);\n      token.verify(serviceIds, clock.instant(), groupSendKeyPair);\n      return true;\n    } catch (final InvalidInputException e) {\n      throw GrpcExceptions.fieldViolation(\"group_send_token\", \"malformed group send token\");\n    } catch (VerificationFailedException e) {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptor.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Metadata;\nimport io.grpc.MethodDescriptor;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicGrpcAllowListConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\npublic class GrpcAllowListInterceptor implements ServerInterceptor {\n\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n\n\n  public GrpcAllowListInterceptor(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> serverCall,\n      final Metadata metadata, final ServerCallHandler<ReqT, RespT> next) {\n    final DynamicGrpcAllowListConfiguration allowList = this.dynamicConfigurationManager.getConfiguration().getGrpcAllowList();\n    final MethodDescriptor<ReqT, RespT> methodDescriptor = serverCall.getMethodDescriptor();\n    if (!allowList.enableAll() &&\n        !allowList.enabledServices().contains(methodDescriptor.getServiceName()) &&\n        !allowList.enabledMethods().contains(methodDescriptor.getFullMethodName())) {\n      return ServerInterceptorUtil.closeWithStatus(serverCall, Status.UNIMPLEMENTED);\n    }\n    return next.startCall(serverCall, metadata);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/GrpcExceptions.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.Any;\nimport com.google.rpc.BadRequest;\nimport com.google.rpc.ErrorInfo;\nimport com.google.rpc.RetryInfo;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport io.grpc.protobuf.StatusProto;\nimport java.time.Duration;\nimport javax.annotation.Nullable;\n\npublic class GrpcExceptions {\n\n  public static final String DOMAIN = \"grpc.chat.signal.org\";\n\n  private static final Any ERROR_INFO_CONSTRAINT_VIOLATED = Any.pack(ErrorInfo.newBuilder()\n      .setDomain(DOMAIN)\n      .setReason(\"CONSTRAINT_VIOLATED\")\n      .build());\n\n  private static final Any ERROR_INFO_RESOURCE_EXHAUSTED = Any.pack(ErrorInfo.newBuilder()\n      .setDomain(DOMAIN)\n      .setReason(\"RESOURCE_EXHAUSTED\")\n      .build());\n\n  private static final Any ERROR_INFO_INVALID_CREDENTIALS = Any.pack(ErrorInfo.newBuilder()\n      .setDomain(DOMAIN)\n      .setReason(\"INVALID_CREDENTIALS\")\n      .build());\n\n  private static final Any ERROR_INFO_BAD_AUTHENTICATION = Any.pack(ErrorInfo.newBuilder()\n      .setDomain(DOMAIN)\n      .setReason(\"BAD_AUTHENTICATION\")\n      .build());\n\n  private static final com.google.rpc.Status UPGRADE_REQUIRED = com.google.rpc.Status.newBuilder()\n      .setCode(Status.Code.INVALID_ARGUMENT.value())\n      .setMessage(\"Upgrade required\")\n      .addDetails(Any.pack(ErrorInfo.newBuilder()\n          .setDomain(DOMAIN)\n          .setReason(\"UPGRADE_REQUIRED\")\n          .build()))\n      .build();\n\n\n  private GrpcExceptions() {\n  }\n\n  /// The client version provided in the User-Agent is no longer supported. The client must upgrade to use the service.\n  ///\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException upgradeRequired() {\n    return StatusProto.toStatusRuntimeException(UPGRADE_REQUIRED);\n  }\n\n  /// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always\n  /// possible to check this constraint without communicating with the chat server. This always represents a client bug\n  /// or out of date client. Additional information about the violating field will be included in the metadata.\n  ///\n  /// @param fieldName The name of the field that violated a service constraint\n  /// @param message   Additional context about the constraint violation\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException fieldViolation(final String fieldName, @Nullable final String message) {\n    return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.INVALID_ARGUMENT.value())\n        .setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))\n        .addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)\n        .addDetails(Any.pack(BadRequest.newBuilder()\n            .addFieldViolations(BadRequest.FieldViolation.newBuilder()\n                .setField(fieldName)\n                .setDescription(messageOrDefault(message, Status.Code.INVALID_ARGUMENT)))\n            .build()))\n        .build());\n  }\n\n  /// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always\n  /// possible to check this constraint without communicating with the chat server. This always represents a client bug\n  /// or out of date client.\n  ///\n  /// @param message   Additional context about the constraint violation\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException invalidArguments(@Nullable final String message) {\n    return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.INVALID_ARGUMENT.value())\n        .setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))\n        .addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)\n        .build());\n  }\n\n  /// The RPC argument violated a constraint that was annotated or documented in the service definition. It is always\n  /// possible to check this constraint without communicating with the chat server. This always represents a client bug\n  /// or out of date client.\n  ///\n  /// @param message   Additional context about the constraint violation\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException constraintViolation(@Nullable final String message) {\n    return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.INVALID_ARGUMENT.value())\n        .setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))\n        .addDetails(ERROR_INFO_CONSTRAINT_VIOLATED)\n        .build());\n  }\n\n  ///  The request has incorrectly set authentication credentials for the RPC. This represents a client bug where the\n  /// authorization header is not correct for the RPC. For example,\n  ///\n  ///  - The RPC was for an anonymous service, but included an Authentication header in the RPC metadata\n  ///  - The RPC should only be made by the primary device, but the request had linked device credentials\n  ///\n  /// @param message indicating why the credentials were set incorrectly\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException badAuthentication(@Nullable final String message) {\n    return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.INVALID_ARGUMENT.value())\n        .setMessage(messageOrDefault(message, Status.Code.INVALID_ARGUMENT))\n        .addDetails(ERROR_INFO_BAD_AUTHENTICATION)\n        .build());\n  }\n\n  /// The account credentials provided in the authorization header are no longer valid.\n  ///\n  /// @param message indicating why the credentials were invalid\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException invalidCredentials(@Nullable final String message) {\n    return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.UNAUTHENTICATED.value())\n        .setMessage(messageOrDefault(message, Status.Code.UNAUTHENTICATED))\n        .addDetails(ERROR_INFO_INVALID_CREDENTIALS)\n        .build());\n  }\n\n  /// A server-side resource was exhausted. The details field may include a RetryInfo message that includes the amount\n  /// of time in seconds the client should wait before retrying the request.\n  ///\n  /// If a RetryInfo is present, the client must wait the indicated time before retrying the request. If absent, the\n  /// client should retry with an exponential backoff.\n  ///\n  /// @param retryDuration If present, the duration the client should wait before retrying the request\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException rateLimitExceeded(@Nullable final Duration retryDuration) {\n    final com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.RESOURCE_EXHAUSTED.value())\n        .addDetails(ERROR_INFO_RESOURCE_EXHAUSTED);\n\n    if (retryDuration != null) {\n      builder.addDetails(Any.pack(RetryInfo.newBuilder()\n          .setRetryDelay(com.google.protobuf.Duration.newBuilder()\n              .setSeconds(retryDuration.getSeconds())\n              .setNanos(retryDuration.getNano()))\n          .build()));\n    }\n    return StatusProto.toStatusRuntimeException(builder.build());\n  }\n\n  /// There was an internal error processing the RPC. The client should retry the request with exponential backoff.\n  ///\n  /// @return A [StatusRuntimeException] encoding the error\n  public static StatusRuntimeException unavailable(@Nullable final String message) {\n    return StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.UNAVAILABLE.value())\n        .setMessage(messageOrDefault(message, Status.Code.UNAVAILABLE))\n        .addDetails(Any.pack(ErrorInfo.newBuilder()\n            .setDomain(DOMAIN)\n            .setReason(\"UNAVAILABLE\")\n            .build()))\n        .build());\n  }\n\n  private static String messageOrDefault(@Nullable final String message, Status.Code code) {\n    return message == null ? code.toString() : message;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/IdentityTypeUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Status;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\n\npublic class IdentityTypeUtil {\n\n  private IdentityTypeUtil() {\n  }\n\n  public static IdentityType fromGrpcIdentityType(final org.signal.chat.common.IdentityType grpcIdentityType) {\n    return switch (grpcIdentityType) {\n      case IDENTITY_TYPE_ACI -> IdentityType.ACI;\n      case IDENTITY_TYPE_PNI -> IdentityType.PNI;\n      case IDENTITY_TYPE_UNSPECIFIED, UNRECOGNIZED -> throw GrpcExceptions.invalidArguments(\"invalid identity type\");\n    };\n  }\n\n  public static org.signal.chat.common.IdentityType toGrpcIdentityType(final IdentityType identityType) {\n    return switch (identityType) {\n      case ACI -> org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI;\n      case PNI -> org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI;\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeyTransparencyGrpcService.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.grpc.Status;\nimport org.signal.keytransparency.client.AciMonitorRequest;\nimport org.signal.keytransparency.client.ConsistencyParameters;\nimport org.signal.keytransparency.client.DistinguishedRequest;\nimport org.signal.keytransparency.client.DistinguishedResponse;\nimport org.signal.keytransparency.client.E164MonitorRequest;\nimport org.signal.keytransparency.client.E164SearchRequest;\nimport org.signal.keytransparency.client.MonitorRequest;\nimport org.signal.keytransparency.client.MonitorResponse;\nimport org.signal.keytransparency.client.SearchRequest;\nimport org.signal.keytransparency.client.SearchResponse;\nimport org.signal.keytransparency.client.SimpleKeyTransparencyQueryServiceGrpc;\nimport org.signal.keytransparency.client.UsernameHashMonitorRequest;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\n\npublic class KeyTransparencyGrpcService extends\n    SimpleKeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceImplBase {\n  @VisibleForTesting\n  static final int COMMITMENT_INDEX_LENGTH = 32;\n  private final RateLimiters rateLimiters;\n  private final KeyTransparencyServiceClient client;\n\n  public KeyTransparencyGrpcService(final RateLimiters rateLimiters,\n      final KeyTransparencyServiceClient client) {\n    this.rateLimiters = rateLimiters;\n    this.client = client;\n  }\n\n  @Override\n  public SearchResponse search(final SearchRequest request) throws RateLimitExceededException {\n    rateLimiters.getKeyTransparencySearchLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());\n    return client.search(validateSearchRequest(request));\n  }\n\n  @Override\n  public MonitorResponse monitor(final MonitorRequest request) throws RateLimitExceededException {\n    rateLimiters.getKeyTransparencyMonitorLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());\n    return client.monitor(validateMonitorRequest(request));\n  }\n\n  @Override\n  public DistinguishedResponse distinguished(final DistinguishedRequest request) throws RateLimitExceededException {\n    rateLimiters.getKeyTransparencyDistinguishedLimiter().validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());\n    // A client's very first distinguished request will not have a \"last\" parameter\n    if (request.hasLast() && request.getLast() <= 0) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Last tree head size must be positive\").asRuntimeException();\n    }\n    return client.distinguished(request);\n  }\n\n  private SearchRequest validateSearchRequest(final SearchRequest request) {\n    if (request.hasE164SearchRequest()) {\n      final E164SearchRequest e164SearchRequest = request.getE164SearchRequest();\n      if (e164SearchRequest.getUnidentifiedAccessKey().isEmpty() != e164SearchRequest.getE164().isEmpty()) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"Unidentified access key and E164 must be provided together or not at all\").asRuntimeException();\n      }\n    }\n\n    if (!request.getConsistency().hasDistinguished()) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Must provide distinguished tree head size\").asRuntimeException();\n    }\n\n    validateConsistencyParameters(request.getConsistency());\n    return request;\n  }\n\n  private MonitorRequest validateMonitorRequest(final MonitorRequest request) {\n    final AciMonitorRequest aciMonitorRequest = request.getAci();\n\n    try {\n      AciServiceIdentifier.fromBytes(aciMonitorRequest.getAci().toByteArray());\n    } catch (IllegalArgumentException e) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Invalid ACI\").asRuntimeException();\n    }\n    if (aciMonitorRequest.getEntryPosition() <= 0) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Aci entry position must be positive\").asRuntimeException();\n    }\n    if (aciMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Aci commitment index must be 32 bytes\").asRuntimeException();\n    }\n\n    if (request.hasUsernameHash()) {\n      final UsernameHashMonitorRequest usernameHashMonitorRequest = request.getUsernameHash();\n      if (usernameHashMonitorRequest.getUsernameHash().isEmpty()) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"Username hash cannot be empty\").asRuntimeException();\n      }\n      if (usernameHashMonitorRequest.getUsernameHash().size() != AccountController.USERNAME_HASH_LENGTH) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"Invalid username hash length\").asRuntimeException();\n      }\n      if (usernameHashMonitorRequest.getEntryPosition() <= 0) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"Username hash entry position must be positive\").asRuntimeException();\n      }\n      if (usernameHashMonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"Username hash commitment index must be 32 bytes\").asRuntimeException();\n      }\n    }\n\n    if (request.hasE164()) {\n      final E164MonitorRequest e164MonitorRequest = request.getE164();\n      if (e164MonitorRequest.getE164().isEmpty()) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"E164 cannot be empty\").asRuntimeException();\n      }\n      if (e164MonitorRequest.getEntryPosition() <= 0) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"E164 entry position must be positive\").asRuntimeException();\n      }\n      if (e164MonitorRequest.getCommitmentIndex().size() != COMMITMENT_INDEX_LENGTH) {\n        throw Status.INVALID_ARGUMENT.withDescription(\"E164 commitment index must be 32 bytes\").asRuntimeException();\n      }\n    }\n\n    if (!request.getConsistency().hasDistinguished() || !request.getConsistency().hasLast()) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Must provide distinguished and last tree head sizes\").asRuntimeException();\n    }\n\n    validateConsistencyParameters(request.getConsistency());\n    return request;\n  }\n\n  private static void validateConsistencyParameters(final ConsistencyParameters consistency) {\n    if (consistency.getDistinguished() <= 0) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Distinguished tree head size must be positive\").asRuntimeException();\n    }\n\n    if (consistency.hasLast() && consistency.getLast() <= 0) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Last tree head size must be positive\").asRuntimeException();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.util.Arrays;\nimport java.util.concurrent.Flow;\nimport org.signal.chat.errors.FailedUnidentifiedAuthorization;\nimport org.signal.chat.errors.NotFound;\nimport org.signal.chat.keys.AccountPreKeyBundles;\nimport org.signal.chat.keys.CheckIdentityKeyRequest;\nimport org.signal.chat.keys.CheckIdentityKeyResponse;\nimport org.signal.chat.keys.GetPreKeysAnonymousRequest;\nimport org.signal.chat.keys.GetPreKeysAnonymousResponse;\nimport org.signal.chat.keys.SimpleKeysAnonymousGrpc;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport reactor.adapter.JdkFlowAdapter;\nimport reactor.core.publisher.Mono;\nimport reactor.util.function.Tuples;\n\npublic class KeysAnonymousGrpcService extends SimpleKeysAnonymousGrpc.KeysAnonymousImplBase {\n\n  private final AccountsManager accountsManager;\n  private final KeysManager keysManager;\n  private final GroupSendTokenUtil groupSendTokenUtil;\n\n  public KeysAnonymousGrpcService(\n      final AccountsManager accountsManager, final KeysManager keysManager, final ServerSecretParams serverSecretParams, final Clock clock) {\n    this.accountsManager = accountsManager;\n    this.keysManager = keysManager;\n    groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, clock);\n  }\n\n  @Override\n  public GetPreKeysAnonymousResponse getPreKeys(final GetPreKeysAnonymousRequest request) {\n    final ServiceIdentifier serviceIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getTargetIdentifier());\n\n    final byte deviceId = request.getRequest().hasDeviceId()\n        ? DeviceIdUtil.validate(request.getRequest().getDeviceId())\n        : KeysGrpcHelper.ALL_DEVICES;\n\n    return switch (request.getAuthorizationCase()) {\n      case GROUP_SEND_TOKEN -> {\n        if (!groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), serviceIdentifier)) {\n          yield GetPreKeysAnonymousResponse.newBuilder()\n              .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n              .build();\n        }\n\n        yield accountsManager.getByServiceIdentifier(serviceIdentifier)\n            .flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier, deviceId, keysManager))\n            .map(accountPreKeyBundles -> GetPreKeysAnonymousResponse.newBuilder().setPreKeys(accountPreKeyBundles).build())\n            .orElseGet(() ->  GetPreKeysAnonymousResponse.newBuilder()\n                .setTargetNotFound(NotFound.getDefaultInstance())\n                .build());\n      }\n\n      case UNIDENTIFIED_ACCESS_KEY -> accountsManager.getByServiceIdentifier(serviceIdentifier)\n          .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, request.getUnidentifiedAccessKey().toByteArray()))\n          .flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier, deviceId, keysManager))\n          .map(accountPreKeyBundles -> GetPreKeysAnonymousResponse.newBuilder().setPreKeys(accountPreKeyBundles).build())\n          .orElseGet(() -> GetPreKeysAnonymousResponse.newBuilder()\n              .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n              .build());\n\n      case UNRESTRICTED_ACCESS -> accountsManager.getByServiceIdentifier(serviceIdentifier)\n          .filter(Account::isUnrestrictedUnidentifiedAccess)\n          .flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, serviceIdentifier, deviceId, keysManager))\n          .map(accountPreKeyBundles -> GetPreKeysAnonymousResponse.newBuilder().setPreKeys(accountPreKeyBundles).build())\n          .orElseGet(() -> GetPreKeysAnonymousResponse.newBuilder()\n              .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n              .build());\n\n      case AUTHORIZATION_NOT_SET -> throw GrpcExceptions.fieldViolation(\"authorization\", \"invalid authorization type\");\n    };\n  }\n\n  @Override\n  public Flow.Publisher<CheckIdentityKeyResponse> checkIdentityKeys(final Flow.Publisher<CheckIdentityKeyRequest> requests) {\n    return JdkFlowAdapter.publisherToFlowPublisher(JdkFlowAdapter.flowPublisherToFlux(requests)\n        .map(request -> Tuples.of(ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier()),\n            request.getFingerprint().toByteArray()))\n        .flatMap(serviceIdentifierAndFingerprint -> Mono.fromFuture(\n                () -> accountsManager.getByServiceIdentifierAsync(serviceIdentifierAndFingerprint.getT1()))\n            .flatMap(Mono::justOrEmpty)\n            .filter(account -> !fingerprintMatches(account.getIdentityKey(serviceIdentifierAndFingerprint.getT1()\n                .identityType()), serviceIdentifierAndFingerprint.getT2()))\n            .map(account -> CheckIdentityKeyResponse.newBuilder()\n                .setTargetIdentifier(\n                    ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifierAndFingerprint.getT1()))\n                .setIdentityKey(ByteString.copyFrom(account.getIdentityKey(serviceIdentifierAndFingerprint.getT1()\n                    .identityType()).serialize()))\n                .build())));\n  }\n\n  private static boolean fingerprintMatches(final IdentityKey identityKey, final byte[] fingerprint) {\n    final byte[] digest;\n    try {\n      digest = MessageDigest.getInstance(\"SHA-256\").digest(identityKey.serialize());\n    } catch (NoSuchAlgorithmException e) {\n      // SHA-256 should always be supported as an algorithm\n      throw new AssertionError(\"All Java implementations must support the SHA-256 message digest\");\n    }\n\n    return Arrays.equals(digest, 0, 4, fingerprint, 0, 4);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysGrpcHelper.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport org.signal.chat.common.EcPreKey;\nimport org.signal.chat.common.EcSignedPreKey;\nimport org.signal.chat.common.KemSignedPreKey;\nimport org.signal.chat.keys.AccountPreKeyBundles;\nimport org.signal.chat.keys.DevicePreKeyBundle;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.KeyIdUtil;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\n\nclass KeysGrpcHelper {\n\n  static final byte ALL_DEVICES = 0;\n\n  /// Fetch {@link AccountPreKeyBundles} from the targetAccount\n  ///\n  /// @param targetAccount the account to fetch pre-key bundles from\n  /// @param targetServiceIdentifier the service identifier for the target Account\n  /// @param targetDeviceId the device ID to retrieve pre-key bundles for, or [#ALL_DEVICES] if all devices should be\n  /// retrieved\n  /// @param keysManager The {@link KeysManager} to lookup pre-keys from\n  ///\n  /// @return the requested bundles, or empty if the keys for the `targetAccount` do not exist\n  static Optional<AccountPreKeyBundles> getPreKeys(final Account targetAccount,\n      final ServiceIdentifier targetServiceIdentifier,\n      final byte targetDeviceId,\n      final KeysManager keysManager) {\n\n    final Stream<Device> devices = targetDeviceId == ALL_DEVICES\n        ? targetAccount.getDevices().stream()\n        : targetAccount.getDevice(targetDeviceId).stream();\n\n    final String userAgent = RequestAttributesUtil.getUserAgent().orElse(null);\n\n    final Map<Byte, CompletableFuture<Optional<KeysManager.DevicePreKeys>>> takeKeyFuturesByDeviceId =\n        devices.collect(Collectors.toMap(\n            Device::getId,\n            device -> keysManager.takeDevicePreKeys(device.getId(), targetServiceIdentifier, userAgent)));\n\n    CompletableFuture.allOf(takeKeyFuturesByDeviceId.values().toArray(CompletableFuture[]::new)).join();\n\n    final Map<Byte, KeysManager.DevicePreKeys> preKeysByDeviceId = takeKeyFuturesByDeviceId.entrySet().stream()\n        .filter(entry -> entry.getValue().resultNow().isPresent())\n        .collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().resultNow().orElseThrow()));\n\n    if (preKeysByDeviceId.isEmpty()) {\n      // If there were no devices with valid prekey bundles in the account, the account is gone\n      return Optional.empty();\n    }\n\n    final AccountPreKeyBundles.Builder preKeyBundlesBuilder = AccountPreKeyBundles.newBuilder()\n        .setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetServiceIdentifier.identityType()).serialize()));\n\n    preKeysByDeviceId.forEach((deviceId, devicePreKeys) -> {\n      final Device device = targetAccount.getDevice(deviceId).orElseThrow();\n\n\n      final DevicePreKeyBundle.Builder builder = DevicePreKeyBundle.newBuilder()\n          .setEcSignedPreKey(EcSignedPreKey.newBuilder()\n              .setKeyId(KeyIdUtil.toUnsignedInt(devicePreKeys.ecSignedPreKey().keyId()))\n              .setPublicKey(ByteString.copyFrom(devicePreKeys.ecSignedPreKey().serializedPublicKey()))\n              .setSignature(ByteString.copyFrom(devicePreKeys.ecSignedPreKey().signature())))\n          .setKemOneTimePreKey(KemSignedPreKey.newBuilder()\n              .setKeyId(KeyIdUtil.toUnsignedInt(devicePreKeys.kemSignedPreKey().keyId()))\n              .setPublicKey(ByteString.copyFrom(devicePreKeys.kemSignedPreKey().serializedPublicKey()))\n              .setSignature(ByteString.copyFrom(devicePreKeys.kemSignedPreKey().signature())))\n          .setRegistrationId(device.getRegistrationId(targetServiceIdentifier.identityType()));\n\n      devicePreKeys.ecPreKey().ifPresent(ecPreKey -> builder.setEcOneTimePreKey(EcPreKey.newBuilder()\n          .setKeyId(KeyIdUtil.toUnsignedInt(ecPreKey.keyId()))\n          .setPublicKey(ByteString.copyFrom(ecPreKey.serializedPublicKey()))));\n\n      preKeyBundlesBuilder.putDevicePreKeys(deviceId, builder.build());\n    });\n\n    return Optional.of(preKeyBundlesBuilder.build());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/KeysGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.StatusRuntimeException;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.BiFunction;\nimport org.signal.chat.common.EcPreKey;\nimport org.signal.chat.common.EcSignedPreKey;\nimport org.signal.chat.common.KemSignedPreKey;\nimport org.signal.chat.errors.NotFound;\nimport org.signal.chat.keys.GetPreKeyCountRequest;\nimport org.signal.chat.keys.GetPreKeyCountResponse;\nimport org.signal.chat.keys.GetPreKeysRequest;\nimport org.signal.chat.keys.GetPreKeysResponse;\nimport org.signal.chat.keys.SetEcSignedPreKeyRequest;\nimport org.signal.chat.keys.SetKemLastResortPreKeyRequest;\nimport org.signal.chat.keys.SetOneTimeEcPreKeysRequest;\nimport org.signal.chat.keys.SetOneTimeKemSignedPreKeysRequest;\nimport org.signal.chat.keys.SetPreKeyResponse;\nimport org.signal.chat.keys.SimpleKeysGrpc;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\n\npublic class KeysGrpcService extends SimpleKeysGrpc.KeysImplBase {\n\n  private final AccountsManager accountsManager;\n  private final KeysManager keysManager;\n  private final RateLimiters rateLimiters;\n\n  private static final StatusRuntimeException INVALID_PUBLIC_KEY_EXCEPTION =\n      GrpcExceptions.fieldViolation(\"pre_keys\", \"invalid public key\");\n\n  private static final StatusRuntimeException INVALID_SIGNATURE_EXCEPTION =\n      GrpcExceptions.fieldViolation(\"pre_keys\", \"pre-key signature did not match account identity key\");\n\n  public KeysGrpcService(final AccountsManager accountsManager,\n      final KeysManager keysManager,\n      final RateLimiters rateLimiters) {\n\n    this.accountsManager = accountsManager;\n    this.keysManager = keysManager;\n    this.rateLimiters = rateLimiters;\n  }\n\n  @Override\n  public GetPreKeyCountResponse getPreKeyCount(final GetPreKeyCountRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final Account account = getAuthenticatedAccount(authenticatedDevice.accountIdentifier());\n\n    final CompletableFuture<Integer> aciEcKeyCountFuture =\n        keysManager.getEcCount(account.getIdentifier(IdentityType.ACI), authenticatedDevice.deviceId());\n\n    final CompletableFuture<Integer> pniEcKeyCountFuture =\n        keysManager.getEcCount(account.getIdentifier(IdentityType.PNI), authenticatedDevice.deviceId());\n\n    final CompletableFuture<Integer> aciKemKeyCountFuture =\n        keysManager.getPqCount(account.getIdentifier(IdentityType.ACI), authenticatedDevice.deviceId());\n\n    final CompletableFuture<Integer> pniKemKeyCountFuture =\n        keysManager.getPqCount(account.getIdentifier(IdentityType.PNI), authenticatedDevice.deviceId());\n\n    CompletableFuture.allOf(aciEcKeyCountFuture, pniEcKeyCountFuture, aciKemKeyCountFuture, pniKemKeyCountFuture).join();\n\n    return GetPreKeyCountResponse.newBuilder()\n        .setAciEcPreKeyCount(aciEcKeyCountFuture.resultNow())\n        .setPniEcPreKeyCount(pniEcKeyCountFuture.resultNow())\n        .setAciKemPreKeyCount(aciKemKeyCountFuture.resultNow())\n        .setPniKemPreKeyCount(pniKemKeyCountFuture.resultNow())\n        .build();\n  }\n\n  @Override\n  public GetPreKeysResponse getPreKeys(final GetPreKeysRequest request) throws RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    final ServiceIdentifier targetIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getTargetIdentifier());\n\n    final byte deviceId = request.hasDeviceId()\n        ? DeviceIdUtil.validate(request.getDeviceId())\n        : KeysGrpcHelper.ALL_DEVICES;\n\n    final String rateLimitKey = authenticatedDevice.accountIdentifier() + \".\" +\n        authenticatedDevice.deviceId() + \"__\" +\n        targetIdentifier.uuid() + \".\" +\n        deviceId;\n\n    rateLimiters.getPreKeysLimiter().validate(rateLimitKey);\n\n    return accountsManager.getByServiceIdentifier(targetIdentifier)\n        .flatMap(targetAccount -> KeysGrpcHelper.getPreKeys(targetAccount, targetIdentifier, deviceId, keysManager))\n        .map(accountPreKeyBundles -> GetPreKeysResponse.newBuilder()\n            .setPreKeys(accountPreKeyBundles)\n            .build())\n        .orElseGet(() -> GetPreKeysResponse.newBuilder()\n            .setTargetNotFound(NotFound.getDefaultInstance())\n            .build());\n  }\n\n  @Override\n  public SetPreKeyResponse setOneTimeEcPreKeys(final SetOneTimeEcPreKeysRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    storeOneTimePreKeys(authenticatedDevice.accountIdentifier(),\n        request.getPreKeysList(),\n        IdentityTypeUtil.fromGrpcIdentityType(request.getIdentityType()),\n        (requestPreKey, _) -> checkEcPreKey(requestPreKey),\n        (identifier, preKeys) -> keysManager.storeEcOneTimePreKeys(identifier, authenticatedDevice.deviceId(), preKeys));\n\n    return SetPreKeyResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetPreKeyResponse setOneTimeKemSignedPreKeys(final SetOneTimeKemSignedPreKeysRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    storeOneTimePreKeys(authenticatedDevice.accountIdentifier(),\n        request.getPreKeysList(),\n        IdentityTypeUtil.fromGrpcIdentityType(request.getIdentityType()),\n        KeysGrpcService::checkKemSignedPreKey,\n        (identifier, preKeys) -> keysManager.storeKemOneTimePreKeys(identifier, authenticatedDevice.deviceId(), preKeys));\n\n    return SetPreKeyResponse.getDefaultInstance();\n  }\n\n  private <K, R> void storeOneTimePreKeys(final UUID authenticatedAccountUuid,\n      final List<R> requestPreKeys,\n      final IdentityType identityType,\n      final BiFunction<R, IdentityKey, K> extractPreKeyFunction,\n      final BiFunction<UUID, List<K>, CompletableFuture<Void>> storeKeysFunction) {\n\n    final Account account = getAuthenticatedAccount(authenticatedAccountUuid);\n\n    final List<K> preKeys = requestPreKeys.stream()\n        .map(requestPreKey -> extractPreKeyFunction.apply(requestPreKey, account.getIdentityKey(identityType)))\n        .toList();\n\n    storeKeysFunction.apply(account.getIdentifier(identityType), preKeys).join();\n  }\n\n  @Override\n  public SetPreKeyResponse setEcSignedPreKey(final SetEcSignedPreKeyRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    storeRepeatedUseKey(authenticatedDevice.accountIdentifier(),\n        request.getIdentityType(),\n        request.getSignedPreKey(),\n        KeysGrpcService::checkEcSignedPreKey,\n        (account, signedPreKey) -> {\n          final IdentityType identityType = IdentityTypeUtil.fromGrpcIdentityType(request.getIdentityType());\n          final UUID identifier = account.getIdentifier(identityType);\n\n          return keysManager.storeEcSignedPreKeys(identifier, authenticatedDevice.deviceId(), signedPreKey);\n        });\n\n    return SetPreKeyResponse.getDefaultInstance();\n  }\n\n  @Override\n  public SetPreKeyResponse setKemLastResortPreKey(final SetKemLastResortPreKeyRequest request) {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    storeRepeatedUseKey(authenticatedDevice.accountIdentifier(),\n        request.getIdentityType(),\n        request.getSignedPreKey(),\n        KeysGrpcService::checkKemSignedPreKey,\n        (account, lastResortKey) -> {\n          final UUID identifier =\n              account.getIdentifier(IdentityTypeUtil.fromGrpcIdentityType(request.getIdentityType()));\n\n          return keysManager.storePqLastResort(identifier, authenticatedDevice.deviceId(), lastResortKey);\n        });\n\n    return SetPreKeyResponse.getDefaultInstance();\n  }\n\n  private <K, R> void storeRepeatedUseKey(final UUID authenticatedAccountUuid,\n      final org.signal.chat.common.IdentityType identityType,\n      final R storeKeyRequest,\n      final BiFunction<R, IdentityKey, K> extractKeyFunction,\n      final BiFunction<Account, K, CompletableFuture<Void>> storeKeyFunction) {\n\n    final Account account = getAuthenticatedAccount(authenticatedAccountUuid);\n\n    final IdentityKey identityKey = account.getIdentityKey(IdentityTypeUtil.fromGrpcIdentityType(identityType));\n    final K key = extractKeyFunction.apply(storeKeyRequest, identityKey);\n\n    storeKeyFunction.apply(account, key).join();\n  }\n\n  private static ECPreKey checkEcPreKey(final EcPreKey preKey) {\n    try {\n      return new ECPreKey(preKey.getKeyId(), new ECPublicKey(preKey.getPublicKey().toByteArray()));\n    } catch (final InvalidKeyException e) {\n      throw INVALID_PUBLIC_KEY_EXCEPTION;\n    }\n  }\n\n  private static ECSignedPreKey checkEcSignedPreKey(final EcSignedPreKey preKey, final IdentityKey identityKey) {\n    try {\n      final ECSignedPreKey ecSignedPreKey = new ECSignedPreKey(preKey.getKeyId(),\n          new ECPublicKey(preKey.getPublicKey().toByteArray()),\n          preKey.getSignature().toByteArray());\n\n      if (ecSignedPreKey.signatureValid(identityKey)) {\n        return ecSignedPreKey;\n      } else {\n        throw INVALID_SIGNATURE_EXCEPTION;\n      }\n    } catch (final InvalidKeyException e) {\n      throw INVALID_PUBLIC_KEY_EXCEPTION;\n    }\n  }\n\n  private static KEMSignedPreKey checkKemSignedPreKey(final KemSignedPreKey preKey, final IdentityKey identityKey) {\n    try {\n      final KEMSignedPreKey kemSignedPreKey = new KEMSignedPreKey(preKey.getKeyId(),\n          new KEMPublicKey(preKey.getPublicKey().toByteArray()),\n          preKey.getSignature().toByteArray());\n\n      if (kemSignedPreKey.signatureValid(identityKey)) {\n        return kemSignedPreKey;\n      } else {\n        throw INVALID_SIGNATURE_EXCEPTION;\n      }\n    } catch (final InvalidKeyException e) {\n      throw INVALID_PUBLIC_KEY_EXCEPTION;\n    }\n  }\n\n  private Account getAuthenticatedAccount(final UUID authenticatedAccountId) {\n    return accountsManager.getByAccountIdentifier(authenticatedAccountId)\n        .orElseThrow(() -> GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/MessagesAnonymousGrpcService.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport java.time.Clock;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport com.google.protobuf.Empty;\nimport org.signal.chat.errors.FailedUnidentifiedAuthorization;\nimport org.signal.chat.errors.NotFound;\nimport org.signal.chat.messages.IndividualRecipientMessageBundle;\nimport org.signal.chat.messages.SendMessageType;\nimport org.signal.chat.messages.MultiRecipientMismatchedDevices;\nimport org.signal.chat.messages.MultiRecipientSuccess;\nimport org.signal.chat.messages.SendMessageResponse;\nimport org.signal.chat.messages.SendMultiRecipientMessageRequest;\nimport org.signal.chat.messages.SendMultiRecipientMessageResponse;\nimport org.signal.chat.messages.SendMultiRecipientStoryRequest;\nimport org.signal.chat.messages.SendSealedSenderMessageRequest;\nimport org.signal.chat.messages.SendStoryMessageRequest;\nimport org.signal.chat.messages.SimpleMessagesAnonymousGrpc;\nimport org.signal.libsignal.protocol.InvalidMessageException;\nimport org.signal.libsignal.protocol.InvalidVersionException;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.MultiRecipientMismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.push.MessageUtil;\nimport org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;\nimport org.whispersystems.textsecuregcm.spam.MessageType;\nimport org.whispersystems.textsecuregcm.spam.SpamCheckResult;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\n\npublic class MessagesAnonymousGrpcService extends SimpleMessagesAnonymousGrpc.MessagesAnonymousImplBase {\n\n  private final AccountsManager accountsManager;\n  private final RateLimiters rateLimiters;\n  private final MessageSender messageSender;\n  private final GroupSendTokenUtil groupSendTokenUtil;\n  private final CardinalityEstimator messageByteLimitEstimator;\n  private final SpamChecker spamChecker;\n  private final Clock clock;\n\n  private static final SendMessageResponse SEND_MESSAGE_SUCCESS_RESPONSE = SendMessageResponse\n      .newBuilder()\n      .setSuccess(Empty.getDefaultInstance())\n      .build();\n\n  public MessagesAnonymousGrpcService(final AccountsManager accountsManager,\n      final RateLimiters rateLimiters,\n      final MessageSender messageSender,\n      final GroupSendTokenUtil groupSendTokenUtil,\n      final CardinalityEstimator messageByteLimitEstimator,\n      final SpamChecker spamChecker,\n      final Clock clock) {\n\n    this.accountsManager = accountsManager;\n    this.rateLimiters = rateLimiters;\n    this.messageSender = messageSender;\n    this.groupSendTokenUtil = groupSendTokenUtil;\n    this.messageByteLimitEstimator = messageByteLimitEstimator;\n    this.spamChecker = spamChecker;\n    this.clock = clock;\n  }\n\n  @Override\n  public SendMessageResponse sendSingleRecipientMessage(final SendSealedSenderMessageRequest request)\n      throws RateLimitExceededException {\n\n    final ServiceIdentifier destinationServiceIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());\n\n    final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);\n\n    final boolean authorized = switch (request.getAuthorizationCase()) {\n      case UNIDENTIFIED_ACCESS_KEY -> {\n        if (destinationServiceIdentifier.identityType() == IdentityType.PNI) {\n          throw GrpcExceptions.fieldViolation(\"authorization\",\n              \"message for PNI cannot be authenticated with an unidentified access token\");\n        }\n        final byte[] uak = request.getUnidentifiedAccessKey().toByteArray();\n        yield maybeDestination\n            .map(account -> UnidentifiedAccessUtil.checkUnidentifiedAccess(account, uak))\n            // If the destination is not found, return an authorization error instead of not-found. Otherwise,\n            // this would provide an unauthenticated existence check.\n            .orElse(false);\n      }\n      case GROUP_SEND_TOKEN ->\n          groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), destinationServiceIdentifier);\n      case UNRESTRICTED_ACCESS ->\n        maybeDestination.map(account -> account.isUnrestrictedUnidentifiedAccess()).orElse(false);\n      case AUTHORIZATION_NOT_SET ->\n          throw GrpcExceptions.fieldViolation(\"authorization\", \"expected authorization token not provided\");\n    };\n\n    if (!authorized) {\n      return SendMessageResponse.newBuilder()\n          .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n          .build();\n    }\n\n    if (maybeDestination.isEmpty()) {\n      return SendMessageResponse.newBuilder().setDestinationNotFound(NotFound.getDefaultInstance()).build();\n    }\n    \n    return sendIndividualMessage(maybeDestination.get(),\n        destinationServiceIdentifier,\n        request.getMessages(),\n        request.getEphemeral(),\n        request.getUrgent(),\n        false);\n  }\n\n  @Override\n  public SendMessageResponse sendStory(final SendStoryMessageRequest request)\n      throws RateLimitExceededException {\n\n    final ServiceIdentifier destinationServiceIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());\n\n    final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);\n\n    if (maybeDestination.isEmpty()) {\n      // Don't reveal to unauthenticated callers whether a destination account actually exists\n      return SEND_MESSAGE_SUCCESS_RESPONSE;\n    }\n\n    final Account destination = maybeDestination.get();\n\n    rateLimiters.getStoriesLimiter().validate(destination.getIdentifier(IdentityType.ACI));\n\n    return sendIndividualMessage(destination,\n        destinationServiceIdentifier,\n        request.getMessages(),\n        false,\n        request.getUrgent(),\n        true);\n  }\n\n  private SendMessageResponse sendIndividualMessage(final Account destination,\n      final ServiceIdentifier destinationServiceIdentifier,\n      final IndividualRecipientMessageBundle messages,\n      final boolean ephemeral,\n      final boolean urgent,\n      final boolean story) throws RateLimitExceededException {\n\n    final SpamCheckResult<GrpcChallengeResponse> spamCheckResult =\n        spamChecker.checkForIndividualRecipientSpamGrpc(\n            story ? MessageType.INDIVIDUAL_STORY : MessageType.INDIVIDUAL_SEALED_SENDER,\n            Optional.empty(),\n            Optional.of(destination),\n            destinationServiceIdentifier);\n\n    spamCheckResult.response().ifPresent(grpcResponse ->\n      grpcResponse.throwStatusOr(_ -> GrpcExceptions.rateLimitExceeded(null)));\n\n    try {\n      final int totalPayloadLength = messages.getMessagesMap().values().stream()\n          .mapToInt(message -> message.getPayload().size())\n          .sum();\n\n      rateLimiters.getInboundMessageBytes().validate(destinationServiceIdentifier.uuid(), totalPayloadLength);\n    } catch (final RateLimitExceededException e) {\n      messageByteLimitEstimator.add(destinationServiceIdentifier.uuid().toString());\n      throw e;\n    }\n\n    final Map<Byte, MessageProtos.Envelope> messagesByDeviceId = messages.getMessagesMap().entrySet()\n        .stream()\n        .collect(Collectors.toMap(\n            entry -> DeviceIdUtil.validate(entry.getKey()),\n            entry -> {\n              if (entry.getValue().getType() != SendMessageType.UNIDENTIFIED_SENDER) {\n                throw GrpcExceptions.invalidArguments(\"sealed sender messages must have a type of UNIDENTIFIED_SENDER\");\n              }\n              final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()\n                  .setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER)\n                  .setClientTimestamp(messages.getTimestamp())\n                  .setServerTimestamp(clock.millis())\n                  .setDestinationServiceId(destinationServiceIdentifier.toServiceIdentifierString())\n                  .setEphemeral(ephemeral)\n                  .setUrgent(urgent)\n                  .setContent(entry.getValue().getPayload());\n\n              if (story) {\n                // Avoid sending this field if it's false.\n                envelopeBuilder.setStory(true);\n              }\n\n              spamCheckResult.token().ifPresent(reportSpamToken ->\n                  envelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken)));\n\n              return envelopeBuilder.build();\n            }\n        ));\n\n    final Map<Byte, Integer> registrationIdsByDeviceId = messages.getMessagesMap().entrySet().stream()\n        .collect(Collectors.toMap(\n            entry -> entry.getKey().byteValue(),\n            entry -> entry.getValue().getRegistrationId()));\n\n    try {\n      messageSender.sendMessages(destination,\n          destinationServiceIdentifier,\n          messagesByDeviceId,\n          registrationIdsByDeviceId,\n          Optional.empty(),\n          RequestAttributesUtil.getUserAgent().orElse(null));\n\n      return SEND_MESSAGE_SUCCESS_RESPONSE;\n    } catch (final MismatchedDevicesException e) {\n      return SendMessageResponse.newBuilder()\n          .setMismatchedDevices(MessagesGrpcHelper.buildMismatchedDevices(destinationServiceIdentifier, e.getMismatchedDevices()))\n          .build();\n    } catch (final MessageTooLargeException e) {\n      throw GrpcExceptions.invalidArguments(\"message too large\");\n    }\n  }\n\n  @Override\n  public SendMultiRecipientMessageResponse sendMultiRecipientMessage(final SendMultiRecipientMessageRequest request) {\n\n    final SealedSenderMultiRecipientMessage multiRecipientMessage =\n        parseAndValidateMultiRecipientMessage(request.getMessage().getPayload().toByteArray());\n\n    if (!groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), multiRecipientMessage.getRecipients().keySet())) {\n      return SendMultiRecipientMessageResponse.newBuilder()\n          .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n          .build();\n    }\n\n    return sendMultiRecipientMessage(multiRecipientMessage,\n        request.getMessage().getTimestamp(),\n        request.getEphemeral(),\n        request.getUrgent(),\n        false);\n  }\n\n  @Override\n  public SendMultiRecipientMessageResponse sendMultiRecipientStory(final SendMultiRecipientStoryRequest request) {\n\n    final SealedSenderMultiRecipientMessage multiRecipientMessage =\n        parseAndValidateMultiRecipientMessage(request.getMessage().getPayload().toByteArray());\n\n    final SendMultiRecipientMessageResponse sendMultiRecipientMessageResponse = sendMultiRecipientMessage(\n        multiRecipientMessage,\n        request.getMessage().getTimestamp(),\n        false,\n        request.getUrgent(),\n        true);\n    if (sendMultiRecipientMessageResponse.hasSuccess()) {\n      // Clear the unresolved recipients for stories\n      return sendMultiRecipientMessageResponse.toBuilder()\n          .setSuccess(MultiRecipientSuccess.getDefaultInstance())\n          .build();\n    } else {\n      return sendMultiRecipientMessageResponse;\n    }\n\n  }\n\n  private SendMultiRecipientMessageResponse sendMultiRecipientMessage(\n      final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final long timestamp,\n      final boolean ephemeral,\n      final boolean urgent,\n      final boolean story) {\n\n    final SpamCheckResult<GrpcChallengeResponse> spamCheckResult =\n        spamChecker.checkForMultiRecipientSpamGrpc(story\n            ? MessageType.MULTI_RECIPIENT_STORY\n            : MessageType.MULTI_RECIPIENT_SEALED_SENDER);\n\n    spamCheckResult.response().ifPresent(response ->\n        response.throwStatusOr(_ -> GrpcExceptions.rateLimitExceeded(null)));\n\n    // At this point, the caller has at least superficially provided the information needed to send a multi-recipient\n    // message. Attempt to resolve the destination service identifiers to Signal accounts.\n    final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients =\n        MessageUtil.resolveRecipients(accountsManager, multiRecipientMessage);\n\n    try {\n      messageSender.sendMultiRecipientMessage(multiRecipientMessage,\n          resolvedRecipients,\n          timestamp,\n          story,\n          ephemeral,\n          urgent,\n          RequestAttributesUtil.getUserAgent().orElse(null))\n          .join();\n\n      final MultiRecipientSuccess.Builder responseBuilder = MultiRecipientSuccess.newBuilder();\n\n      MessageUtil.getUnresolvedRecipients(multiRecipientMessage, resolvedRecipients).stream()\n          .map(ServiceIdentifierUtil::toGrpcServiceIdentifier)\n          .forEach(responseBuilder::addUnresolvedRecipients);\n\n      return SendMultiRecipientMessageResponse.newBuilder().setSuccess(responseBuilder).build();\n    } catch (final MessageTooLargeException e) {\n      throw GrpcExceptions.invalidArguments(\"message for an individual recipient was too large\");\n    } catch (final MultiRecipientMismatchedDevicesException e) {\n      final MultiRecipientMismatchedDevices.Builder mismatchedDevicesBuilder =\n          MultiRecipientMismatchedDevices.newBuilder();\n\n      e.getMismatchedDevicesByServiceIdentifier().forEach((serviceIdentifier, mismatchedDevices) ->\n          mismatchedDevicesBuilder.addMismatchedDevices(MessagesGrpcHelper.buildMismatchedDevices(serviceIdentifier, mismatchedDevices)));\n\n      return SendMultiRecipientMessageResponse.newBuilder()\n          .setMismatchedDevices(mismatchedDevicesBuilder)\n          .build();\n    }\n  }\n\n  private SealedSenderMultiRecipientMessage parseAndValidateMultiRecipientMessage(\n      final byte[] serializedMultiRecipientMessage) {\n\n    final SealedSenderMultiRecipientMessage multiRecipientMessage;\n\n    try {\n      multiRecipientMessage = SealedSenderMultiRecipientMessage.parse(serializedMultiRecipientMessage);\n    } catch (final InvalidMessageException _) {\n      throw GrpcExceptions.fieldViolation(\"payload\", \"invalid multi-recipient message\");\n    } catch (final InvalidVersionException e) {\n      throw GrpcExceptions.fieldViolation(\"payload\", \"unrecognized sealed sender major version\");\n    }\n\n    if (multiRecipientMessage.getRecipients().isEmpty()) {\n      throw GrpcExceptions.fieldViolation(\"payload\", \"recipient list is empty\");\n    }\n\n    // Check that the request is well-formed and doesn't contain repeated entries for the same device for the same\n    // recipient\n    if (MessageUtil.hasDuplicateDevices(multiRecipientMessage)) {\n      throw GrpcExceptions.fieldViolation(\"payload\", \"multi-recipient message contains duplicate recipient\");\n    }\n\n    return multiRecipientMessage;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/MessagesGrpcHelper.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport org.signal.chat.messages.MismatchedDevices;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\n\npublic class MessagesGrpcHelper {\n\n  /**\n   * Translates an internal {@link org.whispersystems.textsecuregcm.controllers.MismatchedDevices} entity to a gRPC\n   * {@link MismatchedDevices} entity.\n   *\n   * @param serviceIdentifier the service identifier to which the mismatched device response applies\n   * @param mismatchedDevices the mismatched device entity to translate to gRPC\n   *\n   * @return a gRPC {@code MismatchedDevices} representation of the given mismatched devices\n   */\n  public static MismatchedDevices buildMismatchedDevices(final ServiceIdentifier serviceIdentifier,\n      final org.whispersystems.textsecuregcm.controllers.MismatchedDevices mismatchedDevices) {\n\n    final MismatchedDevices.Builder mismatchedDevicesBuilder = MismatchedDevices.newBuilder()\n        .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier));\n\n    mismatchedDevices.missingDeviceIds().forEach(mismatchedDevicesBuilder::addMissingDevices);\n    mismatchedDevices.extraDeviceIds().forEach(mismatchedDevicesBuilder::addExtraDevices);\n    mismatchedDevices.staleDeviceIds().forEach(mismatchedDevicesBuilder::addStaleDevices);\n\n    return mismatchedDevicesBuilder.build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/MessagesGrpcService.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport java.time.Clock;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport com.google.protobuf.Empty;\nimport org.signal.chat.errors.NotFound;\nimport org.signal.chat.messages.SendMessageType;\nimport org.signal.chat.messages.IndividualRecipientMessageBundle;\nimport org.signal.chat.messages.SendAuthenticatedSenderMessageRequest;\nimport org.signal.chat.messages.SendMessageAuthenticatedSenderResponse;\nimport org.signal.chat.messages.SendSyncMessageRequest;\nimport org.signal.chat.messages.SimpleMessagesGrpc;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;\nimport org.whispersystems.textsecuregcm.spam.MessageType;\nimport org.whispersystems.textsecuregcm.spam.SpamCheckResult;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\n\nimport static org.whispersystems.textsecuregcm.grpc.MessagesGrpcHelper.buildMismatchedDevices;\n\npublic class MessagesGrpcService extends SimpleMessagesGrpc.MessagesImplBase {\n\n  private final AccountsManager accountsManager;\n  private final RateLimiters rateLimiters;\n  private final MessageSender messageSender;\n  private final CardinalityEstimator messageByteLimitEstimator;\n  private final SpamChecker spamChecker;\n  private final Clock clock;\n\n  private static final SendMessageAuthenticatedSenderResponse SEND_MESSAGE_SUCCESS_RESPONSE =\n      SendMessageAuthenticatedSenderResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build();\n\n  public MessagesGrpcService(final AccountsManager accountsManager,\n      final RateLimiters rateLimiters,\n      final MessageSender messageSender,\n      final CardinalityEstimator messageByteLimitEstimator,\n      final SpamChecker spamChecker,\n      final Clock clock) {\n\n    this.accountsManager = accountsManager;\n    this.rateLimiters = rateLimiters;\n    this.messageSender = messageSender;\n    this.messageByteLimitEstimator = messageByteLimitEstimator;\n    this.spamChecker = spamChecker;\n    this.clock = clock;\n  }\n\n  @Override\n  public SendMessageAuthenticatedSenderResponse sendMessage(final SendAuthenticatedSenderMessageRequest request)\n      throws RateLimitExceededException {\n\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final AciServiceIdentifier senderServiceIdentifier = new AciServiceIdentifier(authenticatedDevice.accountIdentifier());\n    final Account sender = accountsManager.getByServiceIdentifier(senderServiceIdentifier)\n        .orElseThrow(() -> GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n\n    final ServiceIdentifier destinationServiceIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getDestination());\n\n    if (sender.isIdentifiedBy(destinationServiceIdentifier)) {\n      throw GrpcExceptions.invalidArguments(\"use `sendSyncMessage` to send messages to own account\");\n    }\n\n    final Optional<Account> maybeDestination = accountsManager.getByServiceIdentifier(destinationServiceIdentifier);\n    if (maybeDestination.isEmpty()) {\n      return SendMessageAuthenticatedSenderResponse.newBuilder()\n          .setDestinationNotFound(NotFound.getDefaultInstance())\n          .build();\n    }\n    final Account destination = maybeDestination.get();\n\n    rateLimiters.getMessagesLimiter().validate(authenticatedDevice.accountIdentifier(), destination.getUuid());\n\n    return sendMessage(destination,\n        destinationServiceIdentifier,\n        authenticatedDevice,\n        MessageType.INDIVIDUAL_IDENTIFIED_SENDER,\n        request.getMessages(),\n        request.getEphemeral(),\n        request.getUrgent());\n  }\n\n  @Override\n  public SendMessageAuthenticatedSenderResponse sendSyncMessage(final SendSyncMessageRequest request)\n      throws RateLimitExceededException {\n\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final AciServiceIdentifier senderServiceIdentifier = new AciServiceIdentifier(authenticatedDevice.accountIdentifier());\n    final Account sender = accountsManager.getByServiceIdentifier(senderServiceIdentifier)\n        .orElseThrow(() -> GrpcExceptions.invalidCredentials(\"invalid credentials\"));\n\n    return sendMessage(sender,\n        senderServiceIdentifier,\n        authenticatedDevice,\n        MessageType.SYNC,\n        request.getMessages(),\n        false,\n        request.getUrgent());\n  }\n\n  private SendMessageAuthenticatedSenderResponse sendMessage(final Account destination,\n      final ServiceIdentifier destinationServiceIdentifier,\n      final AuthenticatedDevice sender,\n      final MessageType messageType,\n      final IndividualRecipientMessageBundle messages,\n      final boolean ephemeral,\n      final boolean urgent) throws RateLimitExceededException {\n\n    try {\n      final int totalPayloadLength = messages.getMessagesMap().values().stream()\n          .mapToInt(message -> message.getPayload().size())\n          .sum();\n\n      rateLimiters.getInboundMessageBytes().validate(destinationServiceIdentifier.uuid(), totalPayloadLength);\n    } catch (final RateLimitExceededException e) {\n      messageByteLimitEstimator.add(destinationServiceIdentifier.uuid().toString());\n      throw e;\n    }\n\n    final SpamCheckResult<GrpcChallengeResponse> spamCheckResult =\n        spamChecker.checkForIndividualRecipientSpamGrpc(messageType,\n            Optional.of(sender),\n            Optional.of(destination),\n            destinationServiceIdentifier);\n\n    if (spamCheckResult.response().isPresent()) {\n      return SendMessageAuthenticatedSenderResponse.newBuilder()\n          .setChallengeRequired(spamCheckResult.response().get().getResponseOrThrowStatus())\n          .build();\n    }\n\n    final Map<Byte, MessageProtos.Envelope> messagesByDeviceId = messages.getMessagesMap().entrySet()\n        .stream()\n        .collect(Collectors.toMap(\n            entry -> DeviceIdUtil.validate(entry.getKey()),\n            entry -> {\n              final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()\n                  .setType(getEnvelopeType(entry.getValue().getType()))\n                  .setClientTimestamp(messages.getTimestamp())\n                  .setServerTimestamp(clock.millis())\n                  .setDestinationServiceId(destinationServiceIdentifier.toServiceIdentifierString())\n                  .setSourceServiceId(new AciServiceIdentifier(sender.accountIdentifier()).toServiceIdentifierString())\n                  .setSourceDevice(sender.deviceId())\n                  .setEphemeral(ephemeral)\n                  .setUrgent(urgent)\n                  .setContent(entry.getValue().getPayload());\n\n              spamCheckResult.token().ifPresent(reportSpamToken ->\n                  envelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken)));\n\n              return envelopeBuilder.build();\n            }\n        ));\n\n    final Map<Byte, Integer> registrationIdsByDeviceId = messages.getMessagesMap().entrySet().stream()\n        .collect(Collectors.toMap(\n            entry -> entry.getKey().byteValue(),\n            entry -> entry.getValue().getRegistrationId()));\n\n    try {\n      messageSender.sendMessages(destination,\n          destinationServiceIdentifier,\n          messagesByDeviceId,\n          registrationIdsByDeviceId,\n          messageType == MessageType.SYNC ? Optional.of(sender.deviceId()) : Optional.empty(),\n          RequestAttributesUtil.getUserAgent().orElse(null));\n\n      return SEND_MESSAGE_SUCCESS_RESPONSE;\n    } catch (final MismatchedDevicesException e) {\n      return SendMessageAuthenticatedSenderResponse.newBuilder()\n          .setMismatchedDevices(buildMismatchedDevices(destinationServiceIdentifier, e.getMismatchedDevices()))\n          .build();\n    } catch (final MessageTooLargeException e) {\n      throw GrpcExceptions.invalidArguments(\"message too large\");\n    }\n  }\n\n  private static MessageProtos.Envelope.Type getEnvelopeType(final SendMessageType type) {\n    return switch (type) {\n      case DOUBLE_RATCHET -> MessageProtos.Envelope.Type.CIPHERTEXT;\n      case PREKEY_MESSAGE -> MessageProtos.Envelope.Type.PREKEY_BUNDLE;\n      case PLAINTEXT_CONTENT -> MessageProtos.Envelope.Type.PLAINTEXT_CONTENT;\n      case UNIDENTIFIED_SENDER ->\n          throw GrpcExceptions.invalidArguments(\"illegal envelope type for identified sends\");\n      case UNSPECIFIED, UNRECOGNIZED ->\n          throw GrpcExceptions.invalidArguments(\"unrecognized envelope type\");\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/MetricServerInterceptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport com.google.protobuf.Message;\nimport com.google.rpc.ErrorInfo;\nimport io.grpc.ForwardingServerCall;\nimport io.grpc.ForwardingServerCallListener;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport io.grpc.protobuf.StatusProto;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.UncheckedIOException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\n\npublic class MetricServerInterceptor implements ServerInterceptor {\n\n  private static final Logger log = LoggerFactory.getLogger(MetricServerInterceptor.class);\n\n  private static final String TAG_SERVICE_NAME = \"grpcService\";\n  private static final String TAG_METHOD_NAME = \"method\";\n  private static final String TAG_METHOD_TYPE = \"methodType\";\n  private static final String TAG_STATUS_CODE = \"statusCode\";\n  private static final String TAG_REASON = \"reason\";\n\n  @VisibleForTesting\n  static final String DEFAULT_SUCCESS_REASON = \"success\";\n  @VisibleForTesting\n  static final String DEFAULT_ERROR_REASON = \"n/a\";\n\n  @VisibleForTesting\n  static final String REQUEST_MESSAGE_COUNTER_NAME = MetricsUtil.name(MetricServerInterceptor.class, \"requestMessage\");\n  @VisibleForTesting\n  static final String RESPONSE_COUNTER_NAME = MetricsUtil.name(MetricServerInterceptor.class, \"responseMessage\");\n  @VisibleForTesting\n  static final String RPC_COUNTER_NAME = MetricsUtil.name(MetricServerInterceptor.class, \"rpc\");\n  @VisibleForTesting\n  static final String DURATION_TIMER_NAME = MetricsUtil.name(MetricServerInterceptor.class, \"processingDuration\");\n\n  private final MeterRegistry meterRegistry;\n  private final ClientReleaseManager clientReleaseManager;\n\n  public MetricServerInterceptor(final MeterRegistry meterRegistry, final ClientReleaseManager clientReleaseManager) {\n    this.meterRegistry = meterRegistry;\n    this.clientReleaseManager = clientReleaseManager;\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(\n      final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n\n    final Optional<String> userAgentString = RequestAttributesUtil.getUserAgent();\n    final List<Tag> tagList = new ArrayList<>(6);\n    tagList.add(Tag.of(TAG_SERVICE_NAME, call.getMethodDescriptor().getServiceName()));\n    tagList.add(Tag.of(TAG_METHOD_NAME, call.getMethodDescriptor().getBareMethodName()));\n    tagList.add(Tag.of(TAG_METHOD_TYPE, call.getMethodDescriptor().getType().name()));\n\n    RequestAttributesUtil.getUserAgent()\n        .map(UserAgentTagUtil::getPlatformTag)\n        .ifPresent(tagList::add);\n    userAgentString\n        .flatMap(ua -> UserAgentTagUtil.getClientVersionTag(ua, clientReleaseManager))\n        .ifPresent(tagList::add);\n\n    final Tags tags = Tags.of(tagList);\n\n\n    final MetricServerCall<ReqT, RespT> monitoringServerCall = new MetricServerCall<>(call, tags);\n    return new MetricServerCallListener<>(next.startCall(monitoringServerCall, headers), tags);\n  }\n\n  /**\n   * A ServerCall delegator that updates metrics on response messages and the final RPC status\n   */\n  private class MetricServerCall<ReqT, RespT> extends ForwardingServerCall.SimpleForwardingServerCall<ReqT, RespT> {\n\n    private final Counter responseMessageCounter;\n    private final Tags tags;\n    private @Nullable String reason = null;\n\n    MetricServerCall(final ServerCall<ReqT, RespT> delegate, final Tags tags) {\n      super(delegate);\n      this.responseMessageCounter = meterRegistry.counter(RESPONSE_COUNTER_NAME, tags);\n      this.tags = tags;\n    }\n\n    @Override\n    public void close(final Status status, final Metadata responseHeaders) {\n      if (!status.isOk()) {\n        reason = errorInfo(StatusProto.fromStatusAndTrailers(status, responseHeaders))\n            .map(ErrorInfo::getReason)\n            .orElse(DEFAULT_ERROR_REASON);\n      }\n      Tags responseTags = tags.and(Tag.of(TAG_STATUS_CODE, status.getCode().name()));\n      if (reason != null) {\n        responseTags = responseTags.and(TAG_REASON, reason);\n      }\n      meterRegistry.counter(RPC_COUNTER_NAME, responseTags).increment();\n      super.close(status, responseHeaders);\n    }\n\n    @Override\n    public void sendMessage(final RespT responseMessage) {\n      this.responseMessageCounter.increment();\n      // Extract the annotated reason (if any) from the message\n      final String messageReason = MetricServerCall.reason(responseMessage);\n\n      // If there are multiple messages sent on this RPC (server-side streaming), just use the most recent reason\n      this.reason = messageReason == null ? DEFAULT_SUCCESS_REASON : messageReason;\n\n      super.sendMessage(responseMessage);\n    }\n\n    @Nullable\n    private static String reason(final Object obj) {\n      if (!(obj instanceof Message msg)) {\n        return null;\n      }\n      // iterate through all fields on the message\n      for (Map.Entry<Descriptors.FieldDescriptor, Object> field : msg.getAllFields().entrySet()) {\n        // iterate through all options on the field\n        for (Map.Entry<Descriptors.FieldDescriptor, Object> option : field.getKey().getOptions().getAllFields().entrySet()) {\n          if (option.getKey().getFullName().equals(\"org.signal.chat.tag.reason\")) {\n            if (!(option.getValue() instanceof String s)) {\n              log.error(\"Invalid value for option tag.reason {}\", option.getValue());\n              continue;\n            }\n            // return the first tag we see\n            return s;\n          }\n        }\n\n        // No reason on this field. Recursively check subfields of this field for a reason\n        final String subReason = reason(field.getValue());\n        if (subReason != null) {\n          return subReason;\n        }\n      }\n      // No field or subfield contained an annotated reason\n      return null;\n    }\n  }\n\n  /**\n   * A ServerCallListener delegator that updates metrics on requests and measures the RPC time on completion\n   */\n  private class MetricServerCallListener<ReqT> extends ForwardingServerCallListener.SimpleForwardingServerCallListener<ReqT> {\n\n    private final Counter requestCounter;\n    private final Timer responseTimer;\n    private final Timer.Sample sample;\n\n    MetricServerCallListener(final ServerCall.Listener<ReqT> delegate, final Tags tags) {\n      super(delegate);\n      this.requestCounter = meterRegistry.counter(REQUEST_MESSAGE_COUNTER_NAME, tags);\n      this.responseTimer = meterRegistry.timer(DURATION_TIMER_NAME, tags);\n      this.sample = Timer.start(meterRegistry);\n    }\n\n    @Override\n    public void onMessage(final ReqT requestMessage) {\n      this.requestCounter.increment();\n      super.onMessage(requestMessage);\n    }\n\n    @Override\n    public void onComplete() {\n      this.sample.stop(responseTimer);\n      super.onComplete();\n    }\n\n    @Override\n    public void onCancel() {\n      this.sample.stop(responseTimer);\n      super.onCancel();\n    }\n  }\n\n  private static Optional<ErrorInfo> errorInfo(final com.google.rpc.Status statusProto) {\n    return statusProto.getDetailsList().stream()\n        .filter(any -> any.is(ErrorInfo.class))\n        .map(errorInfo -> {\n          try {\n            return errorInfo.unpack(ErrorInfo.class);\n          } catch (final InvalidProtocolBufferException e) {\n            throw new UncheckedIOException(e);\n          }\n        })\n        .findFirst();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/PaymentsGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static java.util.Objects.requireNonNull;\n\nimport java.math.BigDecimal;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nonnull;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.signal.chat.payments.GetCurrencyConversionsRequest;\nimport org.signal.chat.payments.GetCurrencyConversionsResponse;\nimport org.signal.chat.payments.SimplePaymentsGrpc;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;\n\npublic class PaymentsGrpcService extends SimplePaymentsGrpc.PaymentsImplBase {\n\n  private final CurrencyConversionManager currencyManager;\n\n\n  public PaymentsGrpcService(final CurrencyConversionManager currencyManager) {\n    this.currencyManager = requireNonNull(currencyManager);\n  }\n\n  @Override\n  public GetCurrencyConversionsResponse getCurrencyConversions(final GetCurrencyConversionsRequest request) {\n    AuthenticationUtil.requireAuthenticatedDevice();\n\n    final CurrencyConversionEntityList currencyConversionEntityList = currencyManager\n        .getCurrencyConversions()\n        .orElseThrow(() -> GrpcExceptions.unavailable(\"currency conversions not available\"));\n\n    final List<GetCurrencyConversionsResponse.CurrencyConversionEntity> currencyConversionEntities = currencyConversionEntityList\n        .getCurrencies()\n        .stream()\n        .map(cce -> GetCurrencyConversionsResponse.CurrencyConversionEntity.newBuilder()\n            .setBase(cce.getBase())\n            .putAllConversions(transformBigDecimalsToStrings(cce.getConversions()))\n            .build())\n        .toList();\n\n    return GetCurrencyConversionsResponse.newBuilder()\n        .addAllCurrencies(currencyConversionEntities).setTimestamp(currencyConversionEntityList.getTimestamp())\n        .build();\n  }\n\n  @Nonnull\n  private static Map<String, String> transformBigDecimalsToStrings(final Map<String, BigDecimal> conversions) {\n    AuthenticationUtil.requireAuthenticatedDevice();\n    return conversions.entrySet().stream()\n        .map(e -> Pair.of(e.getKey(), e.getValue().toString()))\n        .collect(Collectors.toMap(Pair::getKey, Pair::getValue));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Status;\nimport io.grpc.StatusException;\nimport java.time.Clock;\nimport org.signal.chat.profile.CredentialType;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;\nimport org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;\nimport org.signal.chat.profile.GetUnversionedProfileResponse;\nimport org.signal.chat.profile.GetVersionedProfileAnonymousRequest;\nimport org.signal.chat.profile.GetVersionedProfileResponse;\nimport org.signal.chat.profile.SimpleProfileAnonymousGrpc;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\n\npublic class ProfileAnonymousGrpcService extends SimpleProfileAnonymousGrpc.ProfileAnonymousImplBase {\n  private final AccountsManager accountsManager;\n  private final ProfilesManager profilesManager;\n  private final ProfileBadgeConverter profileBadgeConverter;\n  private final ServerZkProfileOperations zkProfileOperations;\n  private final GroupSendTokenUtil groupSendTokenUtil;\n\n  public ProfileAnonymousGrpcService(\n      final AccountsManager accountsManager,\n      final ProfilesManager profilesManager,\n      final ProfileBadgeConverter profileBadgeConverter,\n      final ServerSecretParams serverSecretParams) {\n    this.accountsManager = accountsManager;\n    this.profilesManager = profilesManager;\n    this.profileBadgeConverter = profileBadgeConverter;\n    this.zkProfileOperations = new ServerZkProfileOperations(serverSecretParams);\n    this.groupSendTokenUtil = new GroupSendTokenUtil(serverSecretParams, Clock.systemUTC());\n  }\n\n  @Override\n  public GetUnversionedProfileResponse getUnversionedProfile(final GetUnversionedProfileAnonymousRequest request) throws StatusException {\n    final ServiceIdentifier targetIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getServiceIdentifier());\n\n    // Callers must be authenticated to request unversioned profiles by PNI\n    if (targetIdentifier.identityType() == IdentityType.PNI) {\n      throw Status.UNAUTHENTICATED.asException();\n    }\n\n    final Account account = switch (request.getAuthenticationCase()) {\n      case GROUP_SEND_TOKEN -> {\n        if (!groupSendTokenUtil.checkGroupSendToken(request.getGroupSendToken(), targetIdentifier)) {\n          throw Status.UNAUTHENTICATED.asException();\n        }\n        yield accountsManager.getByServiceIdentifier(targetIdentifier)\n            .orElseThrow(Status.NOT_FOUND::asException);\n      }\n      case UNIDENTIFIED_ACCESS_KEY ->\n          getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray());\n      default -> throw Status.INVALID_ARGUMENT.asException();\n    };\n\n    return ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,\n            null,\n            account,\n            profileBadgeConverter);\n  }\n\n  @Override\n  public GetVersionedProfileResponse getVersionedProfile(final GetVersionedProfileAnonymousRequest request) throws StatusException {\n    final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());\n\n    if (targetIdentifier.identityType() != IdentityType.ACI) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Expected ACI service identifier\").asException();\n    }\n\n    final Account targetAccount = getTargetAccountAndValidateUnidentifiedAccess(targetIdentifier, request.getUnidentifiedAccessKey().toByteArray());\n    return ProfileGrpcHelper.getVersionedProfile(targetAccount, profilesManager, request.getRequest().getVersion());\n  }\n\n  @Override\n  public GetExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredential(\n      final GetExpiringProfileKeyCredentialAnonymousRequest request) throws StatusException {\n    final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getRequest().getAccountIdentifier());\n\n    if (targetIdentifier.identityType() != IdentityType.ACI) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Expected ACI service identifier\").asException();\n    }\n\n    if (request.getRequest().getCredentialType() != CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Expected expiring profile key credential type\").asException();\n    }\n\n    final Account account = getTargetAccountAndValidateUnidentifiedAccess(\n        targetIdentifier, request.getUnidentifiedAccessKey().toByteArray());\n    return ProfileGrpcHelper.getExpiringProfileKeyCredentialResponse(account.getUuid(),\n            request.getRequest().getVersion(), request.getRequest().getCredentialRequest().toByteArray(), profilesManager, zkProfileOperations);\n  }\n\n  private Account getTargetAccountAndValidateUnidentifiedAccess(final ServiceIdentifier targetIdentifier, final byte[] unidentifiedAccessKey) throws StatusException {\n\n    return accountsManager.getByServiceIdentifier(targetIdentifier)\n        .filter(targetAccount -> UnidentifiedAccessUtil.checkUnidentifiedAccess(targetAccount, unidentifiedAccessKey))\n        .orElseThrow(Status.UNAUTHENTICATED::asException);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcHelper.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.UUID;\nimport io.grpc.StatusException;\nimport org.signal.chat.profile.Badge;\nimport org.signal.chat.profile.BadgeSvg;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;\nimport org.signal.chat.profile.GetUnversionedProfileResponse;\nimport org.signal.chat.profile.GetVersionedProfileResponse;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;\nimport org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport org.whispersystems.textsecuregcm.util.ProfileHelper;\n\npublic class ProfileGrpcHelper {\n  static GetVersionedProfileResponse getVersionedProfile(final Account account,\n      final ProfilesManager profilesManager,\n      final String requestVersion) throws StatusException {\n\n    final VersionedProfile profile = profilesManager.get(account.getUuid(), requestVersion)\n        .orElseThrow(Status.NOT_FOUND.withDescription(\"Profile version not found\")::asException);\n\n    final GetVersionedProfileResponse.Builder responseBuilder = GetVersionedProfileResponse.newBuilder();\n\n    responseBuilder\n        .setName(ByteString.copyFrom(profile.name()))\n        .setAbout(ByteString.copyFrom(profile.about()))\n        .setAboutEmoji(ByteString.copyFrom(profile.aboutEmoji()))\n        .setAvatar(profile.avatar())\n        .setPhoneNumberSharing(ByteString.copyFrom(profile.phoneNumberSharing()));\n\n    // Allow requests where either the version matches the latest version on Account or the latest version on Account\n    // is empty to read the payment address.\n    if (account.getCurrentProfileVersion().map(v -> v.equals(requestVersion)).orElse(true)) {\n      responseBuilder.setPaymentAddress(ByteString.copyFrom(profile.paymentAddress()));\n    }\n\n    return responseBuilder.build();\n  }\n\n  @VisibleForTesting\n  static List<Badge> buildBadges(final List<org.whispersystems.textsecuregcm.entities.Badge> badges) {\n    final ArrayList<Badge> grpcBadges = new ArrayList<>();\n    for (final org.whispersystems.textsecuregcm.entities.Badge badge : badges) {\n      grpcBadges.add(Badge.newBuilder()\n          .setId(badge.getId())\n          .setCategory(badge.getCategory())\n          .setName(badge.getName())\n          .setDescription(badge.getDescription())\n          .addAllSprites6(badge.getSprites6())\n          .setSvg(badge.getSvg())\n          .addAllSvgs(buildBadgeSvgs(badge.getSvgs()))\n          .build());\n    }\n    return grpcBadges;\n  }\n\n  @VisibleForTesting\n  static List<org.signal.chat.common.DeviceCapability> buildAccountCapabilities(final Account account) {\n    return Arrays.stream(DeviceCapability.values())\n        .filter(DeviceCapability::includeInProfile)\n        .filter(account::hasCapability)\n        .map(DeviceCapabilityUtil::toGrpcDeviceCapability)\n        .toList();\n  }\n\n  private static List<BadgeSvg> buildBadgeSvgs(final List<org.whispersystems.textsecuregcm.entities.BadgeSvg> badgeSvgs) {\n    ArrayList<BadgeSvg> grpcBadgeSvgs = new ArrayList<>();\n    for (final org.whispersystems.textsecuregcm.entities.BadgeSvg badgeSvg : badgeSvgs) {\n      grpcBadgeSvgs.add(BadgeSvg.newBuilder()\n          .setDark(badgeSvg.getDark())\n          .setLight(badgeSvg.getLight())\n          .build());\n    }\n    return grpcBadgeSvgs;\n  }\n\n  static GetUnversionedProfileResponse buildUnversionedProfileResponse(\n      final ServiceIdentifier targetIdentifier,\n      final UUID requesterUuid,\n      final Account targetAccount,\n      final ProfileBadgeConverter profileBadgeConverter) {\n    final GetUnversionedProfileResponse.Builder responseBuilder = GetUnversionedProfileResponse.newBuilder()\n        .setIdentityKey(ByteString.copyFrom(targetAccount.getIdentityKey(targetIdentifier.identityType()).serialize()))\n        .addAllCapabilities(buildAccountCapabilities(targetAccount));\n\n    switch (targetIdentifier.identityType()) {\n      case ACI -> {\n        responseBuilder.setUnrestrictedUnidentifiedAccess(targetAccount.isUnrestrictedUnidentifiedAccess())\n            .addAllBadges(buildBadges(profileBadgeConverter.convert(\n                RequestAttributesUtil.getAvailableAcceptedLocales(),\n                targetAccount.getBadges(),\n                ProfileHelper.isSelfProfileRequest(requesterUuid, targetIdentifier))));\n\n        targetAccount.getUnidentifiedAccessKey()\n            .map(UnidentifiedAccessChecksum::generateFor)\n            .map(ByteString::copyFrom)\n            .ifPresent(responseBuilder::setUnidentifiedAccess);\n      }\n      case PNI -> responseBuilder.setUnrestrictedUnidentifiedAccess(false);\n    }\n\n    return responseBuilder.build();\n  }\n\n  static GetExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse(\n      final UUID targetUuid,\n      final String version,\n      final byte[] encodedCredentialRequest,\n      final ProfilesManager profilesManager,\n      final ServerZkProfileOperations zkProfileOperations) throws StatusException {\n\n    final VersionedProfile profile = profilesManager.get(targetUuid, version)\n        .orElseThrow(Status.NOT_FOUND.withDescription(\"Profile version not found\")::asException);\n\n    final ExpiringProfileKeyCredentialResponse profileKeyCredentialResponse;\n    try {\n      profileKeyCredentialResponse = ProfileHelper.getExpiringProfileKeyCredential(encodedCredentialRequest,\n          profile, new ServiceId.Aci(targetUuid), zkProfileOperations);\n    } catch (VerificationFailedException | InvalidInputException e) {\n      throw Status.INVALID_ARGUMENT.withCause(e).asException();\n    }\n\n    return GetExpiringProfileKeyCredentialResponse.newBuilder()\n        .setProfileKeyCredential(ByteString.copyFrom(profileKeyCredentialResponse.serialize()))\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcService.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.time.Clock;\nimport java.time.ZonedDateTime;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport io.grpc.StatusException;\nimport org.signal.chat.profile.CredentialType;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;\nimport org.signal.chat.profile.GetUnversionedProfileRequest;\nimport org.signal.chat.profile.GetUnversionedProfileResponse;\nimport org.signal.chat.profile.GetVersionedProfileRequest;\nimport org.signal.chat.profile.GetVersionedProfileResponse;\nimport org.signal.chat.profile.ProfileAvatarUploadAttributes;\nimport org.signal.chat.profile.SetProfileRequest;\nimport org.signal.chat.profile.SetProfileRequest.AvatarChange;\nimport org.signal.chat.profile.SetProfileResponse;\nimport org.signal.chat.profile.SimpleProfileGrpc;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.s3.PolicySigner;\nimport org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.ProfileHelper;\n\npublic class ProfileGrpcService extends SimpleProfileGrpc.ProfileImplBase {\n\n  private final Clock clock;\n  private final AccountsManager accountsManager;\n  private final ProfilesManager  profilesManager;\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  private final Map<String, BadgeConfiguration> badgeConfigurationMap;\n  private final PostPolicyGenerator policyGenerator;\n  private final PolicySigner policySigner;\n  private final ProfileBadgeConverter profileBadgeConverter;\n  private final RateLimiters rateLimiters;\n  private final ServerZkProfileOperations zkProfileOperations;\n\n  private record AvatarData(Optional<String> currentAvatar,\n                            Optional<String>  finalAvatar,\n                            Optional<ProfileAvatarUploadAttributes> uploadAttributes) {}\n\n  public ProfileGrpcService(\n      final Clock clock,\n      final AccountsManager accountsManager,\n      final ProfilesManager profilesManager,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final BadgesConfiguration badgesConfiguration,\n      final PostPolicyGenerator policyGenerator,\n      final PolicySigner policySigner,\n      final ProfileBadgeConverter profileBadgeConverter,\n      final RateLimiters rateLimiters,\n      final ServerZkProfileOperations zkProfileOperations) {\n    this.clock = clock;\n    this.accountsManager = accountsManager;\n    this.profilesManager = profilesManager;\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n    this.badgeConfigurationMap = badgesConfiguration.getBadges().stream().collect(Collectors.toMap(\n        BadgeConfiguration::getId, Function.identity()));\n    this.policyGenerator = policyGenerator;\n    this.policySigner = policySigner;\n    this.profileBadgeConverter = profileBadgeConverter;\n    this.rateLimiters = rateLimiters;\n    this.zkProfileOperations = zkProfileOperations;\n  }\n\n  @Override\n  public SetProfileResponse setProfile(final SetProfileRequest request) throws StatusException {\n    validateRequest(request);\n\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n    final Account account = accountsManager.getByAccountIdentifier(\n        authenticatedDevice.accountIdentifier()).orElseThrow(Status.UNAUTHENTICATED::asException);\n    final Optional<VersionedProfile> maybeProfile = profilesManager.get(\n        authenticatedDevice.accountIdentifier(), request.getVersion());\n\n    if (!request.getPaymentAddress().isEmpty()) {\n      final boolean hasDisallowedPrefix =\n          dynamicConfigurationManager.getConfiguration().getPaymentsConfiguration().getDisallowedPrefixes().stream()\n              .anyMatch(prefix -> account.getNumber().startsWith(prefix));\n      if (hasDisallowedPrefix && maybeProfile.map(VersionedProfile::paymentAddress).isEmpty()) {\n        throw Status.PERMISSION_DENIED.asException();\n      }\n    }\n\n    final Optional<String> currentAvatar = maybeProfile.map(VersionedProfile::avatar)\n        .filter(avatar -> avatar.startsWith(\"profiles/\"));\n\n    final AvatarData avatarData = switch (AvatarChangeUtil.fromGrpcAvatarChange(request.getAvatarChange())) {\n      case AVATAR_CHANGE_UNCHANGED -> new AvatarData(currentAvatar, currentAvatar, Optional.empty());\n      case AVATAR_CHANGE_CLEAR -> new AvatarData(currentAvatar, Optional.empty(), Optional.empty());\n      case AVATAR_CHANGE_UPDATE -> {\n        final String updateAvatarObjectName = ProfileHelper.generateAvatarObjectName();\n        yield new AvatarData(currentAvatar, Optional.of(updateAvatarObjectName),\n            Optional.of(generateAvatarUploadForm(updateAvatarObjectName)));\n      }\n    };\n\n    profilesManager.set(account.getUuid(),\n        new VersionedProfile(\n            request.getVersion(),\n            request.getName().toByteArray(),\n            avatarData.finalAvatar().orElse(null),\n            request.getAboutEmoji().toByteArray(),\n            request.getAbout().toByteArray(),\n            request.getPaymentAddress().toByteArray(),\n            request.getPhoneNumberSharing().toByteArray(),\n            request.getCommitment().toByteArray()));\n\n    accountsManager.update(account, a -> {\n\n      final List<AccountBadge> updatedBadges = Optional.of(request.getBadgeIdsList())\n          .map(badges -> ProfileHelper.mergeBadgeIdsWithExistingAccountBadges(clock, badgeConfigurationMap, badges,\n              a.getBadges()))\n          .orElseGet(a::getBadges);\n\n      a.setBadges(clock, updatedBadges);\n      a.setCurrentProfileVersion(request.getVersion());\n    });\n\n    if (request.getAvatarChange() != AvatarChange.AVATAR_CHANGE_UNCHANGED && avatarData.currentAvatar().isPresent()) {\n      profilesManager.deleteAvatar(avatarData.currentAvatar().get());\n    }\n\n    return avatarData.uploadAttributes()\n        .map(avatarUploadAttributes -> SetProfileResponse.newBuilder().setAttributes(avatarUploadAttributes).build())\n        .orElse(SetProfileResponse.newBuilder().build());\n  }\n\n  @Override\n  public GetUnversionedProfileResponse getUnversionedProfile(final GetUnversionedProfileRequest request) throws StatusException, RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final ServiceIdentifier targetIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getServiceIdentifier());\n    final Account targetAccount = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);\n\n    return ProfileGrpcHelper.buildUnversionedProfileResponse(targetIdentifier,\n            authenticatedDevice.accountIdentifier(),\n            targetAccount,\n            profileBadgeConverter);\n  }\n\n  @Override\n  public GetVersionedProfileResponse getVersionedProfile(final GetVersionedProfileRequest request) throws StatusException, RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final ServiceIdentifier targetIdentifier =\n        ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());\n\n    if (targetIdentifier.identityType() != IdentityType.ACI) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Expected ACI service identifier\").asException();\n    }\n\n    final Account account = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);\n\n    return ProfileGrpcHelper.getVersionedProfile(account, profilesManager, request.getVersion());\n  }\n\n  @Override\n  public GetExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredential(\n      final GetExpiringProfileKeyCredentialRequest request) throws StatusException, RateLimitExceededException {\n    final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n    final ServiceIdentifier targetIdentifier = ServiceIdentifierUtil.fromGrpcServiceIdentifier(request.getAccountIdentifier());\n\n    if (targetIdentifier.identityType() != IdentityType.ACI) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Expected ACI service identifier\").asException();\n    }\n\n    if (request.getCredentialType() != CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Expected expiring profile key credential type\").asException();\n    }\n\n    final Account targetAccount = validateRateLimitAndGetAccount(authenticatedDevice.accountIdentifier(), targetIdentifier);\n\n    return ProfileGrpcHelper.getExpiringProfileKeyCredentialResponse(targetAccount.getUuid(),\n        request.getVersion(), request.getCredentialRequest().toByteArray(), profilesManager, zkProfileOperations);\n  }\n\n\n  private Account validateRateLimitAndGetAccount(final UUID requesterUuid,\n      final ServiceIdentifier targetIdentifier) throws RateLimitExceededException, StatusException {\n    rateLimiters.getProfileLimiter().validate(requesterUuid);\n\n    return accountsManager.getByServiceIdentifier(targetIdentifier).orElseThrow(Status.NOT_FOUND::asException);\n  }\n\n  private void validateRequest(final SetProfileRequest request) throws StatusException {\n    if (request.getVersion().isEmpty()) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Missing version\").asException();\n    }\n\n    if (request.getCommitment().isEmpty()) {\n      throw Status.INVALID_ARGUMENT.withDescription(\"Missing profile commitment\").asException();\n    }\n\n    checkByteStringLength(request.getName(), \"Invalid name length\", List.of(81, 285));\n    checkByteStringLength(request.getAboutEmoji(), \"Invalid about emoji length\", List.of(0, 60));\n    checkByteStringLength(request.getAbout(), \"Invalid about length\", List.of(0, 156, 282, 540));\n    checkByteStringLength(request.getPaymentAddress(), \"Invalid mobile coin address length\", List.of(0, 582));\n  }\n\n  private static void checkByteStringLength(final ByteString byteString, final String errorMessage,\n      final List<Integer> allowedLengths) throws StatusException {\n\n    final int byteStringLength = byteString.toByteArray().length;\n\n    for (int allowedLength : allowedLengths) {\n      if (byteStringLength == allowedLength) {\n        return;\n      }\n    }\n\n    throw Status.INVALID_ARGUMENT.withDescription(errorMessage).asException();\n  }\n\n  private ProfileAvatarUploadAttributes generateAvatarUploadForm(final String objectName) {\n    final ZonedDateTime now = ZonedDateTime.now(clock);\n    final Pair<String, String> policy = policyGenerator.createFor(now, objectName, ProfileHelper.MAX_PROFILE_AVATAR_SIZE_BYTES);\n    final String signature = policySigner.getSignature(now, policy.second());\n\n    return ProfileAvatarUploadAttributes.newBuilder()\n        .setPath(objectName)\n        .setCredential(policy.first())\n        .setAcl(\"private\")\n        .setAlgorithm(\"AWS4-HMAC-SHA256\")\n        .setDate(now.format(PostPolicyGenerator.AWS_DATE_TIME))\n        .setPolicy(policy.second())\n        .setSignature(ByteString.copyFrom(signature.getBytes()))\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/RateLimitUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\n\nclass RateLimitUtil {\n\n  static void rateLimitByRemoteAddress(final RateLimiter rateLimiter) throws RateLimitExceededException {\n    rateLimiter.validate(RequestAttributesUtil.getRemoteAddress().getHostAddress());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/RequestAttributes.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport java.net.InetAddress;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport javax.annotation.Nullable;\n\npublic record RequestAttributes(InetAddress remoteAddress,\n                                @Nullable String userAgent,\n                                @Nullable String acceptLanguageRaw,\n                                List<Locale.LanguageRange> acceptLanguage) {\n\n  private static final Logger LOGGER = LoggerFactory.getLogger(RequestAttributes.class);\n\n  public RequestAttributes(InetAddress remoteAddress,\n      @Nullable String userAgent,\n      @Nullable String acceptLanguageRaw) {\n    this(remoteAddress, userAgent, acceptLanguageRaw, parseAcceptLanguage(acceptLanguageRaw, userAgent));\n  }\n\n  private static List<Locale.LanguageRange> parseAcceptLanguage(final String acceptLanguageRaw, final String userAgent) {\n    List<Locale.LanguageRange> acceptLanguages = Collections.emptyList();\n    if (StringUtils.isNotBlank(acceptLanguageRaw)) {\n      try {\n        acceptLanguages = Locale.LanguageRange.parse(acceptLanguageRaw);\n      } catch (final IllegalArgumentException e) {\n        LOGGER.debug(\"Invalid Accept-Language header from User-Agent {}: {}\", userAgent, acceptLanguageRaw, e);\n      }\n    }\n    return acceptLanguages;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/RequestAttributesInterceptor.java",
    "content": "package org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.common.net.HttpHeaders;\nimport io.grpc.Context;\nimport io.grpc.Contexts;\nimport io.grpc.Grpc;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport java.net.InetAddress;\nimport java.net.InetSocketAddress;\nimport java.net.SocketAddress;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * The request attributes interceptor makes common request attributes from call metadata available to service\n * implementations by attaching them to a {@link Context} attribute that can be read via {@link RequestAttributesUtil}.\n *\n * @see RequestAttributesUtil\n */\npublic class RequestAttributesInterceptor implements ServerInterceptor {\n\n  private static final Logger log = LoggerFactory.getLogger(RequestAttributesInterceptor.class);\n\n  private static final Metadata.Key<String> ACCEPT_LANG_KEY =\n      Metadata.Key.of(HttpHeaders.ACCEPT_LANGUAGE, Metadata.ASCII_STRING_MARSHALLER);\n\n  private static final Metadata.Key<String> USER_AGENT_KEY =\n      Metadata.Key.of(HttpHeaders.USER_AGENT, Metadata.ASCII_STRING_MARSHALLER);\n\n  private static final Metadata.Key<String> X_FORWARDED_FOR_KEY =\n      Metadata.Key.of(HttpHeaders.X_FORWARDED_FOR, Metadata.ASCII_STRING_MARSHALLER);\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n    final String userAgentHeader = headers.get(USER_AGENT_KEY);\n    final String acceptLanguageHeader = headers.get(ACCEPT_LANG_KEY);\n    final String xForwardedForHeader = headers.get(X_FORWARDED_FOR_KEY);\n\n    final Optional<InetAddress> remoteAddress = getMostRecentProxy(xForwardedForHeader)\n        .flatMap(mostRecentProxy -> {\n          try {\n            return Optional.of(InetAddress.ofLiteral(mostRecentProxy));\n          } catch (IllegalArgumentException e) {\n            log.warn(\"Failed to parse most recent proxy {} as an IP address\", mostRecentProxy, e);\n            return Optional.empty();\n          }\n        })\n        .or(() -> {\n          log.warn(\"No usable X-Forwarded-For header present, using remote socket address\");\n          final SocketAddress socketAddress = call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);\n          if (socketAddress == null || !(socketAddress instanceof InetSocketAddress inetAddress)) {\n            log.warn(\"Remote socket address not present or is not an inet address: {}\", socketAddress);\n            return Optional.empty();\n          }\n          return Optional.of(inetAddress.getAddress());\n        });\n    if (!remoteAddress.isPresent()) {\n      return ServerInterceptorUtil.closeWithStatus(call, Status.UNAVAILABLE);\n    }\n\n    final RequestAttributes requestAttributes =\n        new RequestAttributes(remoteAddress.get(), userAgentHeader, acceptLanguageHeader);\n    return Contexts.interceptCall(\n        Context.current().withValue(RequestAttributesUtil.REQUEST_ATTRIBUTES_CONTEXT_KEY, requestAttributes),\n        call, headers, next);\n  }\n\n  /**\n     * Returns the most recent proxy in a chain described by an {@code X-Forwarded-For} header.\n     *\n     * @param forwardedFor the value of an X-Forwarded-For header\n     * @return the IP address of the most recent proxy in the forwarding chain, or empty if none was found or\n     * {@code forwardedFor} was null\n     * @see <a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For\">X-Forwarded-For - HTTP |\n     * MDN</a>\n     */\n  public static Optional<String> getMostRecentProxy(@Nullable final String forwardedFor) {\n    return Optional.ofNullable(forwardedFor)\n        .map(ff -> {\n          final int idx = forwardedFor.lastIndexOf(',') + 1;\n          return idx < forwardedFor.length()\n              ? forwardedFor.substring(idx).trim()\n              : null;\n        })\n        .filter(StringUtils::isNotBlank);\n      }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/RequestAttributesUtil.java",
    "content": "package org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Context;\nimport javax.annotation.Nullable;\nimport java.net.InetAddress;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Optional;\n\npublic class RequestAttributesUtil {\n\n  static final Context.Key<RequestAttributes> REQUEST_ATTRIBUTES_CONTEXT_KEY = Context.key(\"request-attributes\");\n\n  private static final List<Locale> AVAILABLE_LOCALES = Arrays.asList(Locale.getAvailableLocales());\n\n  /**\n   * Returns the acceptable languages listed by the remote client in the current gRPC request context.\n   *\n   * @return the acceptable languages listed by the remote client; may be empty if unparseable or not specified\n   */\n  public static List<Locale.LanguageRange> getAcceptableLanguages() {\n    return REQUEST_ATTRIBUTES_CONTEXT_KEY.get().acceptLanguage();\n  }\n\n  /**\n   * Returns the raw \"Accept-Language\" header string from the remote client in the current gRPC request context\n   *\n   * @return the raw \"Accept-Language\" header listed by the remote client; may be null if not specified\n   */\n  public static @Nullable String getAcceptLanguageRaw() {\n    return REQUEST_ATTRIBUTES_CONTEXT_KEY.get().acceptLanguageRaw();\n  }\n\n  /**\n   * Returns a list of distinct locales supported by the JVM and accepted by the remote client in the current gRPC\n   * context. May be empty if the client did not supply a list of acceptable languages, if the list of acceptable\n   * languages could not be parsed, or if none of the acceptable languages are available in the current JVM.\n   *\n   * @return a list of distinct locales acceptable to the remote client and available in this JVM\n   */\n  public static List<Locale> getAvailableAcceptedLocales() {\n    return Locale.filter(getAcceptableLanguages(), AVAILABLE_LOCALES);\n  }\n\n  /**\n   * Returns the remote address of the remote client in the current gRPC request context.\n   *\n   * @return the remote address of the remote client\n   */\n  public static InetAddress getRemoteAddress() {\n    return REQUEST_ATTRIBUTES_CONTEXT_KEY.get().remoteAddress();\n  }\n\n  /**\n   * Returns the unparsed user-agent of the remote client in the current gRPC request context.\n   *\n   * @return the unparsed user-agent of the remote client; may be empty if not specified\n   */\n  public static Optional<String> getUserAgent() {\n    return Optional.ofNullable(REQUEST_ATTRIBUTES_CONTEXT_KEY.get().userAgent());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ServerInterceptorUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\n\npublic class ServerInterceptorUtil {\n\n  @SuppressWarnings(\"rawtypes\")\n  private static final ServerCall.Listener NO_OP_LISTENER = new ServerCall.Listener<>() {};\n\n  private ServerInterceptorUtil() {\n  }\n\n  /**\n   * Closes the given server call with the given status, returning a no-op listener.\n   *\n   * @param call the server call to close\n   * @param status the status with which to close the call\n   *\n   * @return a no-op server call listener\n   *\n   * @param <ReqT> the type of request object handled by the server call\n   * @param <RespT> the type of response object returned by the server call\n   */\n  public static <ReqT, RespT> ServerCall.Listener<ReqT> closeWithStatus(final ServerCall<ReqT, RespT> call, final Status status) {\n    call.close(status, new Metadata());\n\n    //noinspection unchecked\n    return NO_OP_LISTENER;\n  }\n\n  /**\n   * Closes the given server call with the status and metadata from the provided exception, returning a no-op listener.\n   *\n   * @param call the server call to close\n   * @param exception the {@link StatusRuntimeException} with which to close the call\n   *\n   * @return a no-op server call listener\n   *\n   * @param <ReqT> the type of request object handled by the server call\n   * @param <RespT> the type of response object returned by the server call\n   */\n  public static <ReqT, RespT> ServerCall.Listener<ReqT> closeWithStatusException(final ServerCall<ReqT, RespT> call, final StatusRuntimeException exception) {\n    call.close(exception.getStatus(), exception.getTrailers());\n\n    //noinspection unchecked\n    return NO_OP_LISTENER;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ServiceIdentifierUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class ServiceIdentifierUtil {\n\n  private ServiceIdentifierUtil() {\n  }\n\n  public static ServiceIdentifier fromGrpcServiceIdentifier(final org.signal.chat.common.ServiceIdentifier serviceIdentifier) {\n    if (serviceIdentifier == null) {\n      throw GrpcExceptions.invalidArguments(\"invalid service identifier\");\n    }\n\n    final UUID uuid;\n    try {\n      uuid = UUIDUtil.fromByteString(serviceIdentifier.getUuid());\n    } catch (final IllegalArgumentException e) {\n      throw GrpcExceptions.invalidArguments(\"invalid service identifier\");\n    }\n\n    return switch (IdentityTypeUtil.fromGrpcIdentityType(serviceIdentifier.getIdentityType())) {\n      case ACI -> new AciServiceIdentifier(uuid);\n      case PNI -> new PniServiceIdentifier(uuid);\n    };\n  }\n\n  public static org.signal.chat.common.ServiceIdentifier toGrpcServiceIdentifier(final ServiceIdentifier serviceIdentifier) {\n    return org.signal.chat.common.ServiceIdentifier.newBuilder()\n        .setIdentityType(IdentityTypeUtil.toGrpcIdentityType(serviceIdentifier.identityType()))\n        .setUuid(UUIDUtil.toByteString(serviceIdentifier.uuid()))\n        .build();\n  }\n\n  public static ByteString toCompactByteString(final ServiceIdentifier serviceIdentifier) {\n    return ByteString.copyFrom(serviceIdentifier.toCompactByteArray());\n  }\n\n  public static ServiceIdentifier fromByteString(final ByteString byteString) {\n    return ServiceIdentifier.fromBytes(byteString.toByteArray());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\nimport io.grpc.ForwardingServerCallListener;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.StatusRuntimeException;\nimport java.util.List;\nimport java.util.Map;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.grpc.validators.E164FieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.EnumSpecifiedFieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.ExactlySizeFieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.FieldValidationException;\nimport org.whispersystems.textsecuregcm.grpc.validators.FieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.NonEmptyFieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.PresentFieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.RangeFieldValidator;\nimport org.whispersystems.textsecuregcm.grpc.validators.SizeFieldValidator;\n\npublic class ValidatingInterceptor implements ServerInterceptor {\n\n  private static final Logger log = LoggerFactory.getLogger(ValidatingInterceptor.class);\n  private final Map<String, FieldValidator> fieldValidators = Map.of(\n      \"org.signal.chat.require.nonEmpty\", new NonEmptyFieldValidator(),\n      \"org.signal.chat.require.present\", new PresentFieldValidator(),\n      \"org.signal.chat.require.specified\", new EnumSpecifiedFieldValidator(),\n      \"org.signal.chat.require.e164\", new E164FieldValidator(),\n      \"org.signal.chat.require.exactlySize\", new ExactlySizeFieldValidator(),\n      \"org.signal.chat.require.range\", new RangeFieldValidator(),\n      \"org.signal.chat.require.size\", new SizeFieldValidator()\n  );\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(\n      final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n    return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(next.startCall(call, headers)) {\n\n      // The way `UnaryServerCallHandler` (which is what we're wrapping here) is implemented\n      // is when `onMessage()` is called, the processing of the message doesn't immediately start\n      // and instead is delayed until `onHalfClose()` (which is the point when client says\n      // that no more messages will be sent). Then, in `onHalfClose()` it either tries to process\n      // the message if it's there, or reports an error if the message is not there.\n      // This means that the logic is not designed for the case of the call being closed by the interceptor.\n      // The only workaround is to not delegate calls to it in the case when we're closing the call\n      // because of the validation error.\n      private boolean forwardCalls = true;\n\n      @Override\n      public void onMessage(final ReqT message) {\n        try {\n          validateMessage(message);\n          super.onMessage(message);\n        } catch (final StatusRuntimeException e) {\n          call.close(e.getStatus(), e.getTrailers());\n          forwardCalls = false;\n        } catch (RuntimeException runtimeException) {\n          final StatusRuntimeException grpcException = switch (runtimeException) {\n            case StatusRuntimeException e -> e;\n            default -> {\n              log.error(\"Failure applying request validation to message {}\", call.getMethodDescriptor().getFullMethodName(), runtimeException);\n              yield GrpcExceptions.unavailable(\"failure applying request validation\");\n            }\n          };\n          call.close(grpcException.getStatus(), grpcException.getTrailers());\n          forwardCalls = false;\n        }\n      }\n\n      @Override\n      public void onHalfClose() {\n        if (forwardCalls) {\n          super.onHalfClose();\n        }\n      }\n    };\n  }\n\n  private void validateMessage(final Object message) {\n    if (message instanceof Message msg) {\n      for (final Descriptors.FieldDescriptor fd : msg.getDescriptorForType().getFields()) {\n        for (final Map.Entry<Descriptors.FieldDescriptor, Object> entry : fd.getOptions().getAllFields().entrySet()) {\n          final Descriptors.FieldDescriptor extensionFieldDescriptor = entry.getKey();\n          final String extensionName = extensionFieldDescriptor.getFullName();\n\n          // If this is a oneof, but this field isn't set, we shouldn't validate it. We assume if you have a validator\n          // that requires presence, you don't actually want presence enforcement on a oneof case.\n          if (fd.getRealContainingOneof() != null && !msg.hasField(fd)) {\n            continue;\n          }\n\n          // first validate the field\n          final FieldValidator validator = fieldValidators.get(extensionName);\n          // not all extensions are validators, so `validator` value here could legitimately be `null`\n          if (validator != null) {\n            try {\n              validator.validate(entry.getValue(), fd, msg);\n            } catch (FieldValidationException e) {\n              throw GrpcExceptions.fieldViolation(fd.getName(),\n                  \"extension %s: %s\".formatted(extensionName, e.getMessage()));\n            }\n          }\n        }\n\n        // Recursively validate the field's value(s) if it is a message or a repeated field\n        // gRPC's proto deserialization limits nesting to 100 so this has bounded stack usage\n        if (fd.isRepeated() && msg.getField(fd) instanceof List list) {\n          // Checking for repeated fields also handles maps, because maps are syntax sugar for repeated MapEntries\n          // which themselves are Messages that will be recursively descended.\n          for (final Object o : list) {\n            validateMessage(o);\n          }\n        } else if (fd.hasPresence() && msg.hasField(fd)) {\n          // If the field has presence information and is present, recursively validate it. Not all fields have\n          // presence, but we only validate Message type fields anyway, which always have explicit presence.\n          validateMessage(msg.getField(fd));\n        }\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ManagedGrpcServer.java",
    "content": "package org.whispersystems.textsecuregcm.grpc.net;\n\nimport io.dropwizard.lifecycle.Managed;\nimport io.grpc.Server;\n\nimport java.io.IOException;\nimport java.util.concurrent.TimeUnit;\n\npublic class ManagedGrpcServer implements Managed {\n  private final Server server;\n\n  public ManagedGrpcServer(Server server) {\n    this.server = server;\n  }\n\n  @Override\n  public void start() throws IOException {\n    server.start();\n  }\n\n  @Override\n  public void stop() {\n    try {\n      server.shutdown().awaitTermination(5, TimeUnit.MINUTES);\n    } catch (final InterruptedException e) {\n      server.shutdownNow();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/net/ManagedNioEventLoopGroup.java",
    "content": "package org.whispersystems.textsecuregcm.grpc.net;\n\nimport io.dropwizard.lifecycle.Managed;\nimport io.netty.channel.nio.NioEventLoopGroup;\n\n/**\n * A wrapper for a Netty {@link NioEventLoopGroup} that implements Dropwizard's {@link Managed} interface, allowing\n * Dropwizard to manage the lifecycle of the event loop group.\n */\npublic class ManagedNioEventLoopGroup extends NioEventLoopGroup implements Managed {\n\n  @Override\n  public void stop() throws Exception {\n    this.shutdownGracefully().await();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/BaseFieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\nimport java.util.Set;\n\npublic abstract class BaseFieldValidator<T> implements FieldValidator {\n\n  private final String extensionName;\n\n  private final Set<Descriptors.FieldDescriptor.Type> supportedTypes;\n\n  private final MissingOptionalAction missingOptionalAction;\n\n  private final boolean applicableToRepeated;\n\n  protected enum MissingOptionalAction {\n    FAIL,\n    SUCCEED,\n    VALIDATE_DEFAULT_VALUE\n  }\n\n\n  protected BaseFieldValidator(\n      final String extensionName,\n      final Set<Descriptors.FieldDescriptor.Type> supportedTypes,\n      final MissingOptionalAction missingOptionalAction,\n      final boolean applicableToRepeated) {\n    this.extensionName = requireNonNull(extensionName);\n    this.supportedTypes = requireNonNull(supportedTypes);\n    this.missingOptionalAction = missingOptionalAction;\n    this.applicableToRepeated = applicableToRepeated;\n  }\n\n  @Override\n  public void validate(\n      final Object extensionValue,\n      final Descriptors.FieldDescriptor fd,\n      final Message msg) throws FieldValidationException {\n    final T extensionValueTyped = resolveExtensionValue(extensionValue);\n\n    // for the fields with an `optional` modifier, checking if the field was set\n    // and if not, checking if extension allows missing optional field\n    if (fd.hasPresence() && !msg.hasField(fd)) {\n      switch (missingOptionalAction) {\n        case FAIL -> {\n          throw new FieldValidationException(\"extension requires a value to be set\");\n        }\n        case SUCCEED -> {\n          return;\n        }\n        case VALIDATE_DEFAULT_VALUE -> {\n          // just continuing\n        }\n      }\n    }\n\n    // for the `repeated` fields, checking if it's supported by the extension\n    if (fd.isRepeated()) {\n      if (applicableToRepeated) {\n        validateRepeatedField(extensionValueTyped, fd, msg);\n        return;\n      }\n      throw new IllegalArgumentException(\"can't apply extension %s to `repeated` field %s\"\n          .formatted(extensionName, fd.getFullName()));\n    }\n\n    // checking field type against the set of supported types\n    final Descriptors.FieldDescriptor.Type type = fd.getType();\n    if (!supportedTypes.contains(type)) {\n      throw new IllegalArgumentException(\"can't apply extension %s to field %s of type %s\".formatted(\n          extensionName, fd.getFullName(), type));\n    }\n    switch (type) {\n      case INT64, UINT64, INT32, FIXED64, FIXED32, UINT32, SFIXED32, SFIXED64, SINT32, SINT64 ->\n          validateIntegerNumber(extensionValueTyped, ((Number) msg.getField(fd)).longValue(), type);\n      case STRING -> validateStringValue(extensionValueTyped, (String) msg.getField(fd));\n      case BYTES -> validateBytesValue(extensionValueTyped, (ByteString) msg.getField(fd));\n      case ENUM -> validateEnumValue(extensionValueTyped, (Descriptors.EnumValueDescriptor) msg.getField(fd));\n      case MESSAGE -> {\n        validateMessageValue(extensionValueTyped, (Message) msg.getField(fd));\n      }\n      case FLOAT, DOUBLE, BOOL, GROUP -> {\n        // at this moment, there are no validations specific to these types of fields\n      }\n    }\n\n  }\n\n  protected abstract T resolveExtensionValue(final Object extensionValue) throws FieldValidationException;\n\n  protected void validateRepeatedField(\n      final T extensionValue,\n      final Descriptors.FieldDescriptor fd,\n      final Message msg) throws FieldValidationException {\n    throw new UnsupportedOperationException(\"`validateRepeatedField` method needs to be implemented\");\n  }\n\n  protected void validateIntegerNumber(\n      final T extensionValue,\n      final long fieldValue, final Descriptors.FieldDescriptor.Type type) throws FieldValidationException {\n    throw new UnsupportedOperationException(\"`validateIntegerNumber` method needs to be implemented\");\n  }\n\n  protected void validateStringValue(\n      final T extensionValue,\n      final String fieldValue) throws FieldValidationException {\n    throw new UnsupportedOperationException(\"`validateStringValue` method needs to be implemented\");\n  }\n\n  protected void validateBytesValue(\n      final T extensionValue,\n      final ByteString fieldValue) throws FieldValidationException {\n    throw new UnsupportedOperationException(\"`validateBytesValue` method needs to be implemented\");\n  }\n\n  protected void validateEnumValue(\n      final T extensionValue,\n      final Descriptors.EnumValueDescriptor enumValueDescriptor) throws FieldValidationException {\n    throw new UnsupportedOperationException(\"`validateEnumValue` method needs to be implemented\");\n  }\n\n  protected void validateMessageValue(\n      final T extensionValue,\n      final Message message) throws FieldValidationException {\n    throw new UnsupportedOperationException(\"`validateMessageValue` method needs to be implemented\");\n  }\n\n  protected static boolean requireFlagExtension(final Object extensionValue) throws FieldValidationException {\n    if (extensionValue instanceof Boolean flagIsOn && flagIsOn) {\n      return true;\n    }\n    throw new UnsupportedOperationException(\"only value `true` is allowed\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/E164FieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.Descriptors;\nimport java.util.Set;\nimport org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;\nimport org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic class E164FieldValidator extends BaseFieldValidator<Boolean> {\n\n  public E164FieldValidator() {\n    super(\"e164\", Set.of(Descriptors.FieldDescriptor.Type.STRING), MissingOptionalAction.SUCCEED, false);\n  }\n\n  @Override\n  protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {\n    return requireFlagExtension(extensionValue);\n  }\n\n  @Override\n  protected void validateStringValue(\n      final Boolean extensionValue,\n      final String fieldValue) throws FieldValidationException {\n    try {\n      Util.requireNormalizedNumber(fieldValue);\n    } catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) {\n      throw new FieldValidationException(\"value is not in E164 format\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/EnumSpecifiedFieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.Descriptors;\nimport java.util.Set;\n\npublic class EnumSpecifiedFieldValidator extends BaseFieldValidator<Boolean> {\n\n  public EnumSpecifiedFieldValidator() {\n    super(\"specified\", Set.of(Descriptors.FieldDescriptor.Type.ENUM), MissingOptionalAction.FAIL, false);\n  }\n\n  @Override\n  protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {\n    return requireFlagExtension(extensionValue);\n  }\n\n  @Override\n  protected void validateEnumValue(\n      final Boolean extensionValue,\n      final Descriptors.EnumValueDescriptor enumValueDescriptor) throws FieldValidationException {\n    if (enumValueDescriptor.getIndex() <= 0) {\n      throw new FieldValidationException(\"enum field must be specified\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ExactlySizeFieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\nimport java.util.List;\nimport java.util.Set;\n\npublic class ExactlySizeFieldValidator extends BaseFieldValidator<Set<Integer>> {\n\n  public ExactlySizeFieldValidator() {\n    super(\"exactlySize\", Set.of(\n        Descriptors.FieldDescriptor.Type.STRING,\n        Descriptors.FieldDescriptor.Type.BYTES\n    ), MissingOptionalAction.VALIDATE_DEFAULT_VALUE, true);\n  }\n\n  @Override\n  protected Set<Integer> resolveExtensionValue(final Object extensionValue) {\n    //noinspection unchecked\n    return Set.copyOf((List<Integer>) extensionValue);\n  }\n\n  @Override\n  protected void validateBytesValue(\n      final Set<Integer> permittedSizes,\n      final ByteString fieldValue) throws FieldValidationException {\n    if (permittedSizes.contains(fieldValue.size())) {\n      return;\n    }\n    throw new FieldValidationException(\"byte array length is [%d] but expected to be one of %s\".formatted(fieldValue.size(), permittedSizes));\n  }\n\n  @Override\n  protected void validateStringValue(\n      final Set<Integer> permittedSizes,\n      final String fieldValue) throws FieldValidationException {\n    if (permittedSizes.contains(fieldValue.length())) {\n      return;\n    }\n    throw new FieldValidationException(\"string length is [%d] but expected to be one of %s\".formatted(fieldValue.length(), permittedSizes));\n  }\n\n  @Override\n  protected void validateRepeatedField(\n      final Set<Integer> permittedSizes,\n      final Descriptors.FieldDescriptor fd,\n      final Message msg) throws FieldValidationException {\n    final int size = msg.getRepeatedFieldCount(fd);\n    if (permittedSizes.contains(size)) {\n      return;\n    }\n    throw new FieldValidationException(\"list size is [%d] but expected to be one of %s\".formatted(size, permittedSizes));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/FieldValidationException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\npublic class FieldValidationException extends Exception {\n  public FieldValidationException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/FieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\n\npublic interface FieldValidator {\n\n  void validate(Object extensionValue, Descriptors.FieldDescriptor fd, Message msg)\n      throws FieldValidationException;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/NonEmptyFieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\nimport java.util.Set;\nimport org.apache.commons.lang3.StringUtils;\n\npublic class NonEmptyFieldValidator extends BaseFieldValidator<Boolean> {\n\n  public NonEmptyFieldValidator() {\n    super(\"nonEmpty\", Set.of(\n        Descriptors.FieldDescriptor.Type.STRING,\n        Descriptors.FieldDescriptor.Type.BYTES\n    ), MissingOptionalAction.FAIL, true);\n  }\n\n  @Override\n  protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {\n    return requireFlagExtension(extensionValue);\n  }\n\n  @Override\n  protected void validateBytesValue(\n      final Boolean extensionValue,\n      final ByteString fieldValue) throws FieldValidationException {\n    if (!fieldValue.isEmpty()) {\n      return;\n    }\n    throw new FieldValidationException(\"byte array expected to be non-empty\");\n  }\n\n  @Override\n  protected void validateStringValue(\n      final Boolean extensionValue,\n      final String fieldValue) throws FieldValidationException {\n    if (StringUtils.isNotEmpty(fieldValue)) {\n      return;\n    }\n    throw new FieldValidationException(\"string expected to be non-empty\");\n  }\n\n  @Override\n  protected void validateRepeatedField(\n      final Boolean extensionValue,\n      final Descriptors.FieldDescriptor fd,\n      final Message msg) throws FieldValidationException {\n    if (msg.getRepeatedFieldCount(fd) > 0) {\n      return;\n    }\n    throw new FieldValidationException(\"repeated field is expected to be non-empty\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/PresentFieldValidator.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\nimport java.util.Set;\n\npublic class PresentFieldValidator extends BaseFieldValidator<Boolean> {\n\n  public PresentFieldValidator() {\n    super(\"present\",\n        Set.of(Descriptors.FieldDescriptor.Type.MESSAGE),\n        MissingOptionalAction.FAIL,\n        true);\n  }\n\n  @Override\n  protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {\n    return requireFlagExtension(extensionValue);\n  }\n\n  @Override\n  protected void validateMessageValue(final Boolean extensionValue, final Message msg) throws FieldValidationException {\n    if (msg == null) {\n      throw new FieldValidationException(\"message expected to be present\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Range.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\npublic record Range(long min, long max) {\n  public Range {\n    if (min > max) {\n      throw new IllegalArgumentException(\"invalid range values: expected min <= max but have [%d, %d],\".formatted(min, max));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/RangeFieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.Descriptors;\nimport java.util.Set;\nimport org.signal.chat.require.ValueRangeConstraint;\n\npublic class RangeFieldValidator extends BaseFieldValidator<Range> {\n\n  private static final Set<Descriptors.FieldDescriptor.Type> UNSIGNED_TYPES = Set.of(\n      Descriptors.FieldDescriptor.Type.FIXED32,\n      Descriptors.FieldDescriptor.Type.UINT32,\n      Descriptors.FieldDescriptor.Type.FIXED64,\n      Descriptors.FieldDescriptor.Type.UINT64\n  );\n\n  public RangeFieldValidator() {\n    super(\"range\", Set.of(\n        Descriptors.FieldDescriptor.Type.INT64,\n        Descriptors.FieldDescriptor.Type.UINT64,\n        Descriptors.FieldDescriptor.Type.INT32,\n        Descriptors.FieldDescriptor.Type.FIXED64,\n        Descriptors.FieldDescriptor.Type.FIXED32,\n        Descriptors.FieldDescriptor.Type.UINT32,\n        Descriptors.FieldDescriptor.Type.SFIXED32,\n        Descriptors.FieldDescriptor.Type.SFIXED64,\n        Descriptors.FieldDescriptor.Type.SINT32,\n        Descriptors.FieldDescriptor.Type.SINT64\n    ), MissingOptionalAction.SUCCEED, false);\n  }\n\n  @Override\n  protected Range resolveExtensionValue(final Object extensionValue) {\n    final ValueRangeConstraint rangeConstraint = (ValueRangeConstraint) extensionValue;\n    final long min = rangeConstraint.hasMin() ? rangeConstraint.getMin() : Long.MIN_VALUE;\n    final long max = rangeConstraint.hasMax() ? rangeConstraint.getMax() : Long.MAX_VALUE;\n    return new Range(min, max);\n  }\n\n  @Override\n  protected void validateIntegerNumber(\n      final Range range,\n      final long fieldValue,\n      final Descriptors.FieldDescriptor.Type type) throws FieldValidationException {\n    if (fieldValue < 0 && UNSIGNED_TYPES.contains(type)) {\n      throw new FieldValidationException(\"field value is expected to be within the [%d, %d] range\".formatted(\n          range.min(), range.max()));\n    }\n    if (fieldValue < range.min() || fieldValue > range.max()) {\n      throw new FieldValidationException(\"field value is [%d] but expected to be within the [%d, %d] range\".formatted(\n          fieldValue, range.min(), range.max()));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/SizeFieldValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Descriptors;\nimport com.google.protobuf.Message;\nimport java.util.Set;\nimport org.signal.chat.require.SizeConstraint;\n\npublic class SizeFieldValidator extends BaseFieldValidator<Range> {\n\n  public SizeFieldValidator() {\n    super(\"size\", Set.of(\n        Descriptors.FieldDescriptor.Type.STRING,\n        Descriptors.FieldDescriptor.Type.BYTES\n    ), MissingOptionalAction.VALIDATE_DEFAULT_VALUE, true);\n  }\n\n  @Override\n  protected Range resolveExtensionValue(final Object extensionValue) throws FieldValidationException {\n    final SizeConstraint sizeConstraint = (SizeConstraint) extensionValue;\n    final int min = sizeConstraint.hasMin() ? sizeConstraint.getMin() : 0;\n    final int max = sizeConstraint.hasMax() ? sizeConstraint.getMax() : Integer.MAX_VALUE;\n    return new Range(min, max);\n  }\n\n  @Override\n  protected void validateBytesValue(final Range range, final ByteString fieldValue) throws FieldValidationException {\n    if (fieldValue.size() < range.min() || fieldValue.size() > range.max()) {\n      throw new FieldValidationException(\"field value is [%d] but expected to be within the [%d, %d] range\".formatted(\n          fieldValue.size(), range.min(), range.max()));\n    }\n  }\n\n  @Override\n  protected void validateStringValue(final Range range, final String fieldValue) throws FieldValidationException {\n    if (fieldValue.length() < range.min() || fieldValue.length() > range.max()) {\n      throw new FieldValidationException(\"field value is [%d] but expected to be within the [%d, %d] range\".formatted(\n          fieldValue.length(), range.min(), range.max()));\n    }\n  }\n\n  @Override\n  protected void validateRepeatedField(final Range range, final Descriptors.FieldDescriptor fd, final Message msg) throws FieldValidationException {\n    final int size = msg.getRepeatedFieldCount(fd);\n    if (size < range.min() || size > range.max()) {\n      throw new FieldValidationException(\"field value is [%d] but expected to be within the [%d, %d] range\".formatted(\n          size, range.min(), range.max()));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ValidatorUtils.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc.validators;\n\nimport com.google.protobuf.DescriptorProtos;\nimport com.google.protobuf.Descriptors;\nimport io.grpc.ServerServiceDefinition;\nimport io.grpc.Status;\nimport io.grpc.StatusException;\nimport io.grpc.protobuf.ProtoServiceDescriptorSupplier;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.signal.chat.require.Auth;\n\npublic final class ValidatorUtils {\n\n  public static final String REQUIRE_AUTH_EXTENSION_NAME = \"org.signal.chat.require.auth\";\n\n  private ValidatorUtils() {\n    // noop\n  }\n\n  public static Optional<Auth> serviceAuthExtensionValue(final ServerServiceDefinition serviceDefinition) {\n    return serviceExtensionValueByName(serviceDefinition, REQUIRE_AUTH_EXTENSION_NAME)\n        .map(val -> Auth.valueOf((Descriptors.EnumValueDescriptor) val));\n  }\n\n  private static Optional<Object> serviceExtensionValueByName(\n      final ServerServiceDefinition serviceDefinition,\n      final String fullExtensionName) {\n    final Object schemaDescriptor = serviceDefinition.getServiceDescriptor().getSchemaDescriptor();\n    if (schemaDescriptor instanceof ProtoServiceDescriptorSupplier protoServiceDescriptorSupplier) {\n      final DescriptorProtos.ServiceOptions options = protoServiceDescriptorSupplier.getServiceDescriptor().getOptions();\n      return options.getAllFields().entrySet()\n          .stream()\n          .filter(e -> e.getKey().getFullName().equals(fullExtensionName))\n          .map(Map.Entry::getValue)\n          .findFirst();\n    }\n    return Optional.empty();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClient.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.http;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.github.resilience4j.retry.Retry;\nimport io.github.resilience4j.retry.RetryConfig;\nimport java.io.IOException;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.security.KeyStore;\nimport java.security.cert.CertificateException;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.function.Predicate;\nimport java.util.function.Supplier;\nimport java.util.stream.IntStream;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.SslConfigurator;\nimport org.whispersystems.textsecuregcm.util.CertificateUtil;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\npublic class FaultTolerantHttpClient {\n\n  private final List<HttpClient> httpClients;\n  private final Duration defaultRequestTimeout;\n  @Nullable private final ScheduledExecutorService retryExecutor;\n  @Nullable private final Retry retry;\n  private final CircuitBreaker breaker;\n\n  public static final String SECURITY_PROTOCOL_TLS_1_2 = \"TLSv1.2\";\n  public static final String SECURITY_PROTOCOL_TLS_1_3 = \"TLSv1.3\";\n\n  public static Builder newBuilder(final String name, final Executor executor) {\n    return new Builder(name, executor);\n  }\n\n  @VisibleForTesting\n  FaultTolerantHttpClient(final List<HttpClient> httpClients,\n      final Duration defaultRequestTimeout,\n      @Nullable final ScheduledExecutorService retryExecutor,\n      @Nullable final Retry retry,\n      final CircuitBreaker circuitBreaker) {\n\n    this.httpClients = httpClients;\n    this.defaultRequestTimeout = defaultRequestTimeout;\n    this.retryExecutor = retryExecutor;\n    this.retry = retry;\n    this.breaker = circuitBreaker;\n  }\n\n  private HttpClient httpClient() {\n    return this.httpClients.get(ThreadLocalRandom.current().nextInt(this.httpClients.size()));\n  }\n\n  public <T> HttpResponse<T> send(final HttpRequest request, final HttpResponse.BodyHandler<T> bodyHandler)\n      throws IOException {\n    final Callable<HttpResponse<T>> requestCallable =\n        () -> httpClient().send(requestWithTimeout(request, defaultRequestTimeout), bodyHandler);\n\n    try {\n      return retry != null\n          ? breaker.executeCallable(retry.decorateCallable(requestCallable))\n          : breaker.executeCallable(requestCallable);\n    } catch (final IOException e) {\n      throw e;\n    } catch (final Exception e) {\n      if (e instanceof RuntimeException runtimeException) {\n        throw runtimeException;\n      }\n\n      throw new RuntimeException(e);\n    }\n  }\n\n  public <T> CompletableFuture<HttpResponse<T>> sendAsync(final HttpRequest request,\n      final HttpResponse.BodyHandler<T> bodyHandler) {\n\n    final Supplier<CompletionStage<HttpResponse<T>>> asyncRequestSupplier =\n        () -> httpClient().sendAsync(requestWithTimeout(request, defaultRequestTimeout), bodyHandler);\n\n    if (retry != null) {\n      assert retryExecutor != null;\n\n      return breaker.executeCompletionStage(retry.decorateCompletionStage(retryExecutor, asyncRequestSupplier))\n          .toCompletableFuture();\n    } else {\n      return breaker.executeCompletionStage(asyncRequestSupplier).toCompletableFuture();\n    }\n  }\n\n  private static HttpRequest requestWithTimeout(final HttpRequest request, final Duration defaultRequestTimeout) {\n    return request.timeout().isPresent()\n        ? request\n        : HttpRequest.newBuilder(request, (_, _) -> true)\n            .timeout(defaultRequestTimeout)\n            .build();\n  }\n\n  public static class Builder {\n\n    private HttpClient.Version version = HttpClient.Version.HTTP_2;\n    private HttpClient.Redirect redirect = HttpClient.Redirect.NEVER;\n    private Duration connectTimeout = Duration.ofSeconds(10);\n    private Duration requestTimeout = Duration.ofSeconds(60);\n    private int numClients = 1;\n\n    private final String name;\n    private final Executor executor;\n    private KeyStore trustStore;\n    private String securityProtocol = SECURITY_PROTOCOL_TLS_1_2;\n    private String retryConfigurationName;\n    private ScheduledExecutorService retryExecutor;\n    private Predicate<Throwable> retryOnException;\n    @Nullable private String circuitBreakerConfigurationName;\n\n    private Builder(final String name, final Executor executor) {\n      this.name = getClass().getSimpleName() + \"/\" + Objects.requireNonNull(name);\n      this.executor = Objects.requireNonNull(executor);\n    }\n\n    public Builder withVersion(HttpClient.Version version) {\n      this.version = version;\n      return this;\n    }\n\n    public Builder withRedirect(HttpClient.Redirect redirect) {\n      this.redirect = redirect;\n      return this;\n    }\n\n    public Builder withConnectTimeout(Duration connectTimeout) {\n      this.connectTimeout = connectTimeout;\n      return this;\n    }\n\n    public Builder withRequestTimeout(Duration requestTimeout) {\n      this.requestTimeout = requestTimeout;\n      return this;\n    }\n\n    public Builder withRetry(@Nullable final String retryConfigurationName, final ScheduledExecutorService retryExecutor) {\n      this.retryConfigurationName = retryConfigurationName;\n      this.retryExecutor = retryExecutor;\n\n      return this;\n    }\n\n    public Builder withCircuitBreaker(@Nullable final String circuitBreakerConfigurationName) {\n      this.circuitBreakerConfigurationName = circuitBreakerConfigurationName;\n      return this;\n    }\n\n    public Builder withSecurityProtocol(final String securityProtocol) {\n      this.securityProtocol = securityProtocol;\n      return this;\n    }\n\n    public Builder withTrustedServerCertificates(final String... certificatePem) throws CertificateException {\n      this.trustStore = CertificateUtil.buildKeyStoreForPem(certificatePem);\n      return this;\n    }\n\n    public Builder withRetryOnException(final Predicate<Throwable> predicate) {\n      this.retryOnException = throwable -> predicate.test(ExceptionUtils.unwrap(throwable));\n      return this;\n    }\n\n    /**\n     * Specify that the HttpClient should stripe requests across multiple HTTP clients\n     * <p>\n     * A {@link java.net.http.HttpClient} configured to use HTTP/2 will open a single connection per target host and\n     * will send concurrent requests to that host over the same connection. If the target host has set a low HTTP/2\n     * MAX_CONCURRENT_STREAMS, at MAX_CONCURRENT_STREAMS concurrent requests the client will throw IOExceptions.\n     * <p>\n     * To use a higher parallelism than the host sets per connection, setting a higher numClients will increase the\n     * number of connections we make to the backing server. Each request will be assigned to a random client.\n     * <p>\n     * This builder will refuse to {@link #build()} if the HTTP version is not HTTP/2\n     *\n     * @param numClients The number of underlying HTTP clients to use\n     * @return {@code this}\n     */\n    public Builder withNumClients(final int numClients) {\n      this.numClients = numClients;\n      return this;\n    }\n\n    public FaultTolerantHttpClient build() {\n      if (numClients > 1 && version != HttpClient.Version.HTTP_2) {\n        throw new IllegalArgumentException(\"Should not use additional HTTP clients unless using HTTP/2\");\n      }\n\n      final List<HttpClient> httpClients = IntStream\n          .range(0, numClients)\n          .mapToObj(i -> {\n            final HttpClient.Builder builder = HttpClient.newBuilder()\n                .connectTimeout(connectTimeout)\n                .followRedirects(redirect)\n                .version(version)\n                .executor(executor);\n\n            final SslConfigurator sslConfigurator = SslConfigurator.newInstance().securityProtocol(securityProtocol);\n\n            if (this.trustStore != null) {\n              sslConfigurator.trustStore(trustStore);\n            }\n            builder.sslContext(sslConfigurator.createSSLContext());\n            return builder.build();\n          }).toList();\n\n      @Nullable final Retry retry;\n\n      if (retryExecutor != null) {\n        final RetryConfig.Builder<HttpResponse<?>> retryConfigBuilder =\n            RetryConfig.from(Optional.ofNullable(retryConfigurationName)\n                .flatMap(name -> ResilienceUtil.getRetryRegistry().getConfiguration(name))\n                .orElseGet(() -> ResilienceUtil.getRetryRegistry().getDefaultConfig()));\n\n        retryConfigBuilder.retryOnResult(response -> response.statusCode() >= 500);\n\n        if (retryOnException != null) {\n          retryConfigBuilder.retryOnException(retryOnException);\n        }\n\n        retry = ResilienceUtil.getRetryRegistry()\n            .retry(ResilienceUtil.name(FaultTolerantHttpClient.class, name), retryConfigBuilder.build());\n      } else {\n        retry = null;\n      }\n\n      final String circuitBreakerName = ResilienceUtil.name(FaultTolerantHttpClient.class, name);\n\n      final CircuitBreaker circuitBreaker = circuitBreakerConfigurationName != null\n          ? ResilienceUtil.getCircuitBreakerRegistry().circuitBreaker(circuitBreakerName, circuitBreakerConfigurationName)\n          : ResilienceUtil.getCircuitBreakerRegistry().circuitBreaker(circuitBreakerName);\n\n      return new FaultTolerantHttpClient(httpClients, requestTimeout, retryExecutor, retry, circuitBreaker);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/identity/AciServiceIdentifier.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\nimport io.micrometer.core.instrument.Metrics;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.nio.ByteBuffer;\nimport java.util.Arrays;\nimport java.util.HexFormat;\nimport java.util.UUID;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\n/**\n * An identifier for an account based on the account's ACI.\n *\n * @param uuid the account's ACI UUID\n */\n@Schema(\n    type = \"string\",\n    description = \"An identifier for an account based on the account's ACI\"\n)\npublic record AciServiceIdentifier(UUID uuid) implements ServiceIdentifier {\n  private static final IdentityType IDENTITY_TYPE = IdentityType.ACI;\n\n  @Override\n  public IdentityType identityType() {\n    return IDENTITY_TYPE;\n  }\n\n  @Override\n  public String toServiceIdentifierString() {\n    return uuid.toString();\n  }\n\n  @Override\n  public byte[] toCompactByteArray() {\n    return UUIDUtil.toBytes(uuid);\n  }\n\n  @Override\n  public byte[] toFixedWidthByteArray() {\n    final ByteBuffer byteBuffer = ByteBuffer.allocate(17);\n    byteBuffer.put(IDENTITY_TYPE.getBytePrefix());\n    byteBuffer.putLong(uuid.getMostSignificantBits());\n    byteBuffer.putLong(uuid.getLeastSignificantBits());\n    byteBuffer.flip();\n\n    return byteBuffer.array();\n  }\n\n  @Override\n  public ServiceId.Aci toLibsignal() {\n    return new ServiceId.Aci(uuid);\n  }\n\n  public static AciServiceIdentifier valueOf(final String string) {\n    return new AciServiceIdentifier(UUID.fromString(string));\n  }\n\n  public static AciServiceIdentifier fromBytes(final byte[] bytes) {\n    final UUID uuid;\n\n    if (bytes.length == 17) {\n      if (bytes[0] != IDENTITY_TYPE.getBytePrefix()) {\n        throw new IllegalArgumentException(\"Unexpected byte array prefix: \" + HexFormat.of().formatHex(new byte[] { bytes[0] }));\n      }\n\n      uuid = UUIDUtil.fromBytes(Arrays.copyOfRange(bytes, 1, bytes.length));\n    } else {\n      uuid = UUIDUtil.fromBytes(bytes);\n    }\n\n    return new AciServiceIdentifier(uuid);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/identity/IdentityType.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\npublic enum IdentityType {\n  ACI((byte) 0x00, \"ACI:\"),\n  PNI((byte) 0x01, \"PNI:\");\n\n  private final byte bytePrefix;\n  private final String stringPrefix;\n\n  IdentityType(final byte bytePrefix, final String stringPrefix) {\n    this.bytePrefix = bytePrefix;\n    this.stringPrefix = stringPrefix;\n  }\n\n  byte getBytePrefix() {\n    return bytePrefix;\n  }\n\n  String getStringPrefix() {\n    return stringPrefix;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/identity/PniServiceIdentifier.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\n\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport java.nio.ByteBuffer;\nimport java.util.Arrays;\nimport java.util.HexFormat;\nimport java.util.UUID;\n\n/**\n * An identifier for an account based on the account's phone number identifier (PNI).\n *\n * @param uuid the account's PNI UUID\n */\n@Schema(\n    type = \"string\",\n    description = \"An identifier for an account based on the account's phone number identifier (PNI)\"\n)\npublic record PniServiceIdentifier(UUID uuid) implements ServiceIdentifier {\n\n  private static final IdentityType IDENTITY_TYPE = IdentityType.PNI;\n\n  @Override\n  public IdentityType identityType() {\n    return IDENTITY_TYPE;\n  }\n\n  @Override\n  public String toServiceIdentifierString() {\n    return IDENTITY_TYPE.getStringPrefix() + uuid.toString();\n  }\n\n  @Override\n  public byte[] toCompactByteArray() {\n    return toFixedWidthByteArray();\n  }\n\n  @Override\n  public byte[] toFixedWidthByteArray() {\n    final ByteBuffer byteBuffer = ByteBuffer.allocate(17);\n    byteBuffer.put(IDENTITY_TYPE.getBytePrefix());\n    byteBuffer.putLong(uuid.getMostSignificantBits());\n    byteBuffer.putLong(uuid.getLeastSignificantBits());\n    byteBuffer.flip();\n\n    return byteBuffer.array();\n  }\n\n  @Override\n  public ServiceId.Pni toLibsignal() {\n    return new ServiceId.Pni(uuid);\n  }\n\n  public static PniServiceIdentifier valueOf(final String string) {\n    if (!string.startsWith(IDENTITY_TYPE.getStringPrefix())) {\n      throw new IllegalArgumentException(\"PNI account identifier did not start with \\\"PNI:\\\" prefix\");\n    }\n\n    return new PniServiceIdentifier(UUID.fromString(string.substring(IDENTITY_TYPE.getStringPrefix().length())));\n  }\n\n  public static PniServiceIdentifier fromBytes(final byte[] bytes) {\n    if (bytes.length == 17) {\n      if (bytes[0] != IDENTITY_TYPE.getBytePrefix()) {\n        throw new IllegalArgumentException(\"Unexpected byte array prefix: \" + HexFormat.of().formatHex(new byte[] { bytes[0] }));\n      }\n\n      return new PniServiceIdentifier(UUIDUtil.fromBytes(Arrays.copyOfRange(bytes, 1, bytes.length)));\n    }\n\n    throw new IllegalArgumentException(\"Unexpected byte array length: \" + bytes.length);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/identity/ServiceIdentifier.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport java.util.UUID;\nimport org.signal.libsignal.protocol.ServiceId;\n\n/**\n * A \"service identifier\" is a tuple of a UUID and identity type that identifies an account and identity within the\n * Signal service.\n */\n@Schema(\n    type = \"string\",\n    description = \"A service identifier is a tuple of a UUID and identity type that identifies an account and identity within the Signal service.\",\n    subTypes = {AciServiceIdentifier.class, PniServiceIdentifier.class}\n)\npublic sealed interface ServiceIdentifier permits AciServiceIdentifier, PniServiceIdentifier {\n\n  /**\n   * Returns the identity type of this account identifier.\n   *\n   * @return the identity type of this account identifier\n   */\n  IdentityType identityType();\n\n  /**\n   * Returns the UUID for this account identifier.\n   *\n   * @return the UUID for this account identifier\n   */\n  UUID uuid();\n\n  /**\n   * Returns a string representation of this account identifier in a format that clients can unambiguously resolve into\n   * an identity type and UUID.\n   *\n   * @return a \"strongly-typed\" string representation of this account identifier\n   */\n  String toServiceIdentifierString();\n\n  /**\n   * Returns a compact binary representation of this account identifier.\n   *\n   * @return a binary representation of this account identifier\n   */\n  byte[] toCompactByteArray();\n\n  /**\n   * Returns a fixed-width binary representation of this account identifier.\n   *\n   * @return a binary representation of this account identifier\n   */\n  byte[] toFixedWidthByteArray();\n\n  /**\n   * Parse a service identifier string, which should be a plain UUID string for ACIs and a prefixed UUID string for PNIs\n   *\n   * @param string A service identifier string\n   * @return The parsed {@link ServiceIdentifier}\n   */\n  static ServiceIdentifier valueOf(final String string) {\n    try {\n      return AciServiceIdentifier.valueOf(string);\n    } catch (final IllegalArgumentException e) {\n      return PniServiceIdentifier.valueOf(string);\n    }\n  }\n\n  static ServiceIdentifier fromBytes(final byte[] bytes) {\n    try {\n      return AciServiceIdentifier.fromBytes(bytes);\n    } catch (final IllegalArgumentException e) {\n      return PniServiceIdentifier.fromBytes(bytes);\n    }\n  }\n\n  static ServiceIdentifier fromLibsignal(final ServiceId libsignalServiceId) {\n    if (libsignalServiceId instanceof ServiceId.Aci) {\n      return new AciServiceIdentifier(libsignalServiceId.getRawUUID());\n    }\n    if (libsignalServiceId instanceof ServiceId.Pni) {\n      return new PniServiceIdentifier(libsignalServiceId.getRawUUID());\n    }\n    throw new IllegalArgumentException(\"unknown libsignal ServiceId type\");\n  }\n\n  ServiceId toLibsignal();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/jetty/JettyHttpConfigurationCustomizer.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.jetty;\n\nimport org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;\nimport org.eclipse.jetty.server.ConnectionFactory;\nimport org.eclipse.jetty.server.Connector;\nimport org.eclipse.jetty.server.HttpConfiguration;\nimport org.eclipse.jetty.server.HttpConnectionFactory;\nimport org.eclipse.jetty.util.component.Container;\nimport org.eclipse.jetty.util.component.LifeCycle;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.binder.jetty.JettyConnectionMetrics;\n\n/**\n * Uses {@link Container.Listener} to update {@link org.eclipse.jetty.server.HttpConfiguration}\n */\npublic class JettyHttpConfigurationCustomizer implements Container.Listener, LifeCycle.Listener {\n\n  private static final Logger logger = LoggerFactory.getLogger(JettyHttpConfigurationCustomizer.class);\n\n  @Override\n  public void beanAdded(final Container parent, final Object child) {\n    if (child instanceof Connector c) {\n      for (ConnectionFactory cf : c.getConnectionFactories()) {\n        final HttpConfiguration httpConfiguration = switch (cf) {\n          case HTTP2ServerConnectionFactory h2cf -> h2cf.getHttpConfiguration();\n          case HttpConnectionFactory hcf -> hcf.getHttpConfiguration();\n          default -> null;\n        };\n\n        if (httpConfiguration != null) {\n          // see https://github.com/jetty/jetty.project/issues/1891\n          logger.info(\"setNotifyRemoteAsyncErrors(false) for {}\", cf);\n          httpConfiguration.setNotifyRemoteAsyncErrors(false);\n        }\n      }\n\n      c.addBean(new JettyConnectionMetrics(Metrics.globalRegistry));\n    }\n  }\n\n  @Override\n  public void beanRemoved(final Container parent, final Object child) {\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/keytransparency/KeyTransparencyServiceClient.java",
    "content": "package org.whispersystems.textsecuregcm.keytransparency;\n\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.lifecycle.Managed;\nimport io.grpc.ChannelCredentials;\nimport io.grpc.Deadline;\nimport io.grpc.Grpc;\nimport io.grpc.ManagedChannel;\nimport io.grpc.TlsChannelCredentials;\nimport io.micrometer.core.instrument.Metrics;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.cert.Certificate;\nimport java.security.cert.CertificateException;\nimport java.security.cert.CertificateFactory;\nimport java.security.cert.X509Certificate;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Collection;\nimport java.util.Optional;\nimport java.util.concurrent.TimeUnit;\nimport org.signal.keytransparency.client.AciMonitorRequest;\nimport org.signal.keytransparency.client.ConsistencyParameters;\nimport org.signal.keytransparency.client.DistinguishedRequest;\nimport org.signal.keytransparency.client.DistinguishedResponse;\nimport org.signal.keytransparency.client.E164MonitorRequest;\nimport org.signal.keytransparency.client.E164SearchRequest;\nimport org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;\nimport org.signal.keytransparency.client.MonitorRequest;\nimport org.signal.keytransparency.client.MonitorResponse;\nimport org.signal.keytransparency.client.SearchRequest;\nimport org.signal.keytransparency.client.SearchResponse;\nimport org.signal.keytransparency.client.UsernameHashMonitorRequest;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\npublic class KeyTransparencyServiceClient implements Managed {\n\n  private static final String DAYS_UNTIL_CLIENT_CERTIFICATE_EXPIRATION_GAUGE_NAME =\n      MetricsUtil.name(KeyTransparencyServiceClient.class, \"daysUntilClientCertificateExpiration\");\n  private static final Duration KEY_TRANSPARENCY_RPC_TIMEOUT = Duration.ofSeconds(15);\n\n  private static final Logger logger = LoggerFactory.getLogger(KeyTransparencyServiceClient.class);\n\n  private final String host;\n  private final int port;\n  private final ChannelCredentials tlsChannelCredentials;\n  private ManagedChannel channel;\n  private KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub stub;\n\n  public KeyTransparencyServiceClient(\n      final String host,\n      final int port,\n      final String tlsCertificate,\n      final String clientCertificate,\n      final String clientPrivateKey\n  ) throws IOException {\n    this.host = host;\n    this.port = port;\n    try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream(\n        tlsCertificate.getBytes(StandardCharsets.UTF_8));\n        final ByteArrayInputStream clientCertificateInputStream = new ByteArrayInputStream(\n            clientCertificate.getBytes(StandardCharsets.UTF_8));\n        final ByteArrayInputStream clientPrivateKeyInputStream = new ByteArrayInputStream(\n            clientPrivateKey.getBytes(StandardCharsets.UTF_8))\n    ) {\n      tlsChannelCredentials = TlsChannelCredentials.newBuilder()\n          .trustManager(certificateInputStream)\n          .keyManager(clientCertificateInputStream, clientPrivateKeyInputStream)\n          .build();\n\n      configureClientCertificateMetrics(clientCertificate);\n\n    }\n  }\n\n  private void configureClientCertificateMetrics(String clientCertificate) {\n    try {\n      final CertificateFactory cf = CertificateFactory.getInstance(\"X.509\");\n      final Collection<? extends Certificate> certificates = cf.generateCertificates(\n          new ByteArrayInputStream(clientCertificate.getBytes(StandardCharsets.UTF_8)));\n\n      if (certificates.isEmpty()) {\n        logger.warn(\"No client certificate found\");\n        return;\n      }\n\n      if (certificates.size() > 1) {\n        throw new IllegalArgumentException(\"Unexpected number of client certificates: \" + certificates.size());\n      }\n\n      final Certificate certificate = certificates.iterator().next();\n\n      if (certificate instanceof X509Certificate x509Cert) {\n        final Instant expiration = Instant.ofEpochMilli(x509Cert.getNotAfter().getTime());\n\n        Metrics.gauge(DAYS_UNTIL_CLIENT_CERTIFICATE_EXPIRATION_GAUGE_NAME,\n            this,\n            (ignored) -> Duration.between(Instant.now(), expiration).toDays());\n\n      } else {\n        logger.error(\"Certificate was of unexpected type: {}\", certificate.getClass().getName());\n      }\n\n    } catch (CertificateException e) {\n      throw new AssertionError(\"JDKs are required to support X.509 algorithms\", e);\n    }\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  public SearchResponse search(\n      final ByteString aci,\n      final ByteString aciIdentityKey,\n      final Optional<ByteString> usernameHash,\n      final Optional<E164SearchRequest> e164SearchRequest,\n      final Optional<Long> lastTreeHeadSize,\n      final long distinguishedTreeHeadSize) {\n    final SearchRequest.Builder searchRequestBuilder = SearchRequest.newBuilder()\n        .setAci(aci)\n        .setAciIdentityKey(aciIdentityKey);\n\n    usernameHash.ifPresent(searchRequestBuilder::setUsernameHash);\n    e164SearchRequest.ifPresent(searchRequestBuilder::setE164SearchRequest);\n\n    final ConsistencyParameters.Builder consistency = ConsistencyParameters.newBuilder()\n        .setDistinguished(distinguishedTreeHeadSize);\n    lastTreeHeadSize.ifPresent(consistency::setLast);\n\n    searchRequestBuilder.setConsistency(consistency.build());\n    return search(searchRequestBuilder.build());\n  }\n\n  public SearchResponse search(final SearchRequest request) {\n    return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))\n        .search(request);\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  public MonitorResponse monitor(final AciMonitorRequest aciMonitorRequest,\n      final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest,\n      final Optional<E164MonitorRequest> e164MonitorRequest,\n      final long lastTreeHeadSize,\n      final long distinguishedTreeHeadSize) {\n    final MonitorRequest.Builder monitorRequestBuilder = MonitorRequest.newBuilder()\n        .setAci(aciMonitorRequest)\n        .setConsistency(ConsistencyParameters.newBuilder()\n            .setLast(lastTreeHeadSize)\n            .setDistinguished(distinguishedTreeHeadSize)\n            .build());\n\n    usernameHashMonitorRequest.ifPresent(monitorRequestBuilder::setUsernameHash);\n    e164MonitorRequest.ifPresent(monitorRequestBuilder::setE164);\n    return monitor(monitorRequestBuilder.build());\n  }\n\n  public MonitorResponse monitor(final MonitorRequest request) {\n    return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))\n        .monitor(request);\n  }\n\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  public DistinguishedResponse getDistinguishedKey(final Optional<Long> lastTreeHeadSize) {\n    final DistinguishedRequest request = lastTreeHeadSize.map(\n            last -> DistinguishedRequest.newBuilder().setLast(last).build())\n        .orElseGet(DistinguishedRequest::getDefaultInstance);\n    return distinguished(request);\n  }\n\n  public DistinguishedResponse distinguished(final DistinguishedRequest request) {\n    return stub.withDeadline(toDeadline(KEY_TRANSPARENCY_RPC_TIMEOUT))\n        .distinguished(request);\n  }\n\n  private static Deadline toDeadline(final Duration timeout) {\n    return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS);\n  }\n\n  @Override\n  public void start() throws Exception {\n    channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials)\n        .idleTimeout(1, TimeUnit.MINUTES)\n        .build();\n    stub = KeyTransparencyQueryServiceGrpc.newBlockingStub(channel);\n  }\n\n  @Override\n  public void stop() throws Exception {\n    if (channel != null) {\n      channel.shutdown();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/BaseRateLimiters.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static java.util.Objects.requireNonNull;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.time.Clock;\nimport java.util.Arrays;\nimport java.util.Map;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Supplier;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\npublic abstract class BaseRateLimiters<T extends RateLimiterDescriptor> {\n\n  private final Map<T, RateLimiter> rateLimiterByDescriptor;\n\n  protected BaseRateLimiters(\n      final T[] values,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final ClusterLuaScript validateScript,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final ScheduledExecutorService retryExecutor,\n      final Clock clock) {\n    this.rateLimiterByDescriptor = Arrays.stream(values)\n        .map(descriptor -> Pair.of(\n            descriptor,\n            createForDescriptor(descriptor, dynamicConfigurationManager, validateScript, cacheCluster, retryExecutor, clock)))\n        .collect(Collectors.toUnmodifiableMap(Pair::getKey, Pair::getValue));\n  }\n\n  public RateLimiter forDescriptor(final T handle) {\n    return requireNonNull(rateLimiterByDescriptor.get(handle));\n  }\n\n  public static ClusterLuaScript defaultScript(final FaultTolerantRedisClusterClient cacheCluster) {\n    try {\n      return ClusterLuaScript.fromResource(\n          cacheCluster, \"lua/validate_rate_limit.lua\", ScriptOutputType.INTEGER);\n    } catch (final IOException e) {\n      throw new UncheckedIOException(\"Failed to load rate limit validation script\", e);\n    }\n  }\n\n  private static RateLimiter createForDescriptor(\n      final RateLimiterDescriptor descriptor,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final ClusterLuaScript validateScript,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final ScheduledExecutorService retryExecutor,\n      final Clock clock) {\n    final Supplier<RateLimiterConfig> configResolver =\n        () -> dynamicConfigurationManager.getConfiguration().getLimits().getOrDefault(descriptor.id(), descriptor.defaultConfig());\n    return new LeakyBucketRateLimiter(descriptor.id(), configResolver, validateScript, cacheCluster, retryExecutor, clock);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/CardinalityEstimator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport java.time.Duration;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.Util;\n\nimport static io.lettuce.core.ExpireArgs.Builder.nx;\n\n/**\n * Estimate the number of unique items seen over a configurable period and update a metric\n */\npublic class CardinalityEstimator {\n\n  private volatile long uniqueElementCount;\n  private final FaultTolerantRedisClusterClient redisCluster;\n  private final String hllName;\n  private final Duration period;\n\n  public CardinalityEstimator(final FaultTolerantRedisClusterClient redisCluster, final String name, final Duration period) {\n    this.redisCluster = redisCluster;\n    this.hllName = \"cardinality_estimator::\" + name;\n    this.period = period;\n    Metrics.gauge(\n        MetricsUtil.name(getClass(), \"unique\"),\n        Tags.of(\"metricName\", name),\n        this,\n        obj -> obj.uniqueElementCount);\n  }\n\n  public void add(final String element) {\n    addAsync(element).toCompletableFuture().join();\n  }\n\n  public CompletionStage<Void> addAsync(final String element) {\n    return redisCluster.withCluster(connection -> connection.async()\n        .pfadd(hllName, element)\n        .thenCompose(modCount -> {\n          if (modCount == 0) {\n            // The hll hasn't changed - return our current view of cardinality\n            return CompletableFuture.completedFuture(uniqueElementCount);\n          }\n\n          return connection.async().pfcount(hllName);\n        })\n        .thenCompose(newUniqueElementCount -> {\n          uniqueElementCount = newUniqueElementCount;\n          return connection.async().expire(hllName, period, nx()).thenRun(Util.NOOP);\n        }));\n  }\n\n  @VisibleForTesting\n  long estimate() {\n    return this.uniqueElementCount;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/LeakyBucketRateLimiter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static java.util.Objects.requireNonNull;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Supplier;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic class LeakyBucketRateLimiter implements RateLimiter {\n\n  private final String name;\n  private final Supplier<RateLimiterConfig> configResolver;\n\n  private final ClusterLuaScript validateScript;\n\n  private final FaultTolerantRedisClusterClient cluster;\n  private final ScheduledExecutorService retryExecutor;\n\n  private final Counter limitExceededCounter;\n\n  private final Clock clock;\n\n  private static final String RETRY_NAME = ResilienceUtil.name(LeakyBucketRateLimiter.class);\n\n  public LeakyBucketRateLimiter(\n      final String name,\n      final RateLimiterConfig rateLimiterConfig,\n      final ClusterLuaScript validateScript,\n      final FaultTolerantRedisClusterClient cluster,\n      final ScheduledExecutorService retryExecutor,\n      final Clock clock) {\n\n    this(name, () -> rateLimiterConfig, validateScript, cluster, retryExecutor, clock);\n  }\n\n  public LeakyBucketRateLimiter(\n      final String name,\n      final Supplier<RateLimiterConfig> configResolver,\n      final ClusterLuaScript validateScript,\n      final FaultTolerantRedisClusterClient cluster,\n      final ScheduledExecutorService retryExecutor,\n      final Clock clock) {\n    this.name = requireNonNull(name);\n    this.configResolver = requireNonNull(configResolver);\n    this.validateScript = requireNonNull(validateScript);\n    this.cluster = requireNonNull(cluster);\n    this.retryExecutor = requireNonNull(retryExecutor);\n    this.clock = requireNonNull(clock);\n    this.limitExceededCounter = Metrics.counter(MetricsUtil.name(getClass(), \"exceeded\"), \"rateLimiterName\", name);\n  }\n\n  @Override\n  public void validate(final String key, final long amount) throws RateLimitExceededException {\n    final RateLimiterConfig config = config();\n    try {\n      final long deficitPermitsAmount = executeValidateScript(config, key, amount, true);\n      if (deficitPermitsAmount > 0) {\n        limitExceededCounter.increment();\n        final Duration retryAfter = Duration.ofMillis(\n            (long) Math.ceil((double) deficitPermitsAmount / config.leakRatePerMillis()));\n        throw new RateLimitExceededException(retryAfter);\n      }\n    } catch (final Exception e) {\n      if (e instanceof RateLimitExceededException rateLimitExceededException) {\n        throw rateLimitExceededException;\n      }\n\n      if (!config.failOpen()) {\n        throw e;\n      }\n    }\n  }\n\n  @Override\n  public CompletionStage<Void> validateAsync(final String key, final long amount) {\n    final RateLimiterConfig config = config();\n\n    return executeValidateScriptAsync(config, key, amount, true)\n        .thenCompose(deficitPermitsAmount -> {\n          if (deficitPermitsAmount == 0) {\n            return CompletableFuture.completedFuture((Void) null);\n          }\n          limitExceededCounter.increment();\n          final Duration retryAfter = Duration.ofMillis(\n              (long) Math.ceil((double) deficitPermitsAmount / config.leakRatePerMillis()));\n          return CompletableFuture.failedFuture(new RateLimitExceededException(retryAfter));\n        })\n        .exceptionally(throwable -> {\n          if (ExceptionUtils.unwrap(throwable) instanceof RateLimitExceededException rateLimitExceededException) {\n            throw ExceptionUtils.wrap(rateLimitExceededException);\n          }\n\n          if (config.failOpen()) {\n            return null;\n          }\n\n          throw ExceptionUtils.wrap(throwable);\n        });\n  }\n\n  @Override\n  public boolean hasAvailablePermits(final String key, final long permits) {\n    final RateLimiterConfig config = config();\n    try {\n      final long deficitPermitsAmount = executeValidateScript(config, key, permits, false);\n      return deficitPermitsAmount == 0;\n    } catch (final Exception e) {\n      if (config.failOpen()) {\n        return true;\n      } else {\n        throw e;\n      }\n    }\n  }\n\n  @Override\n  public CompletionStage<Boolean> hasAvailablePermitsAsync(final String key, final long amount) {\n    final RateLimiterConfig config = config();\n    return executeValidateScriptAsync(config, key, amount, false)\n        .thenApply(deficitPermitsAmount -> deficitPermitsAmount == 0)\n        .exceptionally(throwable -> {\n          if (config.failOpen()) {\n            return true;\n          }\n          throw ExceptionUtils.wrap(throwable);\n        });\n  }\n\n  @Override\n  public void clear(final String key) {\n    ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeRunnable(() -> cluster.useCluster(connection -> connection.sync().del(bucketName(name, key))));\n  }\n\n  @Override\n  public CompletionStage<Void> clearAsync(final String key) {\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> cluster.withCluster(connection -> connection.async().del(bucketName(name, key)))\n            .thenRun(Util.NOOP));\n  }\n\n  @Override\n  public RateLimiterConfig config() {\n    return configResolver.get();\n  }\n\n  private long executeValidateScript(final RateLimiterConfig config, final String key, final long amount, final boolean applyChanges) {\n    final List<String> keys = List.of(bucketName(name, key));\n    final List<String> arguments = List.of(\n        String.valueOf(config.bucketSize()),\n        String.valueOf(config.leakRatePerMillis()),\n        String.valueOf(clock.millis()),\n        String.valueOf(amount),\n        String.valueOf(applyChanges)\n    );\n    return (Long) validateScript.execute(keys, arguments);\n  }\n\n  private CompletionStage<Long> executeValidateScriptAsync(final RateLimiterConfig config, final String key, final long amount, final boolean applyChanges) {\n    final List<String> keys = List.of(bucketName(name, key));\n    final List<String> arguments = List.of(\n        String.valueOf(config.bucketSize()),\n        String.valueOf(config.leakRatePerMillis()),\n        String.valueOf(clock.millis()),\n        String.valueOf(amount),\n        String.valueOf(applyChanges)\n    );\n    return validateScript.executeAsync(keys, arguments).thenApply(o -> (Long) o);\n  }\n\n  private static String bucketName(final String name, final String key) {\n    return \"leaky_bucket::\" + name + \"::\" + key;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/MessageDeliveryLoopMonitor.java",
    "content": "package org.whispersystems.textsecuregcm.limits;\n\nimport java.util.UUID;\n\npublic interface MessageDeliveryLoopMonitor {\n  /**\n   * Records an attempt to deliver a message with the given GUID to the given account/device pair and returns the number\n   * of consecutive attempts to deliver the same message and logs a warning if the message appears to be in a delivery\n   * loop. This method is intended to detect cases where a message remains at the head of a device's queue after\n   * repeated attempts to deliver the message, and so the given message GUID should be the first message of a \"page\"\n   * sent to clients.\n   *\n   * @param accountIdentifier the identifier of the destination account\n   * @param deviceId the destination device's ID within the given account\n   * @param messageGuid the GUID of the message\n   * @param userAgent the User-Agent header supplied by the caller\n   * @param context a human-readable string identifying the mechanism of message delivery (e.g. \"rest\" or \"websocket\")\n   */\n  void recordDeliveryAttempt(UUID accountIdentifier, byte deviceId, UUID messageGuid, String userAgent, String context);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/NoopMessageDeliveryLoopMonitor.java",
    "content": "package org.whispersystems.textsecuregcm.limits;\n\nimport java.util.UUID;\n\npublic class NoopMessageDeliveryLoopMonitor implements MessageDeliveryLoopMonitor {\n\n  public NoopMessageDeliveryLoopMonitor() {\n  }\n\n  public void recordDeliveryAttempt(final UUID accountIdentifier, final byte deviceId, final UUID messageGuid, final String userAgent, final String context) {\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/PushChallengeManager.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Metrics;\nimport java.security.SecureRandom;\nimport java.time.Duration;\nimport java.util.HexFormat;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.push.NotPushRegisteredException;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.PushChallengeDynamoDb;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\npublic class PushChallengeManager {\n  private final PushNotificationManager pushNotificationManager;\n  private final PushChallengeDynamoDb pushChallengeDynamoDb;\n\n  private final SecureRandom random = new SecureRandom();\n\n  private static final int CHALLENGE_TOKEN_LENGTH = 16;\n  private static final Duration CHALLENGE_TTL = Duration.ofMinutes(5);\n\n  private static final String CHALLENGE_REQUESTED_COUNTER_NAME = name(PushChallengeManager.class, \"requested\");\n  private static final String CHALLENGE_ANSWERED_COUNTER_NAME = name(PushChallengeManager.class, \"answered\");\n\n  private static final String PLATFORM_TAG_NAME = \"platform\";\n  private static final String SENT_TAG_NAME = \"sent\";\n  private static final String SUCCESS_TAG_NAME = \"success\";\n  private static final String SOURCE_COUNTRY_TAG_NAME = \"sourceCountry\";\n\n  public PushChallengeManager(final PushNotificationManager pushNotificationManager,\n      final PushChallengeDynamoDb pushChallengeDynamoDb) {\n\n    this.pushNotificationManager = pushNotificationManager;\n    this.pushChallengeDynamoDb = pushChallengeDynamoDb;\n  }\n\n  public void sendChallenge(final Account account) throws NotPushRegisteredException {\n    final Device primaryDevice = account.getPrimaryDevice();\n\n    final byte[] token = new byte[CHALLENGE_TOKEN_LENGTH];\n    random.nextBytes(token);\n\n    final boolean sent;\n    final String platform;\n\n    if (pushChallengeDynamoDb.add(account.getUuid(), token, CHALLENGE_TTL)) {\n      pushNotificationManager.sendRateLimitChallengeNotification(account, HexFormat.of().formatHex(token));\n\n      sent = true;\n\n      if (StringUtils.isNotBlank(primaryDevice.getGcmId())) {\n        platform = ClientPlatform.ANDROID.name().toLowerCase();\n      } else if (StringUtils.isNotBlank(primaryDevice.getApnId())) {\n        platform = ClientPlatform.IOS.name().toLowerCase();\n      } else {\n        // This should never happen; if the account has neither an APN nor FCM token, sending the challenge will result\n        // in a `NotPushRegisteredException`\n        platform = \"unrecognized\";\n      }\n    } else {\n      sent = false;\n      platform = \"unrecognized\";\n    }\n\n    Metrics.counter(CHALLENGE_REQUESTED_COUNTER_NAME,\n        PLATFORM_TAG_NAME, platform,\n        SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber()),\n        SENT_TAG_NAME, String.valueOf(sent)).increment();\n  }\n\n  public boolean answerChallenge(final Account account, final String challengeTokenHex) {\n    boolean success = false;\n\n    try {\n      success = pushChallengeDynamoDb.remove(account.getUuid(), HexFormat.of().parseHex(challengeTokenHex));\n    } catch (final IllegalArgumentException ignored) {\n    }\n\n    final String platform;\n\n    if (StringUtils.isNotBlank(account.getPrimaryDevice().getGcmId())) {\n      platform = ClientPlatform.ANDROID.name().toLowerCase();\n    } else if (StringUtils.isNotBlank(account.getPrimaryDevice().getApnId())) {\n      platform = ClientPlatform.IOS.name().toLowerCase();\n    } else {\n      platform = \"unknown\";\n    }\n\n    Metrics.counter(CHALLENGE_ANSWERED_COUNTER_NAME,\n        PLATFORM_TAG_NAME, platform,\n        SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber()),\n        SUCCESS_TAG_NAME, String.valueOf(success)).increment();\n\n    return success;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitByIpFilter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.ClientErrorException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerRequestFilter;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.Optional;\nimport org.glassfish.jersey.server.ExtendedUriInfo;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\npublic class RateLimitByIpFilter implements ContainerRequestFilter {\n\n  private static final Logger logger = LoggerFactory.getLogger(RateLimitByIpFilter.class);\n\n  @VisibleForTesting\n  static final RateLimitExceededException INVALID_HEADER_EXCEPTION = new RateLimitExceededException(Duration.ofHours(1)\n  );\n\n  private static final ExceptionMapper<RateLimitExceededException> EXCEPTION_MAPPER = new RateLimitExceededExceptionMapper();\n\n  private static final String NO_IP_COUNTER_NAME = MetricsUtil.name(RateLimitByIpFilter.class, \"noIpAddress\");\n\n  private final RateLimiters rateLimiters;\n\n  public RateLimitByIpFilter(final RateLimiters rateLimiters) {\n    this.rateLimiters = requireNonNull(rateLimiters);\n  }\n\n  @Override\n  public void filter(final ContainerRequestContext requestContext) throws IOException {\n    // requestContext.getUriInfo() should always be an instance of `ExtendedUriInfo`\n    // in the Jersey client\n    if (!(requestContext.getUriInfo() instanceof final ExtendedUriInfo uriInfo)) {\n      return;\n    }\n\n    final RateLimitedByIp annotation = uriInfo.getMatchedResourceMethod()\n        .getInvocable()\n        .getHandlingMethod()\n        .getAnnotation(RateLimitedByIp.class);\n\n    if (annotation == null) {\n      return;\n    }\n\n    final RateLimiters.For handle = annotation.value();\n\n    try {\n      final Optional<String> remoteAddress = Optional.ofNullable(\n          (String) requestContext.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME));\n\n      // checking if we failed to extract the most recent IP for any reason\n      if (remoteAddress.isEmpty()) {\n        Metrics.counter(\n            NO_IP_COUNTER_NAME,\n            Tags.of(\n                Tag.of(\"limiter\", handle.id()),\n                Tag.of(\"fail\", String.valueOf(annotation.failOnUnresolvedIp()))))\n            .increment();\n\n        // checking if annotation is configured to fail when the most recent IP is not resolved\n        if (annotation.failOnUnresolvedIp()) {\n          logger.error(\"Remote address was null\");\n          throw INVALID_HEADER_EXCEPTION;\n        }\n        // otherwise, allow request\n        return;\n      }\n\n      final RateLimiter rateLimiter = rateLimiters.forDescriptor(handle);\n      rateLimiter.validate(remoteAddress.get());\n    } catch (RateLimitExceededException e) {\n      final Response response = EXCEPTION_MAPPER.toResponse(e);\n      throw new ClientErrorException(response);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.captcha.Action;\nimport org.whispersystems.textsecuregcm.captcha.CaptchaChecker;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.NotPushRegisteredException;\nimport org.whispersystems.textsecuregcm.spam.ChallengeType;\nimport org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic class RateLimitChallengeManager {\n\n  private final PushChallengeManager pushChallengeManager;\n  private final CaptchaChecker captchaChecker;\n  private final RateLimiters rateLimiters;\n\n  private final List<RateLimitChallengeListener> rateLimitChallengeListeners;\n\n  private static final String CAPTCHA_ATTEMPT_COUNTER_NAME = name(RateLimitChallengeManager.class, \"captcha\",\n      \"attempt\");\n  private static final String RESET_RATE_LIMIT_EXCEEDED_COUNTER_NAME = name(RateLimitChallengeManager.class, \"resetRateLimitExceeded\");\n\n  private static final String SOURCE_COUNTRY_TAG_NAME = \"sourceCountry\";\n  private static final String SUCCESS_TAG_NAME = \"success\";\n\n  public RateLimitChallengeManager(\n      final PushChallengeManager pushChallengeManager,\n      final CaptchaChecker captchaChecker,\n      final RateLimiters rateLimiters,\n      final List<RateLimitChallengeListener> rateLimitChallengeListeners) {\n\n    this.pushChallengeManager = pushChallengeManager;\n    this.captchaChecker = captchaChecker;\n    this.rateLimiters = rateLimiters;\n    this.rateLimitChallengeListeners = rateLimitChallengeListeners;\n  }\n\n  public void answerPushChallenge(final Account account, final String challenge) throws RateLimitExceededException {\n    rateLimiters.getPushChallengeAttemptLimiter().validate(account.getUuid());\n\n    final boolean challengeSuccess = pushChallengeManager.answerChallenge(account, challenge);\n\n    if (challengeSuccess) {\n      rateLimiters.getPushChallengeSuccessLimiter().validate(account.getUuid());\n      resetRateLimits(account, ChallengeType.PUSH);\n    }\n  }\n\n  public boolean answerCaptchaChallenge(final Account account, final String captcha, final String mostRecentProxyIp,\n      final String userAgent, final Optional<Float> scoreThreshold)\n      throws RateLimitExceededException, IOException {\n\n    rateLimiters.getCaptchaChallengeAttemptLimiter().validate(account.getUuid());\n\n    final boolean challengeSuccess = captchaChecker.verify(Optional.of(account.getUuid()), Action.CHALLENGE, captcha, mostRecentProxyIp, userAgent).isValid(scoreThreshold);\n\n    final Tags tags = Tags.of(\n        Tag.of(SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())),\n        Tag.of(SUCCESS_TAG_NAME, String.valueOf(challengeSuccess)),\n        UserAgentTagUtil.getPlatformTag(userAgent)\n    );\n\n    Metrics.counter(CAPTCHA_ATTEMPT_COUNTER_NAME, tags).increment();\n\n    if (challengeSuccess) {\n      rateLimiters.getCaptchaChallengeSuccessLimiter().validate(account.getUuid());\n      resetRateLimits(account, ChallengeType.CAPTCHA);\n    }\n    return challengeSuccess;\n  }\n\n  private void resetRateLimits(final Account account, final ChallengeType type) throws RateLimitExceededException {\n    try {\n      rateLimiters.getRateLimitResetLimiter().validate(account.getUuid());\n    } catch (final RateLimitExceededException e) {\n      Metrics.counter(RESET_RATE_LIMIT_EXCEEDED_COUNTER_NAME,\n          SOURCE_COUNTRY_TAG_NAME, Util.getCountryCode(account.getNumber())).increment();\n\n      throw e;\n    }\n\n    rateLimitChallengeListeners.forEach(listener -> listener.handleRateLimitChallengeAnswered(account, type));\n  }\n\n  public void sendPushChallenge(final Account account) throws NotPushRegisteredException {\n    pushChallengeManager.sendChallenge(account);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOption.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\npublic enum RateLimitChallengeOption {\n  CAPTCHA(\"captcha\"),\n  PUSH_CHALLENGE(\"pushChallenge\");\n\n  private final String apiName;\n\n  RateLimitChallengeOption(final String apiName) {\n    this.apiName = apiName;\n  }\n\n  public String getApiName() {\n    return apiName;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManager.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\npublic class RateLimitChallengeOptionManager {\n\n  private final RateLimiters rateLimiters;\n\n  public RateLimitChallengeOptionManager(final RateLimiters rateLimiters) {\n    this.rateLimiters = rateLimiters;\n  }\n\n  public List<RateLimitChallengeOption> getChallengeOptions(final Account account) {\n    final List<RateLimitChallengeOption> options = new ArrayList<>(2);\n\n    if (rateLimiters.getCaptchaChallengeAttemptLimiter().hasAvailablePermits(account.getUuid(), 1) &&\n        rateLimiters.getCaptchaChallengeSuccessLimiter().hasAvailablePermits(account.getUuid(), 1)) {\n\n      options.add(RateLimitChallengeOption.CAPTCHA);\n    }\n\n    if (rateLimiters.getPushChallengeAttemptLimiter().hasAvailablePermits(account.getUuid(), 1) &&\n        rateLimiters.getPushChallengeSuccessLimiter().hasAvailablePermits(account.getUuid(), 1)) {\n\n      options.add(RateLimitChallengeOption.PUSH_CHALLENGE);\n    }\n\n    return options;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIp.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n@Target(ElementType.METHOD)\n@Retention(RetentionPolicy.RUNTIME)\npublic @interface RateLimitedByIp {\n\n  RateLimiters.For value();\n\n  boolean failOnUnresolvedIp() default true;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport java.util.UUID;\nimport java.util.concurrent.CompletionStage;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport reactor.core.publisher.Mono;\n\npublic interface RateLimiter {\n\n  void validate(String key, long amount) throws RateLimitExceededException;\n\n  CompletionStage<Void> validateAsync(String key, long amount);\n\n  boolean hasAvailablePermits(String key, long permits);\n\n  CompletionStage<Boolean> hasAvailablePermitsAsync(String key, long amount);\n\n  void clear(String key);\n\n  CompletionStage<Void> clearAsync(String key);\n\n  RateLimiterConfig config();\n\n  default void validate(final String key) throws RateLimitExceededException {\n    validate(key, 1);\n  }\n\n  default void validate(final UUID accountUuid) throws RateLimitExceededException {\n    validate(accountUuid.toString());\n  }\n\n  default void validate(final UUID accountUuid, final long permits) throws RateLimitExceededException {\n    validate(accountUuid.toString(), permits);\n  }\n\n  default void validate(final UUID srcAccountUuid, final UUID dstAccountUuid) throws RateLimitExceededException {\n    validate(srcAccountUuid.toString() + \"__\" + dstAccountUuid.toString());\n  }\n\n  default CompletionStage<Void> validateAsync(final String key) {\n    return validateAsync(key, 1);\n  }\n\n  default CompletionStage<Void> validateAsync(final UUID accountUuid) {\n    return validateAsync(accountUuid.toString());\n  }\n\n  default CompletionStage<Void> validateAsync(final UUID srcAccountUuid, final UUID dstAccountUuid) {\n    return validateAsync(srcAccountUuid.toString() + \"__\" + dstAccountUuid.toString());\n  }\n\n  default Mono<Void> validateReactive(final String key) {\n    return Mono.fromFuture(() -> validateAsync(key).toCompletableFuture());\n  }\n\n  default Mono<Void> validateReactive(final UUID accountUuid) {\n    return validateReactive(accountUuid.toString());\n  }\n\n  default boolean hasAvailablePermits(final UUID accountUuid, final long permits) {\n    return hasAvailablePermits(accountUuid.toString(), permits);\n  }\n\n  default CompletionStage<Boolean> hasAvailablePermitsAsync(final UUID accountUuid, final long permits) {\n    return hasAvailablePermitsAsync(accountUuid.toString(), permits);\n  }\n\n  default void clear(final UUID accountUuid) {\n    clear(accountUuid.toString());\n  }\n\n  default CompletionStage<Void> clearAsync(final UUID accountUuid) {\n    return clearAsync(accountUuid.toString());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfig.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport jakarta.validation.constraints.AssertTrue;\nimport java.time.Duration;\n\npublic record RateLimiterConfig(long bucketSize, Duration permitRegenerationDuration, boolean failOpen) {\n\n  public double leakRatePerMillis() {\n    return 1.0 / (permitRegenerationDuration.toNanos() / 1e6);\n  }\n\n  @AssertTrue\n  @Schema(hidden = true)\n  public boolean isPositiveRegenerationRate() {\n    try {\n      return permitRegenerationDuration.toNanos() > 0;\n    } catch (final ArithmeticException e) {\n      // The duration was too large to fit in a long, so it's definitely positive\n      return true;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiterDescriptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\n/**\n * Represents an information that defines a rate limiter.\n */\npublic interface RateLimiterDescriptor {\n  /**\n   * Implementing classes will likely be Enums, so name is chosen not to clash with {@link Enum#name()}.\n   * @return id of this rate limiter to be used in `yml` config files and as a part of the bucket key.\n   */\n  String id();\n\n  /**\n   * @return an instance of {@link RateLimiterConfig} to be used by default,\n   *         i.e. if there is no override in the application dynamic configuration.\n   */\n  RateLimiterConfig defaultConfig();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RateLimiters.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.limits;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\npublic class RateLimiters extends BaseRateLimiters<RateLimiters.For> {\n\n  public enum For implements RateLimiterDescriptor {\n    BACKUP_AUTH_CHECK(\"backupAuthCheck\", new RateLimiterConfig(100, Duration.ofMinutes(15), true)),\n    PIN(\"pin\", new RateLimiterConfig(10, Duration.ofDays(1), false)),\n    ATTACHMENT(\"attachmentCreate\", new RateLimiterConfig(50, Duration.ofMillis(1200), true)),\n    BACKUP_ATTACHMENT(\"backupAttachmentCreate\", new RateLimiterConfig(10_000, Duration.ofSeconds(1), true)),\n    PRE_KEYS(\"prekeys\", new RateLimiterConfig(6, Duration.ofMinutes(10), false)),\n    MESSAGES(\"messages\", new RateLimiterConfig(60, Duration.ofSeconds(1), true)),\n    STORIES(\"stories\", new RateLimiterConfig(5_000, Duration.ofSeconds(8), true)),\n    ALLOCATE_DEVICE(\"allocateDevice\", new RateLimiterConfig(6, Duration.ofMinutes(2), false)),\n    VERIFY_DEVICE(\"verifyDevice\", new RateLimiterConfig(6, Duration.ofMinutes(2), false)),\n    PROFILE(\"profile\", new RateLimiterConfig(4320, Duration.ofSeconds(20), true)),\n    STICKER_PACK(\"stickerPack\", new RateLimiterConfig(50, Duration.ofMinutes(72), false)),\n    USERNAME_LOOKUP(\"usernameLookup\", new RateLimiterConfig(100, Duration.ofMinutes(15), true)),\n    USERNAME_SET(\"usernameSet\", new RateLimiterConfig(100, Duration.ofMinutes(15), false)),\n    USERNAME_RESERVE(\"usernameReserve\", new RateLimiterConfig(100, Duration.ofMinutes(15), false)),\n    USERNAME_LINK_OPERATION(\"usernameLinkOperation\", new RateLimiterConfig(10, Duration.ofMinutes(1), false)),\n    USERNAME_LINK_LOOKUP_PER_IP(\"usernameLinkLookupPerIp\", new RateLimiterConfig(100, Duration.ofSeconds(15), true)),\n    CHECK_ACCOUNT_EXISTENCE(\"checkAccountExistence\", new RateLimiterConfig(1000, Duration.ofSeconds(4), true)),\n    REGISTRATION(\"registration\", new RateLimiterConfig(6, Duration.ofSeconds(30), false)),\n    VERIFICATION_PUSH_CHALLENGE(\"verificationPushChallenge\", new RateLimiterConfig(5, Duration.ofSeconds(30), false)),\n    VERIFICATION_CAPTCHA(\"verificationCaptcha\", new RateLimiterConfig(10, Duration.ofSeconds(30), false)),\n    RATE_LIMIT_RESET(\"rateLimitReset\", new RateLimiterConfig(2, Duration.ofHours(12), false)),\n    CAPTCHA_CHALLENGE_ATTEMPT(\"captchaChallengeAttempt\", new RateLimiterConfig(10, Duration.ofMinutes(144), false)),\n    CAPTCHA_CHALLENGE_SUCCESS(\"captchaChallengeSuccess\", new RateLimiterConfig(2, Duration.ofHours(12), false)),\n    SET_BACKUP_ID(\"setBackupId\", new RateLimiterConfig(10, Duration.ofHours(1), false)),\n    SET_PAID_MEDIA_BACKUP_ID(\"setPaidMediaBackupId\", new RateLimiterConfig(5, Duration.ofDays(7), false)),\n    PUSH_CHALLENGE_ATTEMPT(\"pushChallengeAttempt\", new RateLimiterConfig(10, Duration.ofMinutes(144), false)),\n    PUSH_CHALLENGE_SUCCESS(\"pushChallengeSuccess\", new RateLimiterConfig(2, Duration.ofHours(12), false)),\n    GET_CALLING_RELAYS(\"getCallingRelays\", new RateLimiterConfig(100, Duration.ofMinutes(10), false)),\n    CREATE_CALL_LINK(\"createCallLink\", new RateLimiterConfig(100, Duration.ofMinutes(15), false)),\n    INBOUND_MESSAGE_BYTES(\"inboundMessageBytes\", new RateLimiterConfig(128 * 1024 * 1024, Duration.ofNanos(500_000), true)),\n    EXTERNAL_SERVICE_CREDENTIALS(\"externalServiceCredentials\", new RateLimiterConfig(100, Duration.ofMinutes(15), false)),\n    KEY_TRANSPARENCY_DISTINGUISHED_PER_IP(\"keyTransparencyDistinguished\", new RateLimiterConfig(100, Duration.ofSeconds(15), true)),\n    KEY_TRANSPARENCY_SEARCH_PER_IP(\"keyTransparencySearch\", new RateLimiterConfig(100, Duration.ofSeconds(15), true)),\n    KEY_TRANSPARENCY_MONITOR_PER_IP(\"keyTransparencyMonitor\", new RateLimiterConfig(100, Duration.ofSeconds(15), true)),\n    WAIT_FOR_LINKED_DEVICE(\"waitForLinkedDevice\", new RateLimiterConfig(10, Duration.ofSeconds(30), false)),\n    UPLOAD_TRANSFER_ARCHIVE(\"uploadTransferArchive\", new RateLimiterConfig(10, Duration.ofMinutes(1), false)),\n    WAIT_FOR_TRANSFER_ARCHIVE(\"waitForTransferArchive\", new RateLimiterConfig(10, Duration.ofSeconds(30), false)),\n    RECORD_DEVICE_TRANSFER_REQUEST(\"recordDeviceTransferRequest\", new RateLimiterConfig(10, Duration.ofMillis(100), true)),\n    WAIT_FOR_DEVICE_TRANSFER_REQUEST(\"waitForDeviceTransferRequest\", new RateLimiterConfig(10, Duration.ofMillis(100), true)),\n    DEVICE_CHECK_CHALLENGE(\"deviceCheckChallenge\", new RateLimiterConfig(10, Duration.ofMinutes(1), false)),\n    SUBMIT_CALL_QUALITY_SURVEY(\"submitCallQualitySurvey\", new RateLimiterConfig(100, Duration.ofMinutes(1), true))\n    ;\n\n    private final String id;\n\n    private final RateLimiterConfig defaultConfig;\n\n    For(final String id, final RateLimiterConfig defaultConfig) {\n      this.id = id;\n      this.defaultConfig = defaultConfig;\n    }\n\n    public String id() {\n      return id;\n    }\n\n    public RateLimiterConfig defaultConfig() {\n      return defaultConfig;\n    }\n  }\n\n  public static RateLimiters create(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final ScheduledExecutorService retryExecutor) {\n    return new RateLimiters(\n        dynamicConfigurationManager, defaultScript(cacheCluster), cacheCluster, retryExecutor, Clock.systemUTC());\n  }\n\n  @VisibleForTesting\n  RateLimiters(\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final ClusterLuaScript validateScript,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final ScheduledExecutorService retryExecutor,\n      final Clock clock) {\n    super(For.values(), dynamicConfigurationManager, validateScript, cacheCluster, retryExecutor, clock);\n  }\n\n  public RateLimiter getAllocateDeviceLimiter() {\n    return forDescriptor(For.ALLOCATE_DEVICE);\n  }\n\n  public RateLimiter getVerifyDeviceLimiter() {\n    return forDescriptor(For.VERIFY_DEVICE);\n  }\n\n  public RateLimiter getMessagesLimiter() {\n    return forDescriptor(For.MESSAGES);\n  }\n\n  public RateLimiter getPreKeysLimiter() {\n    return forDescriptor(For.PRE_KEYS);\n  }\n\n  public RateLimiter getAttachmentLimiter() {\n    return forDescriptor(For.ATTACHMENT);\n  }\n\n  public RateLimiter getPinLimiter() {\n    return forDescriptor(For.PIN);\n  }\n\n  public RateLimiter getProfileLimiter() {\n    return forDescriptor(For.PROFILE);\n  }\n\n  public RateLimiter getStickerPackLimiter() {\n    return forDescriptor(For.STICKER_PACK);\n  }\n\n  public RateLimiter getUsernameLookupLimiter() {\n    return forDescriptor(For.USERNAME_LOOKUP);\n  }\n\n  public RateLimiter getUsernameLinkLookupLimiter() {\n    return forDescriptor(For.USERNAME_LINK_LOOKUP_PER_IP);\n  }\n\n  public RateLimiter getUsernameLinkOperationLimiter() {\n    return forDescriptor(For.USERNAME_LINK_OPERATION);\n  }\n\n  public RateLimiter getUsernameSetLimiter() {\n    return forDescriptor(For.USERNAME_SET);\n  }\n\n  public RateLimiter getUsernameReserveLimiter() {\n    return forDescriptor(For.USERNAME_RESERVE);\n  }\n\n  public RateLimiter getCheckAccountExistenceLimiter() {\n    return forDescriptor(For.CHECK_ACCOUNT_EXISTENCE);\n  }\n\n  public RateLimiter getRegistrationLimiter() {\n    return forDescriptor(For.REGISTRATION);\n  }\n\n  public RateLimiter getRateLimitResetLimiter() {\n    return forDescriptor(For.RATE_LIMIT_RESET);\n  }\n\n  public RateLimiter getCaptchaChallengeAttemptLimiter() {\n    return forDescriptor(For.CAPTCHA_CHALLENGE_ATTEMPT);\n  }\n\n  public RateLimiter getCaptchaChallengeSuccessLimiter() {\n    return forDescriptor(For.CAPTCHA_CHALLENGE_SUCCESS);\n  }\n\n  public RateLimiter getPushChallengeAttemptLimiter() {\n    return forDescriptor(For.PUSH_CHALLENGE_ATTEMPT);\n  }\n\n  public RateLimiter getPushChallengeSuccessLimiter() {\n    return forDescriptor(For.PUSH_CHALLENGE_SUCCESS);\n  }\n\n  public RateLimiter getVerificationPushChallengeLimiter() {\n    return forDescriptor(For.VERIFICATION_PUSH_CHALLENGE);\n  }\n\n  public RateLimiter getVerificationCaptchaLimiter() {\n    return forDescriptor(For.VERIFICATION_CAPTCHA);\n  }\n\n  public RateLimiter getCreateCallLinkLimiter() {\n    return forDescriptor(For.CREATE_CALL_LINK);\n  }\n\n  public RateLimiter getCallEndpointLimiter() {\n    return forDescriptor(For.GET_CALLING_RELAYS);\n  }\n\n  public RateLimiter getInboundMessageBytes() {\n    return forDescriptor(For.INBOUND_MESSAGE_BYTES);\n  }\n\n  public RateLimiter getStoriesLimiter() {\n    return forDescriptor(For.STORIES);\n  }\n\n  public RateLimiter getWaitForLinkedDeviceLimiter() {\n    return forDescriptor(For.WAIT_FOR_LINKED_DEVICE);\n  }\n\n  public RateLimiter getUploadTransferArchiveLimiter() {\n    return forDescriptor(For.UPLOAD_TRANSFER_ARCHIVE);\n  }\n\n  public RateLimiter getWaitForTransferArchiveLimiter() {\n    return forDescriptor(For.WAIT_FOR_TRANSFER_ARCHIVE);\n  }\n\n  public RateLimiter getKeyTransparencySearchLimiter() {\n    return forDescriptor(For.KEY_TRANSPARENCY_SEARCH_PER_IP);\n  }\n\n  public RateLimiter getKeyTransparencyDistinguishedLimiter() {\n    return forDescriptor(For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP);\n  }\n\n  public RateLimiter getKeyTransparencyMonitorLimiter() {\n    return forDescriptor(For.KEY_TRANSPARENCY_MONITOR_PER_IP);\n  }\n\n  public RateLimiter getSubmitCallQualitySurveyLimiter() {\n    return forDescriptor(For.SUBMIT_CALL_QUALITY_SURVEY);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/limits/RedisMessageDeliveryLoopMonitor.java",
    "content": "package org.whispersystems.textsecuregcm.limits;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\n\npublic class RedisMessageDeliveryLoopMonitor implements MessageDeliveryLoopMonitor {\n\n  private final ClusterLuaScript getDeliveryAttemptsScript;\n\n  private static final Duration DELIVERY_ATTEMPTS_COUNTER_TTL = Duration.ofHours(1);\n  private static final int DELIVERY_LOOP_THRESHOLD = 5;\n\n  private static final Logger logger = LoggerFactory.getLogger(MessageDeliveryLoopMonitor.class);\n\n  public RedisMessageDeliveryLoopMonitor(final FaultTolerantRedisClusterClient rateLimitCluster) {\n    try {\n      getDeliveryAttemptsScript =\n          ClusterLuaScript.fromResource(rateLimitCluster, \"lua/get_delivery_attempt_count.lua\", ScriptOutputType.INTEGER);\n    } catch (final IOException e) {\n      throw new UncheckedIOException(\"Failed to load 'get delivery attempt count' script\", e);\n    }\n  }\n\n  /**\n   * Records an attempt to deliver a message with the given GUID to the given account/device pair and returns the number\n   * of consecutive attempts to deliver the same message and logs a warning if the message appears to be in a delivery\n   * loop. This method is intended to detect cases where a message remains at the head of a device's queue after\n   * repeated attempts to deliver the message, and so the given message GUID should be the first message of a \"page\"\n   * sent to clients.\n   *\n   * @param accountIdentifier the identifier of the destination account\n   * @param deviceId the destination device's ID within the given account\n   * @param messageGuid the GUID of the message\n   * @param userAgent the User-Agent header supplied by the caller\n   * @param context a human-readable string identifying the mechanism of message delivery (e.g. \"rest\" or \"websocket\")\n   */\n  public void recordDeliveryAttempt(final UUID accountIdentifier,\n      final byte deviceId,\n      final UUID messageGuid,\n      final String userAgent,\n      final String context) {\n\n    incrementDeliveryAttemptCount(accountIdentifier, deviceId, messageGuid)\n        .thenAccept(deliveryAttemptCount -> {\n              if (deliveryAttemptCount == DELIVERY_LOOP_THRESHOLD) {\n                logger.warn(\"Detected loop delivering message {} via {} to {}:{} ({})\",\n                    messageGuid, context, accountIdentifier, deviceId, userAgent);\n              }\n            });\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Long> incrementDeliveryAttemptCount(final UUID accountIdentifier, final byte deviceId, final UUID messageGuid) {\n    final String firstMessageGuidKey = \"firstMessageGuid::{\" + accountIdentifier + \":\" + deviceId + \"}\";\n    final String deliveryAttemptsKey = \"firstMessageDeliveryAttempts::{\" + accountIdentifier + \":\" + deviceId + \"}\";\n\n    return getDeliveryAttemptsScript.executeAsync(\n        List.of(firstMessageGuidKey, deliveryAttemptsKey),\n        List.of(messageGuid.toString(), String.valueOf(DELIVERY_ATTEMPTS_COUNTER_TTL.toSeconds())))\n        .thenApply(result -> (long) result);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/BackupExceptionMapper.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport io.dropwizard.jersey.errors.ErrorMessage;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport org.whispersystems.textsecuregcm.backup.BackupBadReceiptException;\nimport org.whispersystems.textsecuregcm.backup.BackupException;\nimport org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;\nimport org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;\nimport org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;\nimport org.whispersystems.textsecuregcm.backup.BackupNotFoundException;\nimport org.whispersystems.textsecuregcm.backup.BackupPermissionException;\nimport org.whispersystems.textsecuregcm.backup.BackupWrongCredentialTypeException;\n\npublic class BackupExceptionMapper implements ExceptionMapper<BackupException> {\n\n  @Override\n  public Response toResponse(final BackupException exception) {\n    final Response.Status status = (switch (exception) {\n      case BackupNotFoundException _ -> Response.Status.NOT_FOUND;\n      case BackupInvalidArgumentException _, BackupBadReceiptException _ -> Response.Status.BAD_REQUEST;\n      case BackupPermissionException _ -> Response.Status.FORBIDDEN;\n      case BackupMissingIdCommitmentException _ -> Response.Status.CONFLICT;\n      case BackupWrongCredentialTypeException _,\n           BackupFailedZkAuthenticationException _ -> Response.Status.UNAUTHORIZED;\n      default -> Response.Status.INTERNAL_SERVER_ERROR;\n    });\n\n    final WebApplicationException wae =\n        new WebApplicationException(exception.getMessage(), exception, Response.status(status).build());\n\n    return Response\n        .fromResponse(wae.getResponse())\n        .type(MediaType.APPLICATION_JSON_TYPE)\n        .entity(new ErrorMessage(wae.getResponse().getStatus(), wae.getLocalizedMessage())).build();\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/CompletionExceptionMapper.java",
    "content": "/*\n * Copyright 2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\nimport java.util.Optional;\nimport java.util.concurrent.CompletionException;\nimport org.glassfish.jersey.spi.ExceptionMappers;\n\n@Provider\npublic class CompletionExceptionMapper implements ExceptionMapper<CompletionException> {\n\n  @Context\n  private ExceptionMappers exceptionMappers;\n\n  @Override\n  public Response toResponse(final CompletionException exception) {\n    final Throwable cause = exception.getCause();\n\n    if (cause != null) {\n\n      final ExceptionMapper exceptionMapper = exceptionMappers.findMapping(cause);\n\n      // some exception mappers, like LoggingExceptionMapper, have side effects (e.g., logging)\n      // so we always build their response…\n      final Response exceptionMapperResponse = exceptionMapper.toResponse(cause);\n\n      final Optional<Response> webApplicationExceptionResponse;\n      if (cause instanceof WebApplicationException webApplicationException) {\n        webApplicationExceptionResponse = Optional.of(webApplicationException.getResponse());\n      } else {\n        webApplicationExceptionResponse = Optional.empty();\n      }\n\n      // …but if the exception was a WebApplicationException, and provides an entity, we want to keep it\n      return webApplicationExceptionResponse\n          .filter(Response::hasEntity)\n          .orElse(exceptionMapperResponse);\n    }\n\n    return Response.serverError().build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/DeviceLimitExceededExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\nimport org.whispersystems.textsecuregcm.controllers.DeviceLimitExceededException;\n\n@Provider\npublic class DeviceLimitExceededExceptionMapper implements ExceptionMapper<DeviceLimitExceededException> {\n  @Override\n  public Response toResponse(DeviceLimitExceededException exception) {\n    return Response.status(411)\n                   .entity(new DeviceLimitExceededDetails(exception.getCurrentDevices(),\n                                                          exception.getMaxDevices()))\n                   .build();\n  }\n\n  private static class DeviceLimitExceededDetails {\n    @JsonProperty\n    private int current;\n    @JsonProperty\n    private int max;\n\n    public DeviceLimitExceededDetails(int current, int max) {\n      this.current = current;\n      this.max     = max;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapper.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport io.dropwizard.jersey.errors.ErrorMessage;\nimport io.grpc.StatusRuntimeException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\n\n@Provider\npublic class GrpcStatusRuntimeExceptionMapper implements ExceptionMapper<StatusRuntimeException> {\n\n  @Override\n  public Response toResponse(final StatusRuntimeException exception) {\n    int httpCode = switch (exception.getStatus().getCode()) {\n      case OK -> 200;\n      case INVALID_ARGUMENT, FAILED_PRECONDITION, OUT_OF_RANGE -> 400;\n      case UNAUTHENTICATED -> 401;\n      case PERMISSION_DENIED -> 403;\n      case NOT_FOUND -> 404;\n      case ALREADY_EXISTS, ABORTED -> 409;\n      case CANCELLED -> 499;\n      case UNKNOWN, UNIMPLEMENTED, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE, DATA_LOSS -> 500;\n    };\n\n    return Response.status(httpCode)\n        .entity(new ErrorMessage(httpCode, exception.getMessage()))\n        .type(MediaType.APPLICATION_JSON_TYPE)\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/IOExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\nimport java.io.IOException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n@Provider\npublic class IOExceptionMapper implements ExceptionMapper<IOException> {\n\n  private final Logger logger = LoggerFactory.getLogger(IOExceptionMapper.class);\n\n  @Override\n  public Response toResponse(IOException e) {\n    if (!(e.getCause() instanceof java.util.concurrent.TimeoutException)) {\n      logger.warn(\"IOExceptionMapper\", e);\n    } else {\n      // Some TimeoutExceptions are because the connection is idle, but are only distinguishable using the exception\n      // message\n      final String message = e.getCause().getMessage();\n      final boolean idleTimeout =\n          message != null &&\n              // org.eclipse.jetty.io.IdleTimeout\n              (message.startsWith(\"Idle timeout expired\")\n                  // org.eclipse.jetty.http2.HTTP2Session\n                  || (message.startsWith(\"Idle timeout\") && message.endsWith(\"elapsed\")));\n      if (idleTimeout) {\n        return Response.status(Response.Status.REQUEST_TIMEOUT).build();\n      }\n    }\n\n    return Response.status(503).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/ImpossiblePhoneNumberExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException;\n\npublic class ImpossiblePhoneNumberExceptionMapper implements ExceptionMapper<ImpossiblePhoneNumberException> {\n\n  private static final Counter IMPOSSIBLE_NUMBER_COUNTER =\n      Metrics.counter(name(ImpossiblePhoneNumberExceptionMapper.class, \"impossibleNumbers\"));\n\n  @Override\n  public Response toResponse(final ImpossiblePhoneNumberException exception) {\n    IMPOSSIBLE_NUMBER_COUNTER.increment();\n\n    return Response.status(Response.Status.BAD_REQUEST).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/InvalidWebsocketAddressExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\nimport org.whispersystems.textsecuregcm.websocket.InvalidWebsocketAddressException;\n\n@Provider\npublic class InvalidWebsocketAddressExceptionMapper implements ExceptionMapper<InvalidWebsocketAddressException> {\n  @Override\n  public Response toResponse(InvalidWebsocketAddressException exception) {\n    return Response.status(Response.Status.BAD_REQUEST).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/JsonMappingExceptionMapper.java",
    "content": "package org.whispersystems.textsecuregcm.mappers;\n\nimport com.fasterxml.jackson.databind.JsonMappingException;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\n\npublic class JsonMappingExceptionMapper implements ExceptionMapper<JsonMappingException> {\n  @Override\n  public Response toResponse(final JsonMappingException exception) {\n    if (exception.getCause() instanceof java.util.concurrent.TimeoutException) {\n      return Response.status(Response.Status.REQUEST_TIMEOUT).build();\n    }\n    if (exception.getCause() instanceof org.eclipse.jetty.io.EofException\n        || exception.getMessage() != null && exception.getMessage().startsWith(\"Early EOF\")) {\n      // Some sort of timeout or broken connection\n      return Response.status(Response.Status.BAD_REQUEST).build();\n    }\n    return Response.status(422).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.core.Response.Status;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException;\n\npublic class NonNormalizedPhoneNumberExceptionMapper implements ExceptionMapper<NonNormalizedPhoneNumberException> {\n\n  private static final String NON_NORMALIZED_NUMBER_COUNTER_NAME =\n      name(NonNormalizedPhoneNumberExceptionMapper.class, \"nonNormalizedNumbers\");\n\n  @Override\n  public Response toResponse(final NonNormalizedPhoneNumberException exception) {\n    String countryCode;\n\n    try {\n      countryCode =\n          String.valueOf(PhoneNumberUtil.getInstance().parse(exception.getOriginalNumber(), null).getCountryCode());\n    } catch (final NumberParseException ignored) {\n      countryCode = \"unknown\";\n    }\n\n    Metrics.counter(NON_NORMALIZED_NUMBER_COUNTER_NAME, \"countryCode\", countryCode).increment();\n\n    return Response.status(Status.BAD_REQUEST)\n        .entity(new NonNormalizedPhoneNumberResponse(exception.getOriginalNumber(), exception.getNormalizedNumber()))\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/NonNormalizedPhoneNumberResponse.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\npublic class NonNormalizedPhoneNumberResponse {\n\n  private final String originalNumber;\n  private final String normalizedNumber;\n\n  @JsonCreator\n  NonNormalizedPhoneNumberResponse(@JsonProperty(\"originalNumber\") final String originalNumber,\n      @JsonProperty(\"normalizedNumber\") final String normalizedNumber) {\n\n    this.originalNumber = originalNumber;\n    this.normalizedNumber = normalizedNumber;\n  }\n\n  public String getOriginalNumber() {\n    return originalNumber;\n  }\n\n  public String getNormalizedNumber() {\n    return normalizedNumber;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/ObsoletePhoneNumberFormatExceptionMapper.java",
    "content": "package org.whispersystems.textsecuregcm.mappers;\n\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.ObsoletePhoneNumberFormatException;\n\npublic class ObsoletePhoneNumberFormatExceptionMapper implements ExceptionMapper<ObsoletePhoneNumberFormatException> {\n\n  private static final String COUNTER_NAME = MetricsUtil.name(ObsoletePhoneNumberFormatExceptionMapper.class, \"errors\");\n\n  @Override\n  public Response toResponse(final ObsoletePhoneNumberFormatException exception) {\n    Metrics.counter(COUNTER_NAME, \"regionCode\", exception.getRegionCode()).increment();\n    return Response.status(499).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/RateLimitExceededExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\n\n@Provider\npublic class RateLimitExceededExceptionMapper implements ExceptionMapper<RateLimitExceededException> {\n\n  private static final Logger logger = LoggerFactory.getLogger(RateLimitExceededExceptionMapper.class);\n\n  /**\n   * Convert a RateLimitExceededException to a 429 response\n   * with a Retry-After header.\n   *\n   * @param e A RateLimitExceededException potentially containing a recommended retry duration\n   * @return the response\n   */\n  @Override\n  public Response toResponse(RateLimitExceededException e) {\n    return e.getRetryDuration()\n        .filter(d -> {\n          if (d.isNegative()) {\n            logger.warn(\"Encountered a negative retry duration: {}, will not include a Retry-After header in response\",\n                d);\n          }\n          // only include non-negative durations in retry headers\n          return !d.isNegative();\n        })\n        .map(d -> Response.status(Response.Status.TOO_MANY_REQUESTS).header(\"Retry-After\", d.toSeconds()))\n        .orElseGet(() -> Response.status(Response.Status.TOO_MANY_REQUESTS)).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/RegistrationServiceSenderExceptionMapper.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;\n\npublic class RegistrationServiceSenderExceptionMapper implements ExceptionMapper<RegistrationServiceSenderException> {\n\n  public static int REMOTE_SERVICE_REJECTED_REQUEST_STATUS = 440;\n\n  @Override\n  public Response toResponse(final RegistrationServiceSenderException exception) {\n    return Response.status(REMOTE_SERVICE_REJECTED_REQUEST_STATUS)\n        .entity(new SendVerificationCodeFailureResponse(exception.getReason(), exception.isPermanent()))\n        .build();\n  }\n\n  @VisibleForTesting\n  public record SendVerificationCodeFailureResponse(RegistrationServiceSenderException.Reason reason,\n                                                    boolean permanentFailure) {\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/ServerRejectedExceptionMapper.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport org.whispersystems.textsecuregcm.controllers.ServerRejectedException;\n\npublic class ServerRejectedExceptionMapper implements ExceptionMapper<ServerRejectedException> {\n\n  @Override\n  public Response toResponse(final ServerRejectedException exception) {\n    return Response.status(508).build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/mappers/SubscriptionExceptionMapper.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.jersey.errors.ErrorMessage;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionException;\nimport org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidAmountException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;\n\npublic class SubscriptionExceptionMapper implements ExceptionMapper<SubscriptionException> {\n  @VisibleForTesting\n  public static final int PROCESSOR_ERROR_STATUS_CODE = 440;\n\n  public record ChargeFailureResponse(String processor, ChargeFailure chargeFailure) {}\n\n  @Override\n  public Response toResponse(final SubscriptionException exception) {\n\n    // Some exceptions have specific error body formats\n    if (exception instanceof SubscriptionInvalidAmountException e) {\n      return Response\n          .status(Response.Status.BAD_REQUEST)\n          .entity(Map.of(\"error\", e.getErrorCode()))\n          .type(MediaType.APPLICATION_JSON_TYPE)\n          .build();\n    }\n    if (exception instanceof SubscriptionProcessorException e) {\n      return Response.status(PROCESSOR_ERROR_STATUS_CODE)\n          .entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))\n          .type(MediaType.APPLICATION_JSON_TYPE)\n          .build();\n    }\n    if (exception instanceof SubscriptionChargeFailurePaymentRequiredException e) {\n      return Response\n          .status(Response.Status.PAYMENT_REQUIRED)\n          .entity(new ChargeFailureResponse(e.getProcessor().name(), e.getChargeFailure()))\n          .type(MediaType.APPLICATION_JSON_TYPE)\n          .build();\n    }\n\n    // Otherwise, we'll return a generic error message WebApplicationException, with a detailed error if one is provided\n    final Response.Status status = (switch (exception) {\n      case SubscriptionNotFoundException e -> Response.Status.NOT_FOUND;\n      case SubscriptionForbiddenException e -> Response.Status.FORBIDDEN;\n      case SubscriptionInvalidArgumentsException e -> Response.Status.BAD_REQUEST;\n      case SubscriptionProcessorConflictException e -> Response.Status.CONFLICT;\n      case SubscriptionPaymentRequiredException e -> Response.Status.PAYMENT_REQUIRED;\n      default -> Response.Status.INTERNAL_SERVER_ERROR;\n    });\n\n    // If the SubscriptionException came with suitable error message, include that in the response body. Otherwise,\n    // don't provide any message to the WebApplicationException constructor so the response includes the default\n    // HTTP error message for the status.\n    final WebApplicationException wae = exception.errorDetail()\n        .map(errorMessage -> new WebApplicationException(errorMessage, exception, Response.status(status).build()))\n        .orElseGet(() -> new WebApplicationException(exception, Response.status(status).build()));\n\n    return Response\n        .fromResponse(wae.getResponse())\n        .type(MediaType.APPLICATION_JSON_TYPE)\n        .entity(new ErrorMessage(wae.getResponse().getStatus(), wae.getLocalizedMessage())).build();\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/ApplicationShutdownMonitor.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Gauge;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport org.eclipse.jetty.util.component.AbstractLifeCycle;\nimport org.eclipse.jetty.util.thread.ShutdownThread;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * A managed monitor that reports whether the application is shutting down as a metric. That metric can then be used in\n * conjunction with other indicators to conditionally fire or suppress alerts.\n */\npublic class ApplicationShutdownMonitor extends AbstractLifeCycle {\n\n  private final AtomicBoolean shuttingDown = new AtomicBoolean(false);\n  private final Logger logger = LoggerFactory.getLogger(ApplicationShutdownMonitor.class);\n\n  public ApplicationShutdownMonitor(final MeterRegistry meterRegistry) {\n    // without a strong reference to the gauge’s value supplier, shutdown garbage collection\n    // might prevent the final value from being reported\n    Gauge.builder(name(getClass(), \"shuttingDown\"), () -> shuttingDown.get() ? 1 : 0)\n        .strongReference(true)\n        .register(meterRegistry);\n  }\n\n  public void register() {\n    // Force this component to get shut down before Dropwizard's\n    // DelayedShutdownHandler, which initiates the delayed-shutdown process\n    // without an additional chance for us to hook it\n    logger.info(\"registering shutdown monitor\");\n    try {\n      start();                    // jetty won't stop an unstarted lifecycle\n      ShutdownThread.register(0, this);\n    } catch (Exception e) {\n      logger.error(\"failed to start application shutdown monitor\", e);\n    }\n  }\n\n  @Override\n  public void doStop() {\n    logger.info(\"setting shutdown flag\");\n    shuttingDown.set(true);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/BackupMetrics.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.backup.CopyResult;\nimport java.util.Optional;\n\npublic class BackupMetrics {\n\n  private final static String COPY_MEDIA_COUNTER_NAME = name(BackupMetrics.class, \"copyMedia\");\n  private final static String GET_BACKUP_CREDENTIALS_NAME = name(BackupMetrics.class, \"getBackupCredentials\");\n  private final static String MESSAGE_BACKUP_SIZE_NAME = name(BackupMetrics.class, \"messageBackupSize\");\n\n\n  private MeterRegistry registry;\n\n  public BackupMetrics() {\n    this(Metrics.globalRegistry);\n  }\n\n  @VisibleForTesting\n  BackupMetrics(MeterRegistry registry) {\n    this.registry = registry;\n  }\n\n  public void updateCopyCounter(final CopyResult copyResult, final Tag platformTag) {\n    registry.counter(COPY_MEDIA_COUNTER_NAME, Tags.of(\n            platformTag,\n            Tag.of(\"outcome\", copyResult.outcome().name().toLowerCase())))\n        .increment();\n  }\n\n  public void updateGetCredentialCounter(final Tag platformTag, BackupCredentialType credentialType,\n      final int numCredentials) {\n    Metrics.counter(GET_BACKUP_CREDENTIALS_NAME, Tags.of(\n            platformTag,\n            Tag.of(\"num\", Integer.toString(numCredentials)),\n            Tag.of(\"type\", credentialType.name().toLowerCase())))\n        .increment();\n  }\n\n  public void updateMessageBackupSizeDistribution(\n      AuthenticatedBackupUser authenticatedBackupUser,\n      final boolean oversize,\n      final Optional<Long> backupLength) {\n    DistributionSummary.builder(MESSAGE_BACKUP_SIZE_NAME)\n        .tags(Tags.of(\n            UserAgentTagUtil.getPlatformTag(authenticatedBackupUser.userAgent()),\n            Tag.of(\"tier\", authenticatedBackupUser.backupLevel().name().toLowerCase()),\n            Tag.of(\"oversize\", Boolean.toString(oversize)),\n            Tag.of(\"hasBackupLength\", Boolean.toString(backupLength.isPresent()))))\n        .register(Metrics.globalRegistry)\n        .record(backupLength.orElse(0L));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/CallQualityInvalidArgumentsException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport javax.annotation.Nullable;\nimport java.util.Optional;\n\npublic class CallQualityInvalidArgumentsException extends Exception {\n  private final @Nullable String field;\n\n  public CallQualityInvalidArgumentsException(final String message) {\n    this(message, null);\n  }\n\n  public CallQualityInvalidArgumentsException(final String message, final String field) {\n    super(message);\n    this.field = field;\n  }\n\n  public Optional<String> getField() {\n    return Optional.ofNullable(field);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/CallQualitySurveyManager.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport com.google.cloud.pubsub.v1.PublisherInterface;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.pubsub.v1.PubsubMessage;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.util.Locale;\nimport java.util.UUID;\nimport java.util.concurrent.Executor;\nimport java.util.function.Supplier;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.calling.survey.CallQualitySurveyResponsePubSubMessage;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.util.GoogleApiUtil;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\npublic class CallQualitySurveyManager {\n\n  private final Supplier<AsnInfoProvider> asnInfoProviderSupplier;\n  private final PublisherInterface pubSubPublisher;\n  private final Clock clock;\n  private final Executor pubSubCallbackExecutor;\n\n  private final String PUB_SUB_MESSAGE_COUNTER_NAME = MetricsUtil.name(CallQualitySurveyManager.class, \"pubSubMessage\");\n\n  private static final Logger logger = LoggerFactory.getLogger(CallQualitySurveyManager.class);\n\n  public CallQualitySurveyManager(final Supplier<AsnInfoProvider> asnInfoProviderSupplier,\n      final PublisherInterface pubSubPublisher,\n      final Clock clock,\n      final Executor pubSubCallbackExecutor) {\n\n    this.asnInfoProviderSupplier = asnInfoProviderSupplier;\n    this.pubSubPublisher = pubSubPublisher;\n    this.clock = clock;\n    this.pubSubCallbackExecutor = pubSubCallbackExecutor;\n  }\n\n  public void submitCallQualitySurvey(final SubmitCallQualitySurveyRequest submitCallQualitySurveyRequest,\n      final String remoteAddress,\n      final String userAgentString) throws CallQualityInvalidArgumentsException {\n\n    validateRequest(submitCallQualitySurveyRequest);\n\n    final CallQualitySurveyResponsePubSubMessage.Builder pubSubMessageBuilder =\n        CallQualitySurveyResponsePubSubMessage.newBuilder()\n            .setResponseId(UUID.randomUUID().toString())\n            .setSubmissionTimestamp(clock.millis() * 1000)\n            .setUserSatisfied(submitCallQualitySurveyRequest.getUserSatisfied())\n            // We receive timestamps as milliseconds since the epoch, but the backing data store wants microseconds\n            .setStartTimestamp(submitCallQualitySurveyRequest.getStartTimestamp() * 1_000)\n            .setEndTimestamp(submitCallQualitySurveyRequest.getEndTimestamp() * 1_000)\n            .setCallType(submitCallQualitySurveyRequest.getCallType())\n            .setSuccess(submitCallQualitySurveyRequest.getSuccess())\n            .setCallEndReason(submitCallQualitySurveyRequest.getCallEndReason());\n\n    try {\n      final UserAgent userAgent = UserAgentUtil.parseUserAgentString(userAgentString);\n\n      pubSubMessageBuilder.setClientPlatform(userAgent.platform().name().toLowerCase(Locale.ROOT));\n      pubSubMessageBuilder.setClientVersion(userAgent.version().toString());\n\n      if (StringUtils.isNotBlank(userAgent.additionalSpecifiers())) {\n        pubSubMessageBuilder.setClientUaAdditionalSpecifiers(userAgent.additionalSpecifiers());\n      }\n    } catch (final UnrecognizedUserAgentException _) {\n    }\n\n    asnInfoProviderSupplier.get().lookup(remoteAddress)\n        .ifPresent(asnInfo -> pubSubMessageBuilder.setAsnRegion(asnInfo.regionCode()));\n\n    pubSubMessageBuilder.addAllCallQualityIssues(submitCallQualitySurveyRequest.getCallQualityIssuesList());\n\n    if (submitCallQualitySurveyRequest.hasAdditionalIssuesDescription()) {\n      pubSubMessageBuilder.setAdditionalIssuesDescription(submitCallQualitySurveyRequest.getAdditionalIssuesDescription());\n    }\n\n    if (submitCallQualitySurveyRequest.hasDebugLogUrl()) {\n      pubSubMessageBuilder.setDebugLogUrl(submitCallQualitySurveyRequest.getDebugLogUrl());\n    }\n\n    if (submitCallQualitySurveyRequest.hasConnectionRttMedian()) {\n      pubSubMessageBuilder.setConnectionRttMedian(submitCallQualitySurveyRequest.getConnectionRttMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasAudioRttMedian()) {\n      pubSubMessageBuilder.setAudioRttMedian(submitCallQualitySurveyRequest.getAudioRttMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasVideoRttMedian()) {\n      pubSubMessageBuilder.setVideoRttMedian(submitCallQualitySurveyRequest.getVideoRttMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasAudioRecvJitterMedian()) {\n      pubSubMessageBuilder.setAudioRecvJitterMedian(submitCallQualitySurveyRequest.getAudioRecvJitterMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasVideoRecvJitterMedian()) {\n      pubSubMessageBuilder.setVideoRecvJitterMedian(submitCallQualitySurveyRequest.getVideoRecvJitterMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasAudioSendJitterMedian()) {\n      pubSubMessageBuilder.setAudioSendJitterMedian(submitCallQualitySurveyRequest.getAudioSendJitterMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasVideoSendJitterMedian()) {\n      pubSubMessageBuilder.setVideoSendJitterMedian(submitCallQualitySurveyRequest.getVideoSendJitterMedian());\n    }\n\n    if (submitCallQualitySurveyRequest.hasAudioRecvPacketLossFraction()) {\n      pubSubMessageBuilder.setAudioRecvPacketLossFraction(submitCallQualitySurveyRequest.getAudioRecvPacketLossFraction());\n    }\n\n    if (submitCallQualitySurveyRequest.hasVideoRecvPacketLossFraction()) {\n      pubSubMessageBuilder.setVideoRecvPacketLossFraction(submitCallQualitySurveyRequest.getVideoRecvPacketLossFraction());\n    }\n\n    if (submitCallQualitySurveyRequest.hasAudioSendPacketLossFraction()) {\n      pubSubMessageBuilder.setAudioSendPacketLossFraction(submitCallQualitySurveyRequest.getAudioSendPacketLossFraction());\n    }\n\n    if (submitCallQualitySurveyRequest.hasVideoSendPacketLossFraction()) {\n      pubSubMessageBuilder.setVideoSendPacketLossFraction(submitCallQualitySurveyRequest.getVideoSendPacketLossFraction());\n    }\n\n    if (submitCallQualitySurveyRequest.hasCallTelemetry()) {\n      pubSubMessageBuilder.setCallTelemetry(submitCallQualitySurveyRequest.getCallTelemetry());\n    }\n\n    GoogleApiUtil.toCompletableFuture(pubSubPublisher.publish(PubsubMessage.newBuilder()\n            .setData(pubSubMessageBuilder.build().toByteString())\n            .build()), pubSubCallbackExecutor)\n        .whenComplete((_, throwable) -> {\n          if (throwable != null) {\n            logger.warn(\"Failed to publish call quality survey pub/sub message\", throwable);\n          }\n\n          Metrics.counter(PUB_SUB_MESSAGE_COUNTER_NAME, \"success\", String.valueOf(throwable == null))\n              .increment();\n        });\n  }\n\n  @VisibleForTesting\n  static void validateRequest(final SubmitCallQualitySurveyRequest request) throws CallQualityInvalidArgumentsException {\n    if (request.getStartTimestamp() == 0) {\n      throw new CallQualityInvalidArgumentsException(\"Start timestamp not specified\", \"startTimestamp\");\n    }\n\n    if (request.getEndTimestamp() == 0) {\n      throw new CallQualityInvalidArgumentsException(\"End timestamp not specified\", \"endTimestamp\");\n    }\n\n    if (StringUtils.isBlank(request.getCallType())) {\n      throw new CallQualityInvalidArgumentsException(\"Call type not specified\", \"callType\");\n    }\n\n    if (StringUtils.isBlank(request.getCallEndReason())) {\n      throw new CallQualityInvalidArgumentsException(\"Call end reason not specified\", \"callEndReason\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/DevicePlatformUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport java.util.Optional;\n\npublic class DevicePlatformUtil {\n\n  private DevicePlatformUtil() {\n  }\n\n  /**\n   * Returns the most likely client platform for a device.\n   *\n   * @param device the device for which to find a client platform\n   *\n   * @return the most likely client platform for the given device or empty if no likely platform could be determined\n   */\n  public static Optional<ClientPlatform> getDevicePlatform(final Device device) {\n    final Optional<ClientPlatform> clientPlatform;\n\n    if (StringUtils.isNotBlank(device.getGcmId())) {\n      clientPlatform = Optional.of(ClientPlatform.ANDROID);\n    } else if (StringUtils.isNotBlank(device.getApnId())) {\n      clientPlatform = Optional.of(ClientPlatform.IOS);\n    } else {\n      clientPlatform = Optional.empty();\n    }\n\n    return clientPlatform.or(() -> Optional.ofNullable(\n        switch (device.getUserAgent()) {\n          case \"OWA\" -> ClientPlatform.ANDROID;\n          case \"OWI\", \"OWP\" -> ClientPlatform.IOS;\n          case \"OWD\" -> ClientPlatform.DESKTOP;\n          case null, default -> null;\n        }));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/GarbageCollectionGauges.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport java.lang.management.GarbageCollectorMXBean;\nimport java.lang.management.ManagementFactory;\nimport java.util.List;\n\npublic class GarbageCollectionGauges {\n\n  private GarbageCollectionGauges() {\n  }\n\n  public static void registerMetrics() {\n    for (final GarbageCollectorMXBean garbageCollectorMXBean : ManagementFactory.getGarbageCollectorMXBeans()) {\n      final List<Tag> tags = List.of(Tag.of(\"memoryManagerName\", garbageCollectorMXBean.getName()));\n\n      Metrics.gauge(name(GarbageCollectionGauges.class, \"collectionCount\"), tags, garbageCollectorMXBean,\n          GarbageCollectorMXBean::getCollectionCount);\n      Metrics.gauge(name(GarbageCollectionGauges.class, \"collectionTime\"), tags, garbageCollectorMXBean,\n          GarbageCollectorMXBean::getCollectionTime);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/LogstashTcpSocketAppenderFactory.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport ch.qos.logback.classic.LoggerContext;\nimport ch.qos.logback.classic.PatternLayout;\nimport ch.qos.logback.classic.spi.ILoggingEvent;\nimport ch.qos.logback.core.Appender;\nimport ch.qos.logback.core.encoder.LayoutWrappingEncoder;\nimport ch.qos.logback.core.helpers.NOPAppender;\nimport ch.qos.logback.core.net.ssl.SSLConfiguration;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport com.fasterxml.jackson.databind.node.JsonNodeFactory;\nimport com.fasterxml.jackson.databind.node.ObjectNode;\nimport com.fasterxml.jackson.databind.node.TextNode;\nimport io.dropwizard.logging.common.AbstractAppenderFactory;\nimport io.dropwizard.logging.common.async.AsyncAppenderFactory;\nimport io.dropwizard.logging.common.filter.LevelFilterFactory;\nimport io.dropwizard.logging.common.layout.LayoutFactory;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.validation.constraints.NotNull;\nimport java.time.Duration;\nimport java.util.Optional;\nimport net.logstash.logback.appender.LogstashTcpSocketAppender;\nimport net.logstash.logback.encoder.LogstashEncoder;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport org.whispersystems.textsecuregcm.util.HostnameUtil;\n\n@JsonTypeName(\"logstashtcpsocket\")\npublic class LogstashTcpSocketAppenderFactory extends AbstractAppenderFactory<ILoggingEvent> {\n\n  @JsonProperty\n  private String destination;\n\n  @JsonProperty\n  private Duration keepAlive = Duration.ofSeconds(20);\n\n  @JsonProperty\n  @NotNull\n  private SecretString apiKey;\n\n  @JsonProperty\n  private String environment;\n\n  @JsonProperty\n  @NotEmpty\n  public String getDestination() {\n    return destination;\n  }\n\n  @JsonProperty\n  public Duration getKeepAlive() {\n    return keepAlive;\n  }\n\n  @JsonProperty\n  public SecretString getApiKey() {\n    return apiKey;\n  }\n\n  @JsonProperty\n  @NotEmpty\n  public String getEnvironment() {\n    return environment;\n  }\n\n  @Override\n  public Appender<ILoggingEvent> build(\n      final LoggerContext context,\n      final String applicationName,\n      final LayoutFactory<ILoggingEvent> layoutFactory,\n      final LevelFilterFactory<ILoggingEvent> levelFilterFactory,\n      final AsyncAppenderFactory<ILoggingEvent> asyncAppenderFactory) {\n\n    final boolean disableLogstashTcpSocketAppender = Optional.ofNullable(\n            System.getenv(\"SIGNAL_DISABLE_LOGSTASH_TCP_SOCKET_APPENDER\"))\n        .isPresent();\n\n    if (disableLogstashTcpSocketAppender) {\n      return new NOPAppender<>();\n    }\n\n    final SSLConfiguration sslConfiguration = new SSLConfiguration();\n    final LogstashTcpSocketAppender appender = new LogstashTcpSocketAppender();\n    appender.setName(\"logstashtcpsocket-appender\");\n    appender.setContext(context);\n    appender.setSsl(sslConfiguration);\n    appender.addDestination(destination);\n    appender.setKeepAliveDuration(new ch.qos.logback.core.util.Duration(keepAlive.toMillis()));\n\n    final LogstashEncoder encoder = new LogstashEncoder();\n    final ObjectNode customFieldsNode = new ObjectNode(JsonNodeFactory.instance);\n    customFieldsNode.set(\"host\", TextNode.valueOf(HostnameUtil.getLocalHostname()));\n    customFieldsNode.set(\"service\", TextNode.valueOf(\"chat\"));\n\n    encoder.setCustomFields(customFieldsNode.toString());\n    final LayoutWrappingEncoder<ILoggingEvent> prefix = new LayoutWrappingEncoder<>();\n    final PatternLayout layout = new PatternLayout();\n    layout.setPattern(String.format(\"%s \", apiKey.value()));\n    layout.setContext(context);\n    prefix.setLayout(layout);\n    encoder.setPrefix(prefix);\n    appender.setEncoder(encoder);\n\n    appender.addFilter(levelFilterFactory.build(threshold));\n    getFilterFactories().forEach(f -> appender.addFilter(f.build()));\n    appender.start();\n\n    return wrapAsync(appender, asyncAppenderFactory);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MessageMetrics.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Timer;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\n\npublic final class MessageMetrics {\n\n  private static final Logger logger = LoggerFactory.getLogger(MessageMetrics.class);\n\n  static final String MISMATCHED_ACCOUNT_ENVELOPE_UUID_COUNTER_NAME = name(MessageMetrics.class,\n      \"mismatchedAccountEnvelopeUuid\");\n\n  public static final String DELIVERY_LATENCY_TIMER_NAME = name(MessageMetrics.class, \"deliveryLatency\");\n  private final MeterRegistry metricRegistry;\n\n  @VisibleForTesting\n  MessageMetrics(final MeterRegistry metricRegistry) {\n    this.metricRegistry = metricRegistry;\n  }\n\n  public MessageMetrics() {\n    this(Metrics.globalRegistry);\n  }\n\n  public void measureAccountOutgoingMessageUuidMismatches(final Account account,\n      final OutgoingMessageEntity outgoingMessage) {\n    measureAccountDestinationUuidMismatches(account, outgoingMessage.destinationUuid());\n  }\n\n  public void measureAccountEnvelopeUuidMismatches(final Account account,\n      final MessageProtos.Envelope envelope) {\n    if (envelope.hasDestinationServiceId()) {\n      try {\n        measureAccountDestinationUuidMismatches(account, ServiceIdentifier.valueOf(envelope.getDestinationServiceId()));\n      } catch (final IllegalArgumentException ignored) {\n        logger.warn(\"Envelope had invalid destination UUID: {}\", envelope.getDestinationServiceId());\n      }\n    }\n  }\n\n  private void measureAccountDestinationUuidMismatches(final Account account, final ServiceIdentifier destinationIdentifier) {\n    if (!account.isIdentifiedBy(destinationIdentifier)) {\n      // In all cases, this represents a mismatch between the account’s current PNI and its PNI when the message was\n      // sent. This is an expected case, but if this metric changes significantly, it could indicate an issue to\n      // investigate.\n      metricRegistry.counter(MISMATCHED_ACCOUNT_ENVELOPE_UUID_COUNTER_NAME).increment();\n    }\n  }\n\n  public void measureOutgoingMessageLatency(final long serverTimestamp,\n      final String channel,\n      final boolean isPrimaryDevice,\n      final boolean isUrgent,\n      final boolean isEphemeral,\n      final String userAgent,\n      final ClientReleaseManager clientReleaseManager) {\n\n    final List<Tag> tags = new ArrayList<>();\n    tags.add(UserAgentTagUtil.getPlatformTag(userAgent));\n    tags.add(Tag.of(\"channel\", channel));\n    tags.add(Tag.of(\"isPrimary\", String.valueOf(isPrimaryDevice)));\n    tags.add(Tag.of(\"isUrgent\", String.valueOf(isUrgent)));\n    tags.add(Tag.of(\"isEphemeral\", String.valueOf(isEphemeral)));\n\n    UserAgentTagUtil.getClientVersionTag(userAgent, clientReleaseManager).ifPresent(tags::add);\n\n    Timer.builder(DELIVERY_LATENCY_TIMER_NAME)\n        .tags(tags)\n        .register(metricRegistry)\n        .record(Duration.between(Instant.ofEpochMilli(serverTimestamp), Instant.now()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsApplicationEventListener.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport org.glassfish.jersey.server.monitoring.ApplicationEvent;\nimport org.glassfish.jersey.server.monitoring.ApplicationEventListener;\nimport org.glassfish.jersey.server.monitoring.RequestEvent;\nimport org.glassfish.jersey.server.monitoring.RequestEventListener;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\n\n/**\n * Delegates request events to a listener that captures and reports request-level metrics.\n *\n * @see MetricsHttpChannelListener\n * @see MetricsRequestEventListener\n */\npublic class MetricsApplicationEventListener implements ApplicationEventListener {\n\n  private final MetricsRequestEventListener metricsRequestEventListener;\n\n  public MetricsApplicationEventListener(final TrafficSource trafficSource, final ClientReleaseManager clientReleaseManager) {\n    if (trafficSource == TrafficSource.HTTP) {\n      throw new IllegalArgumentException(\"Use \" + MetricsHttpChannelListener.class.getName() + \" for HTTP traffic\");\n    }\n    this.metricsRequestEventListener = new MetricsRequestEventListener(trafficSource, clientReleaseManager);\n  }\n\n  @Override\n  public void onEvent(final ApplicationEvent event) {\n  }\n\n  @Override\n  public RequestEventListener onRequest(final RequestEvent requestEvent) {\n    return metricsRequestEventListener;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsHttpChannelListener.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerResponseContext;\nimport jakarta.ws.rs.container.ContainerResponseFilter;\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport javax.annotation.Nullable;\nimport org.eclipse.jetty.server.Connector;\nimport org.eclipse.jetty.server.HttpChannel;\nimport org.eclipse.jetty.server.Request;\nimport org.eclipse.jetty.util.component.Container;\nimport org.eclipse.jetty.util.component.LifeCycle;\nimport org.glassfish.jersey.server.ExtendedUriInfo;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.util.logging.UriInfoUtil;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n/**\n * Gathers and reports HTTP request metrics at the Jetty container level, which sits above Jersey. In order to get\n * templated Jersey request paths, it implements {@link jakarta.ws.rs.container.ContainerResponseFilter}, in order to give\n * itself access to the template. It is limited to {@link TrafficSource#HTTP} requests.\n * <p>\n * It implements {@link LifeCycle.Listener} without overriding methods, so that it can be an event listener that\n * Dropwizard will attach to the container&mdash;the {@link Container.Listener} implementation is where it attaches\n * itself to any {@link Connector}s.\n *\n * @see MetricsRequestEventListener\n */\npublic class MetricsHttpChannelListener implements HttpChannel.Listener, Container.Listener, LifeCycle.Listener,\n    ContainerResponseFilter {\n\n  private static final Logger logger = LoggerFactory.getLogger(MetricsHttpChannelListener.class);\n\n  private record RequestInfo(String path, String method, int statusCode, @Nullable String userAgent) {\n  }\n\n  private final ClientReleaseManager clientReleaseManager;\n  private final Set<String> servletPaths;\n\n  // Use the same counter namespace as MetricsRequestEventListener for continuity\n  public static final String REQUEST_COUNTER_NAME = MetricsRequestEventListener.REQUEST_COUNTER_NAME;\n  public static final String REQUESTS_BY_VERSION_COUNTER_NAME = MetricsRequestEventListener.REQUESTS_BY_VERSION_COUNTER_NAME;\n  @VisibleForTesting\n  static final String RESPONSE_BYTES_COUNTER_NAME = MetricsRequestEventListener.RESPONSE_BYTES_COUNTER_NAME;\n  @VisibleForTesting\n  static final String REQUEST_BYTES_COUNTER_NAME = MetricsRequestEventListener.REQUEST_BYTES_COUNTER_NAME;\n  @VisibleForTesting\n  static final String URI_INFO_PROPERTY_NAME = MetricsHttpChannelListener.class.getName() + \".uriInfo\";\n\n  @VisibleForTesting\n  static final String PATH_TAG = \"path\";\n\n  @VisibleForTesting\n  static final String METHOD_TAG = \"method\";\n\n  @VisibleForTesting\n  static final String STATUS_CODE_TAG = \"status\";\n\n  @VisibleForTesting\n  static final String TRAFFIC_SOURCE_TAG = \"trafficSource\";\n\n  private final MeterRegistry meterRegistry;\n\n\n  public MetricsHttpChannelListener(final ClientReleaseManager clientReleaseManager, final Set<String> servletPaths) {\n    this(Metrics.globalRegistry, clientReleaseManager, servletPaths);\n  }\n\n  @VisibleForTesting\n  MetricsHttpChannelListener(final MeterRegistry meterRegistry, final ClientReleaseManager clientReleaseManager,\n      final Set<String> servletPaths) {\n    this.meterRegistry = meterRegistry;\n    this.clientReleaseManager = clientReleaseManager;\n    this.servletPaths = servletPaths;\n  }\n\n  public void configure(final Environment environment) {\n    // register as ContainerResponseFilter\n    environment.jersey().register(this);\n\n    // hook into lifecycle events, to react to the Connector being added\n    environment.lifecycle().addEventListener(this);\n  }\n\n  @Override\n  public void onRequestFailure(final Request request, final Throwable failure) {\n\n    if (logger.isDebugEnabled()) {\n      final RequestInfo requestInfo = getRequestInfo(request);\n\n      logger.debug(\"Request failure: {} {} ({}) [{}] \",\n          requestInfo.method(),\n          requestInfo.path(),\n          requestInfo.userAgent(),\n          requestInfo.statusCode(), failure);\n    }\n  }\n\n  @Override\n  public void onResponseFailure(Request request, Throwable failure) {\n\n    if (failure instanceof org.eclipse.jetty.io.EofException) {\n      // the client disconnected early\n      return;\n    }\n\n    final RequestInfo requestInfo = getRequestInfo(request);\n\n    logger.warn(\"Response failure: {} {} ({}) [{}] \",\n        requestInfo.method(),\n        requestInfo.path(),\n        requestInfo.userAgent(),\n        requestInfo.statusCode(), failure);\n  }\n\n  @Override\n  public void onComplete(final Request request) {\n\n    final RequestInfo requestInfo = getRequestInfo(request);\n\n    @Nullable final UserAgent userAgent;\n    {\n      UserAgent parsedUserAgent;\n\n      try {\n        parsedUserAgent = UserAgentUtil.parseUserAgentString(requestInfo.userAgent());\n      } catch (final UnrecognizedUserAgentException e) {\n        parsedUserAgent = null;\n      }\n\n      userAgent = parsedUserAgent;\n    }\n\n    final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);\n\n    final List<Tag> tags = new ArrayList<>(5);\n    tags.add(Tag.of(PATH_TAG, requestInfo.path()));\n    tags.add(Tag.of(METHOD_TAG, requestInfo.method()));\n    tags.add(Tag.of(STATUS_CODE_TAG, String.valueOf(requestInfo.statusCode())));\n    tags.add(Tag.of(TRAFFIC_SOURCE_TAG, TrafficSource.HTTP.name().toLowerCase()));\n    tags.add(platformTag);\n\n    final Optional<Tag> maybeClientVersionTag =\n        UserAgentTagUtil.getClientVersionTag(userAgent, clientReleaseManager);\n\n    maybeClientVersionTag.ifPresent(tags::add);\n\n    meterRegistry.counter(REQUEST_COUNTER_NAME, tags).increment();\n\n    meterRegistry.counter(RESPONSE_BYTES_COUNTER_NAME, tags).increment(request.getResponse().getContentCount());\n    meterRegistry.counter(REQUEST_BYTES_COUNTER_NAME, tags).increment(request.getContentRead());\n\n    maybeClientVersionTag.ifPresent(clientVersionTag -> meterRegistry.counter(REQUESTS_BY_VERSION_COUNTER_NAME,\n            Tags.of(clientVersionTag, platformTag))\n        .increment());\n  }\n\n  @Override\n  public void beanAdded(final Container parent, final Object child) {\n    if (child instanceof Connector connector) {\n      connector.addBean(this);\n    }\n  }\n\n  @Override\n  public void beanRemoved(final Container parent, final Object child) {\n\n  }\n\n  @Override\n  public void filter(final ContainerRequestContext requestContext, final ContainerResponseContext responseContext)\n      throws IOException {\n    requestContext.setProperty(URI_INFO_PROPERTY_NAME, requestContext.getUriInfo());\n  }\n\n  private RequestInfo getRequestInfo(Request request) {\n    final String path = Optional.ofNullable(request.getAttribute(URI_INFO_PROPERTY_NAME))\n        .map(attr -> UriInfoUtil.getPathTemplate((ExtendedUriInfo) attr))\n        .orElseGet(() ->\n            Optional.ofNullable(request.getPathInfo())\n                .filter(servletPaths::contains)\n                .orElse(\"unknown\")\n        );\n    final String method = Optional.ofNullable(request.getMethod()).orElse(\"unknown\");\n    // Response cannot be null, but its status might not always reflect an actual response status, since it gets\n    // initialized to 200\n    final int status = request.getResponse().getStatus();\n\n    @Nullable final String userAgent = request.getHeader(HttpHeaders.USER_AGENT);\n\n    return new RequestInfo(path, method, status, userAgent);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListener.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.server.ContainerResponse;\nimport org.glassfish.jersey.server.monitoring.RequestEvent;\nimport org.glassfish.jersey.server.monitoring.RequestEventListener;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.util.logging.UriInfoUtil;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\nimport org.whispersystems.websocket.WebSocketResourceProvider;\n\n/**\n * Gathers and reports request-level metrics for WebSocket traffic only.\n * For HTTP traffic, use {@link MetricsHttpChannelListener}.\n */\npublic class MetricsRequestEventListener implements RequestEventListener {\n\n  private static final Logger logger = LoggerFactory.getLogger(MetricsRequestEventListener.class);\n\n  private final ClientReleaseManager clientReleaseManager;\n\n  public static final String REQUEST_COUNTER_NAME = name(MetricsRequestEventListener.class, \"request\");\n  public static final String REQUESTS_BY_VERSION_COUNTER_NAME = name(MetricsRequestEventListener.class, \"requestByVersion\");\n  public static final String RESPONSE_BYTES_COUNTER_NAME = name(MetricsRequestEventListener.class, \"responseBytes\");\n  public static final String REQUEST_BYTES_COUNTER_NAME = name(MetricsRequestEventListener.class, \"requestBytes\");\n\n  @VisibleForTesting\n  static final String PATH_TAG = \"path\";\n\n  @VisibleForTesting\n  static final String METHOD_TAG = \"method\";\n\n  @VisibleForTesting\n  static final String STATUS_CODE_TAG = \"status\";\n\n  @VisibleForTesting\n  static final String TRAFFIC_SOURCE_TAG = \"trafficSource\";\n\n  @VisibleForTesting\n  static final String AUTHENTICATED_TAG = \"authenticated\";\n\n  @VisibleForTesting\n  static final String LISTEN_PORT_TAG = \"listenPort\";\n\n  private final TrafficSource trafficSource;\n  private final MeterRegistry meterRegistry;\n\n  public MetricsRequestEventListener(final TrafficSource trafficSource, final ClientReleaseManager clientReleaseManager) {\n    this(trafficSource, Metrics.globalRegistry, clientReleaseManager);\n\n    if (trafficSource == TrafficSource.HTTP) {\n      logger.warn(\"Use {} for HTTP traffic\", MetricsHttpChannelListener.class.getName());\n    }\n  }\n\n  @VisibleForTesting\n  MetricsRequestEventListener(final TrafficSource trafficSource,\n      final MeterRegistry meterRegistry,\n      final ClientReleaseManager clientReleaseManager) {\n\n    this.trafficSource = trafficSource;\n    this.meterRegistry = meterRegistry;\n    this.clientReleaseManager = clientReleaseManager;\n  }\n\n  @Override\n  public void onEvent(final RequestEvent event) {\n    if (event.getType() == RequestEvent.Type.FINISHED) {\n      if (!event.getUriInfo().getMatchedTemplates().isEmpty()) {\n        @Nullable final UserAgent userAgent;\n        {\n          final List<String> userAgentValues = event.getContainerRequest().getRequestHeader(HttpHeaders.USER_AGENT);\n          final String userAgentString = userAgentValues != null && !userAgentValues.isEmpty() ? userAgentValues.getFirst() : null;\n\n          UserAgent parsedUserAgent;\n\n          try {\n            parsedUserAgent = UserAgentUtil.parseUserAgentString(userAgentString);\n          } catch (final UnrecognizedUserAgentException e) {\n            parsedUserAgent = null;\n          }\n\n          userAgent = parsedUserAgent;\n        }\n\n        final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);\n\n        final List<Tag> tags = new ArrayList<>();\n        tags.add(Tag.of(PATH_TAG, UriInfoUtil.getPathTemplate(event.getUriInfo())));\n        tags.add(Tag.of(METHOD_TAG, event.getContainerRequest().getMethod()));\n        tags.add(Tag.of(STATUS_CODE_TAG, String.valueOf(Optional\n            .ofNullable(event.getContainerResponse())\n            .map(ContainerResponse::getStatus)\n            .orElse(499))));\n        tags.add(Tag.of(TRAFFIC_SOURCE_TAG, trafficSource.name().toLowerCase()));\n        tags.add(Tag.of(AUTHENTICATED_TAG, Optional.ofNullable(event.getContainerRequest().getProperty(WebSocketResourceProvider.REUSABLE_AUTH_PROPERTY))\n            .filter(Optional.class::isInstance)\n            .map(Optional.class::cast)\n            .map(Optional::isPresent)\n            .orElse(false)\n            .toString()));\n        tags.add(platformTag);\n\n        final Optional<Tag> maybeClientVersionTag =\n            UserAgentTagUtil.getClientVersionTag(userAgent, clientReleaseManager);\n\n        maybeClientVersionTag.ifPresent(tags::add);\n\n        Optional.ofNullable(event.getContainerRequest().getProperty(WebSocketResourceProvider.LISTEN_PORT_PROPERTY))\n            .filter(Integer.class::isInstance)\n            .map(Integer.class::cast)\n            .ifPresent(port -> tags.add(Tag.of(LISTEN_PORT_TAG, Integer.toString(port))));\n\n        meterRegistry.counter(REQUEST_COUNTER_NAME, tags).increment();\n\n        Optional.ofNullable(event.getContainerRequest().getProperty(WebSocketResourceProvider.REQUEST_LENGTH_PROPERTY))\n            .filter(Integer.class::isInstance)\n            .map(Integer.class::cast)\n            .filter(bytes -> bytes >= 0)\n            .ifPresent(bytes -> meterRegistry.counter(REQUEST_BYTES_COUNTER_NAME, tags).increment(bytes));\n\n        Optional.ofNullable(event.getContainerRequest().getProperty(WebSocketResourceProvider.RESPONSE_LENGTH_PROPERTY))\n            .filter(Integer.class::isInstance)\n            .map(Integer.class::cast)\n            .filter(bytes -> bytes >= 0)\n            .ifPresent(bytes -> meterRegistry.counter(RESPONSE_BYTES_COUNTER_NAME, tags).increment(bytes));\n\n        maybeClientVersionTag.ifPresent(clientVersionTag -> meterRegistry.counter(REQUESTS_BY_VERSION_COUNTER_NAME,\n                Tags.of(clientVersionTag, platformTag))\n            .increment());\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MetricsUtil.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport com.codahale.metrics.SharedMetricRegistries;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.Meter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.binder.jetty.JettySslHandshakeMetrics;\nimport io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics;\nimport io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics;\nimport io.micrometer.core.instrument.binder.system.FileDescriptorMetrics;\nimport io.micrometer.core.instrument.binder.system.ProcessorMetrics;\nimport io.micrometer.core.instrument.config.MeterFilter;\nimport io.micrometer.core.instrument.distribution.DistributionStatisticConfig;\nimport io.micrometer.registry.otlp.OtlpMeterRegistry;\nimport io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter;\nimport io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender;\nimport io.opentelemetry.sdk.OpenTelemetrySdk;\nimport io.opentelemetry.sdk.logs.SdkLoggerProvider;\nimport io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor;\nimport io.opentelemetry.sdk.resources.Resource;\nimport io.opentelemetry.sdk.resources.ResourceBuilder;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.eclipse.jetty.util.component.LifeCycle;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.Constants;\n\npublic class MetricsUtil {\n\n  public static final String PREFIX = \"chat\";\n\n  private static volatile boolean registeredMetrics = false;\n\n  /**\n   * Returns a dot-separated ('.') name for the given class and name parts\n   */\n  public static String name(Class<?> clazz, String... parts) {\n    return name(clazz.getSimpleName(), parts);\n  }\n\n  private static String name(String name, String... parts) {\n    final StringBuilder sb = new StringBuilder(PREFIX);\n    sb.append(\".\").append(name);\n    for (String part : parts) {\n      sb.append(\".\").append(part);\n    }\n    return sb.toString();\n  }\n\n  public static void configureRegistries(final WhisperServerConfiguration config, final Environment environment,\n      DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n\n    if (registeredMetrics) {\n      throw new IllegalStateException(\"Metric registries configured more than once\");\n    }\n\n    registeredMetrics = true;\n\n    SharedMetricRegistries.add(Constants.METRICS_NAME, environment.metrics());\n\n    Duration shutdownWaitDuration = Duration.ZERO;\n\n    if (config.getOpenTelemetryConfiguration().enabled()) {\n      final OtlpMeterRegistry otlpMeterRegistry = new OtlpMeterRegistry(\n        config.getOpenTelemetryConfiguration(), io.micrometer.core.instrument.Clock.SYSTEM);\n\n      configureMeterFilters(otlpMeterRegistry.config(), dynamicConfigurationManager);\n      Metrics.addRegistry(otlpMeterRegistry);\n\n      shutdownWaitDuration = config.getOpenTelemetryConfiguration().shutdownWaitDuration();\n    }\n\n    environment.lifecycle().addServerLifecycleListener(\n        server -> JettySslHandshakeMetrics.addToAllConnectors(server, Metrics.globalRegistry));\n\n    new ApplicationShutdownMonitor(Metrics.globalRegistry).register();\n    environment.lifecycle().addEventListener(\n        new MicrometerRegistryManager(Metrics.globalRegistry, shutdownWaitDuration));\n\n    registerSystemResourceMetrics();\n  }\n\n  public static void configureLogging(final WhisperServerConfiguration config, final Environment environment) {\n    if (!config.getOpenTelemetryConfiguration().enabled()) {\n      return;\n    }\n\n    final Map<String, String> env = System.getenv();\n    final String endpoint =\n      Optional.ofNullable(env.get(\"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT\"))\n        .or(() -> Optional.ofNullable(env.get(\"OTEL_EXPORTER_OTLP_ENDPOINT\")))\n        .map(u -> u.endsWith(\"/v1/logs\") ? u : u + \"/v1/logs\")\n        .orElse(\"http://localhost:4318/v1/logs\");\n\n    final ResourceBuilder resource = Resource.builder();\n    config.getOpenTelemetryConfiguration().resourceAttributes().forEach((k, v) -> resource.put(k, v));\n\n    final OpenTelemetrySdk openTelemetry =\n      OpenTelemetrySdk.builder()\n        .setLoggerProvider(\n          SdkLoggerProvider.builder()\n            .setResource(resource.build())\n            .addLogRecordProcessor(\n              BatchLogRecordProcessor.builder(\n                OtlpHttpLogRecordExporter.builder()\n                  .setEndpoint(endpoint)\n                  .build()).build())\n            .build())\n        .build();\n\n    OpenTelemetryAppender.install(openTelemetry);\n\n    environment.lifecycle().addEventListener(new LifeCycle.Listener() {\n      @Override\n      public void lifeCycleStopped(final LifeCycle event) {\n        openTelemetry.close();\n      }\n    });\n  }\n\n  @VisibleForTesting\n  static void configureMeterFilters(MeterRegistry.Config config,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager) {\n    final DistributionStatisticConfig defaultDistributionStatisticConfig = DistributionStatisticConfig.builder()\n        .percentilesHistogram(true)\n        .build();\n\n    final String awsSdkMetricNamePrefix = MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class);\n\n    config.meterFilter(new MeterFilter() {\n          @Override\n          public DistributionStatisticConfig configure(final Meter.Id id, final DistributionStatisticConfig config) {\n            return config.merge(defaultDistributionStatisticConfig);\n          }\n        })\n        // Remove high-cardinality `command` tags from Lettuce metrics and prepend \"chat.\" to meter names\n        .meterFilter(new MeterFilter() {\n          @Override\n          public Meter.Id map(final Meter.Id id) {\n            if (id.getName().startsWith(\"lettuce\")) {\n              return id.withName(PREFIX + \".\" + id.getName())\n                  .replaceTags(id.getTags().stream()\n                      .filter(tag -> !\"command\".equals(tag.getKey()))\n                      .filter(tag -> dynamicConfigurationManager.getConfiguration().getMetricsConfiguration().\n                          enableLettuceRemoteTag() || !\"remote\".equals(tag.getKey()))\n                      .toList());\n            }\n\n            return MeterFilter.super.map(id);\n          }\n        })\n        .meterFilter(MeterFilter.denyNameStartsWith(MessageMetrics.DELIVERY_LATENCY_TIMER_NAME + \".percentile\"))\n        .meterFilter(MeterFilter.deny(id -> !dynamicConfigurationManager.getConfiguration().getMetricsConfiguration().enableAwsSdkMetrics()\n            && id.getName().startsWith(awsSdkMetricNamePrefix)));\n  }\n\n  static void registerSystemResourceMetrics() {\n    new ProcessorMetrics().bindTo(Metrics.globalRegistry);\n    new FileDescriptorMetrics().bindTo(Metrics.globalRegistry);\n\n    new JvmMemoryMetrics().bindTo(Metrics.globalRegistry);\n    new JvmThreadMetrics().bindTo(Metrics.globalRegistry);\n\n    GarbageCollectionGauges.registerMetrics();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MicrometerAwsSdkMetricPublisher.java",
    "content": "package org.whispersystems.textsecuregcm.metrics;\n\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.RejectedExecutionException;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.stream.Collectors;\nimport software.amazon.awssdk.metrics.MetricCollection;\nimport software.amazon.awssdk.metrics.MetricPublisher;\nimport software.amazon.awssdk.metrics.MetricRecord;\n\n/**\n * A Micrometer AWS SDK metric publisher consumes {@link MetricCollection} instances provided by the AWS SDK when it\n * makes calls to the AWS API and publishes a subset of metrics from each call via the Micrometer metrics facade. A\n * single {@code MicrometerAwsSdkMetricPublisher} should be bound to a single AWS service client instance; publishers\n * should not be assigned to multiple service clients.\n *\n * @see <a href=\"https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/metrics-list.html\">Service client metrics</a>\n */\npublic class MicrometerAwsSdkMetricPublisher implements MetricPublisher {\n\n  private final ExecutorService recordMetricsExecutorService;\n\n  private final String awsClientName;\n  private final AtomicInteger mostRecentMaxConcurrency;\n\n  // Note that these metric collection type names don't seem to appear anywhere in the AWS SDK documentation. The docs\n  // also hint at, but don't explicitly call out, the structure of a `MetricCollection` passed to the `#publish(...)`\n  // method. Empirically, for each AWS SDK operation, the SDK will call `#publish(...)` with a collection structured as\n  // follows:\n  //\n  // MetricCollection { name = \"ApiCall\" }\n  // +-- MetricRecord { name = \"ApiCallDuration\" }\n  // +-- MetricRecord { name = \"ApiCallSuccessful\" }\n  // +-- ...\n  // +-- MetricCollection { name = \"ApiCallAttempt\" }\n  //     +-- MetricRecord { name = \"AwsExtendedRequestId\" }\n  //     +-- MetricRecord { name = \"AwsRequestId\" }\n  //     +-- ...\n  //     +-- MetricCollection {name = \"HttpClient\" }\n  //         +-- MetricRecord { name = \"AvailableConcurrency\" }\n  //         +-- MetricRecord { name = \"ConcurrencyAcquireDuration\" }\n  //         +-- ...\n  // +-- MetricCollection { name = \"ApiCallAttempt\" }\n  //     +-- MetricRecord { name = \"AwsExtendedRequestId\" }\n  //     +-- MetricRecord { name = \"AwsRequestId\" }\n  //     +-- ...\n  //     +-- MetricCollection {name = \"HttpClient\" }\n  //         +-- MetricRecord { name = \"AvailableConcurrency\" }\n  //         +-- MetricRecord { name = \"ConcurrencyAcquireDuration\" }\n  //         +-- ...\n  // +-- ...\n  //\n  // Every `MetricCollection` passed to `#publish(...)` should have a name of \"ApiCall,\" and the \"ApiCall\" collection\n  // will have a fixed collection of named metrics (which ARE documented!). The \"ApiCall\" collection should have one or\n  // more \"ApiCallAttempt\" `MetricCollection` as children, and each \"ApiCallAttempt\" collection should have exactly one\n  // \"HttpClient\" `MetricCollection` as a child.\n  private static final String METRIC_COLLECTION_TYPE_API_CALL = \"ApiCall\";\n  private static final String METRIC_COLLECTION_TYPE_API_CALL_ATTEMPT = \"ApiCallAttempt\";\n  private static final String METRIC_COLLECTION_TYPE_HTTP_METRICS = \"HttpClient\";\n\n  private static final String API_CALL_COUNTER_NAME =\n      MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class, \"apiCall\");\n\n  private static final String API_CALL_RETRY_COUNT_DISTRIBUTION_NAME =\n      MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class, \"apiCallRetries\");\n\n  private static final String CHANNEL_ACQUISITION_TIMER_NAME =\n      MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class, \"acquireChannelDuration\");\n\n  private static final String PENDING_CHANNEL_ACQUISITIONS_DISTRIBUTION_NAME =\n      MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class, \"pendingChannelAcquisitions\");\n\n  private static final String CONCURRENT_REQUESTS_DISTRIBUTION_NAME =\n      MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class, \"concurrentRequests\");\n\n  private static final String MAX_CONCURRENCY_GAUGE_NAME =\n          MetricsUtil.name(MicrometerAwsSdkMetricPublisher.class, \"maxConcurrency\");\n\n  private static final String CLIENT_NAME_TAG = \"clientName\";\n\n  /**\n   * Constructs a new metric publisher that uses the given executor service to record metrics and tags metrics with the\n   * given client name.\n   *\n   * @param recordMetricsExecutorService the executor service via which to record metrics\n   * @param awsClientName the name of AWS service client to which this publisher is attached\n   */\n  public MicrometerAwsSdkMetricPublisher(final ExecutorService recordMetricsExecutorService, final String awsClientName) {\n    this.recordMetricsExecutorService = recordMetricsExecutorService;\n    this.awsClientName = awsClientName;\n\n    mostRecentMaxConcurrency = Metrics.gauge(MAX_CONCURRENCY_GAUGE_NAME,\n        Tags.of(CLIENT_NAME_TAG, awsClientName),\n        new AtomicInteger(0));\n  }\n\n  @Override\n  public void publish(final MetricCollection metricCollection) {\n    if (METRIC_COLLECTION_TYPE_API_CALL.equals(metricCollection.name())) {\n      try {\n        recordMetricsExecutorService.submit(() -> recordApiCallMetrics(metricCollection));\n      } catch (final RejectedExecutionException ignored) {\n        // This can happen if clients make new calls to an upstream service while the server is shutting down\n      }\n    }\n  }\n\n  private void recordApiCallMetrics(final MetricCollection apiCallMetricCollection) {\n    if (!apiCallMetricCollection.name().equals(METRIC_COLLECTION_TYPE_API_CALL)) {\n      throw new IllegalArgumentException(\"Unexpected API call metric collection name: \" + apiCallMetricCollection.name());\n    }\n\n    final Map<String, MetricRecord<?>> metricsByName = toMetricMap(apiCallMetricCollection);\n\n    final Optional<String> maybeAwsServiceId = Optional.ofNullable(metricsByName.get(\"ServiceId\"))\n        .map(metricRecord -> (String) metricRecord.value());\n\n    final Optional<String> maybeOperationName = Optional.ofNullable(metricsByName.get(\"OperationName\"))\n        .map(metricRecord -> (String) metricRecord.value());\n\n    // Both the service ID and operation name SHOULD always be present, but since the metric collection is unstructured,\n    // we don't have any compile-time guarantees and so check that they're both present as a pedantic safety check.\n    if (maybeAwsServiceId.isPresent() && maybeOperationName.isPresent()) {\n      final String awsServiceId = maybeAwsServiceId.get();\n      final String operationName = maybeOperationName.get();\n\n      final boolean success = Optional.ofNullable(metricsByName.get(\"ApiCallSuccessful\"))\n          .map(metricRecord -> (boolean) metricRecord.value())\n          .orElse(false);\n\n      final int retryCount = Optional.ofNullable(metricsByName.get(\"RetryCount\"))\n          .map(metricRecord -> (int) metricRecord.value())\n          .orElse(0);\n\n      final Tags tags = Tags.of(\n          CLIENT_NAME_TAG, awsClientName,\n          \"awsServiceId\", awsServiceId,\n          \"operationName\", operationName,\n          \"callSuccess\", String.valueOf(success));\n\n      Metrics.counter(API_CALL_COUNTER_NAME, tags).increment();\n\n      DistributionSummary.builder(API_CALL_RETRY_COUNT_DISTRIBUTION_NAME)\n          .tags(tags)\n          .register(Metrics.globalRegistry)\n          .record(retryCount);\n\n      apiCallMetricCollection.childrenWithName(METRIC_COLLECTION_TYPE_API_CALL_ATTEMPT)\n          .forEach(callAttemptMetricCollection -> recordAttemptMetrics(callAttemptMetricCollection, tags));\n    }\n  }\n\n  private void recordAttemptMetrics(final MetricCollection apiCallAttemptMetricCollection, final Tags callTags) {\n    if (!apiCallAttemptMetricCollection.name().equals(METRIC_COLLECTION_TYPE_API_CALL_ATTEMPT)) {\n      throw new IllegalArgumentException(\"Unexpected API call attempt metric collection name: \" + apiCallAttemptMetricCollection.name());\n    }\n\n    // A \"call attempt\" metric collection should always have exactly one HTTP metrics child collection, but we have no\n    // compiler-level guarantees and so do a pedantic check here just to be safe.\n    apiCallAttemptMetricCollection.childrenWithName(METRIC_COLLECTION_TYPE_HTTP_METRICS).findFirst().ifPresent(httpMetricCollection -> {\n      final Map<String, MetricRecord<?>> callAttemptMetricsByName = toMetricMap(apiCallAttemptMetricCollection);\n      final Map<String, MetricRecord<?>> httpMetricsByName = toMetricMap(httpMetricCollection);\n\n      Optional.ofNullable(httpMetricsByName.get(\"MaxConcurrency\"))\n          .ifPresent(maxConcurrencyMetricRecord -> mostRecentMaxConcurrency.set((int) maxConcurrencyMetricRecord.value()));\n\n      final Tags attemptTags = Optional.ofNullable(callAttemptMetricsByName.get(\"ErrorType\"))\n          .map(errorTypeMetricRecord -> callTags.and(\"error\", errorTypeMetricRecord.value().toString()))\n          .orElse(callTags);\n\n      Optional.ofNullable(httpMetricsByName.get(\"ConcurrencyAcquireDuration\"))\n          .ifPresent(channelAcquisitionDurationMetricRecord -> Timer.builder(CHANNEL_ACQUISITION_TIMER_NAME)\n              .tags(attemptTags)\n              .register(Metrics.globalRegistry)\n              .record((Duration) channelAcquisitionDurationMetricRecord.value()));\n\n      Optional.ofNullable(httpMetricsByName.get(\"LeasedConcurrency\"))\n          .ifPresent(concurrentRequestsMetricRecord -> DistributionSummary.builder(CONCURRENT_REQUESTS_DISTRIBUTION_NAME)\n              .tags(attemptTags)\n              .register(Metrics.globalRegistry)\n              .record((int) concurrentRequestsMetricRecord.value()));\n\n      Optional.ofNullable(httpMetricsByName.get(\"PendingConcurrencyAcquires\"))\n          .ifPresent(pendingChannelAcquisitionsMetricRecord -> DistributionSummary.builder(PENDING_CHANNEL_ACQUISITIONS_DISTRIBUTION_NAME)\n              .tags(attemptTags)\n              .register(Metrics.globalRegistry)\n              .record((int) pendingChannelAcquisitionsMetricRecord.value()));\n    });\n  }\n\n  private static Map<String, MetricRecord<?>> toMetricMap(final MetricCollection metricCollection) {\n    return metricCollection.stream()\n        .collect(Collectors.toMap(metricRecord -> metricRecord.metric().name(), metricRecord -> metricRecord));\n  }\n\n  @Override\n  public void close() {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/MicrometerRegistryManager.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport io.micrometer.core.instrument.MeterRegistry;\nimport java.time.Duration;\nimport org.eclipse.jetty.util.component.LifeCycle;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class MicrometerRegistryManager implements LifeCycle.Listener {\n\n  private static final Logger logger = LoggerFactory.getLogger(MicrometerRegistryManager.class);\n\n  private final MeterRegistry meterRegistry;\n  private final Duration waitDuration;\n\n  public MicrometerRegistryManager(final MeterRegistry meterRegistry, final Duration waitDuration) {\n    this.meterRegistry = meterRegistry;\n    this.waitDuration = waitDuration;\n  }\n\n  @Override\n  public void lifeCycleStopped(final LifeCycle event) {\n    try {\n      logger.info(\"Waiting for {} to ensure final metrics are polled and published\", waitDuration);\n      Thread.sleep(waitDuration.toMillis());\n    } catch (final InterruptedException e) {\n      logger.warn(\"Waiting interrupted\", e);\n    } finally {\n      meterRegistry.close();\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/NoopAwsSdkMetricPublisher.java",
    "content": "package org.whispersystems.textsecuregcm.metrics;\n\nimport software.amazon.awssdk.metrics.MetricCollection;\nimport software.amazon.awssdk.metrics.MetricPublisher;\n\npublic class NoopAwsSdkMetricPublisher implements MetricPublisher {\n\n  @Override\n  public void publish(final MetricCollection metricCollection) {\n  }\n\n  @Override\n  public void close() {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/OpenTelemetryAppenderFactory.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport ch.qos.logback.classic.LoggerContext;\nimport ch.qos.logback.classic.spi.ILoggingEvent;\nimport ch.qos.logback.core.Appender;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport io.dropwizard.logging.common.AbstractAppenderFactory;\nimport io.dropwizard.logging.common.async.AsyncAppenderFactory;\nimport io.dropwizard.logging.common.filter.LevelFilterFactory;\nimport io.dropwizard.logging.common.layout.LayoutFactory;\nimport io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender;\n\n@JsonTypeName(\"otlp\")\npublic class OpenTelemetryAppenderFactory extends AbstractAppenderFactory<ILoggingEvent> {\n\n  @JsonProperty\n  private String destination;\n\n  @Override\n  public Appender<ILoggingEvent> build(\n      final LoggerContext context,\n      final String applicationName,\n      final LayoutFactory<ILoggingEvent> layoutFactory,\n      final LevelFilterFactory<ILoggingEvent> levelFilterFactory,\n      final AsyncAppenderFactory<ILoggingEvent> asyncAppenderFactory) {\n\n    final OpenTelemetryAppender appender = new OpenTelemetryAppender();\n    appender.setCaptureCodeAttributes(true);\n    appender.setCaptureLoggerContext(true);\n    appender.setCaptureLogstashMarkerAttributes(true);\n\n    // The installation of an OpenTelemetry configuration happens in\n    // WhisperServerService (or CommandDependencies), in order to let us tie\n    // into Dropwizard's lifecycle management; this allows us to buffer any\n    // logs emitted between now and that happening.\n    appender.setNumLogsCapturedBeforeOtelInstall(1000);\n\n    appender.addFilter(levelFilterFactory.build(threshold));\n    getFilterFactories().forEach(f -> appender.addFilter(f.build()));\n    appender.start();\n\n    return wrapAsync(appender, asyncAppenderFactory);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/OpenWebSocketCounter.java",
    "content": "package org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.net.InetAddresses;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.net.InetSocketAddress;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.function.Supplier;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.asn.AsnInfo;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\npublic class OpenWebSocketCounter {\n\n  private final Supplier<AsnInfoProvider> asnInfoProviderSupplier;\n  private final ClientReleaseManager clientReleaseManager;\n\n  private final Tags baseTags;\n\n  private final Map<Tags, AtomicInteger> openWebsocketsByTags;\n  private final Map<String, AtomicInteger> openWebsocketsByAsnRegion = new ConcurrentHashMap<>();\n  private final AtomicInteger totalConnections;\n\n  private static final int MAX_COUNTERS = 4096;\n\n  private static final String OPEN_WEBSOCKET_GAUGE_NAME = name(OpenWebSocketCounter.class, \"openWebsockets\");\n  private static final String TOTAL_CONNECTIONS_GAUGE_NAME = name(OpenWebSocketCounter.class, \"totalOpenWebsockets\");\n  private static final String NEW_CONNECTION_COUNTER_NAME = name(OpenWebSocketCounter.class, \"newConnections\");\n  private static final String WEB_SOCKET_CLOSED_COUNTER_NAME = name(OpenWebSocketCounter.class, \"websocketClosed\");\n  private static final String SESSION_DURATION_TIMER_NAME = name(OpenWebSocketCounter.class, \"sessionDuration\");\n  private static final String GAUGE_COUNT_GAUGE_NAME = name(OpenWebSocketCounter.class, \"gaugeCount\");\n  private static final String OPEN_WEBSOCKET_BY_ASN_REGION_GAUGE_NAME = name(OpenWebSocketCounter.class, \"openWebsocketsByAsnRegion\");\n\n  public OpenWebSocketCounter(final String webSocketType,\n      final Supplier<AsnInfoProvider> asnInfoProviderSupplier,\n      final ClientReleaseManager clientReleaseManager) {\n\n    this.asnInfoProviderSupplier = asnInfoProviderSupplier;\n    this.clientReleaseManager = clientReleaseManager;\n\n    this.baseTags = Tags.of(\"webSocketType\", webSocketType);\n    this.openWebsocketsByTags = Metrics.gaugeMapSize(GAUGE_COUNT_GAUGE_NAME, baseTags, new ConcurrentHashMap<>());\n\n    this.totalConnections = Metrics.gauge(TOTAL_CONNECTIONS_GAUGE_NAME, baseTags, new AtomicInteger(0));\n  }\n\n  public void countOpenWebSocket(final WebSocketSessionContext context) {\n    final Timer.Sample sample = Timer.start();\n\n    final Optional<AtomicInteger> maybeOpenWebSocketsByAsnRegion;\n\n    if (context.getClient().getRemoteAddress() instanceof InetSocketAddress inetSocketAddress) {\n      maybeOpenWebSocketsByAsnRegion =\n          asnInfoProviderSupplier.get().lookup(InetAddresses.toAddrString(inetSocketAddress.getAddress()))\n              .map(AsnInfo::regionCode)\n              .map(asnRegion -> openWebsocketsByAsnRegion.computeIfAbsent(asnRegion, region ->\n                  Metrics.gauge(OPEN_WEBSOCKET_BY_ASN_REGION_GAUGE_NAME, Tags.of(\"asnRegion\", region),\n                      new AtomicInteger(0))));\n    } else {\n      maybeOpenWebSocketsByAsnRegion = Optional.empty();\n    }\n\n    maybeOpenWebSocketsByAsnRegion.ifPresent(AtomicInteger::incrementAndGet);\n\n    @Nullable final UserAgent userAgent;\n    {\n      UserAgent parsedUserAgent;\n\n      try {\n        parsedUserAgent = UserAgentUtil.parseUserAgentString(context.getClient().getUserAgent());\n      } catch (final UnrecognizedUserAgentException e) {\n        parsedUserAgent = null;\n      }\n\n      userAgent = parsedUserAgent;\n    }\n\n    final Tags tagsWithClientPlatform = baseTags.and(UserAgentTagUtil.getPlatformTag(userAgent));\n\n    final Optional<AtomicInteger> maybeOpenWebSocketCounter;\n    {\n      final Tags tagsWithAdditionalSpecifiers = tagsWithClientPlatform\n          .and(UserAgentTagUtil.getClientVersionTag(userAgent, clientReleaseManager)\n              .map(Tags::of)\n              .orElseGet(Tags::empty))\n          .and(UserAgentTagUtil.getAdditionalSpecifierTags(userAgent));\n\n      maybeOpenWebSocketCounter = getCounter(tagsWithAdditionalSpecifiers);\n    }\n\n    maybeOpenWebSocketCounter.ifPresent(AtomicInteger::incrementAndGet);\n    totalConnections.incrementAndGet();\n\n    Metrics.counter(NEW_CONNECTION_COUNTER_NAME, tagsWithClientPlatform).increment();\n\n    context.addWebsocketClosedListener((_, statusCode, _) -> {\n      sample.stop(Timer.builder(SESSION_DURATION_TIMER_NAME)\n          .tags(tagsWithClientPlatform)\n          .register(Metrics.globalRegistry));\n\n      maybeOpenWebSocketCounter.ifPresent(AtomicInteger::decrementAndGet);\n      maybeOpenWebSocketsByAsnRegion.ifPresent(AtomicInteger::decrementAndGet);\n      totalConnections.decrementAndGet();\n\n      Metrics.counter(WEB_SOCKET_CLOSED_COUNTER_NAME, tagsWithClientPlatform.and(\"status\", String.valueOf(statusCode)))\n          .increment();\n    });\n  }\n\n  private Optional<AtomicInteger> getCounter(final Tags tags) {\n    // Make a reasonable effort to avoid creating new counters if we're already full\n    return openWebsocketsByTags.size() >= MAX_COUNTERS\n        ? Optional.ofNullable(openWebsocketsByTags.get(tags))\n        : Optional.of(openWebsocketsByTags.computeIfAbsent(tags,\n            t -> Metrics.gauge(OPEN_WEBSOCKET_GAUGE_NAME, t, new AtomicInteger(0))));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/ReportedMessageMetricsListener.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Metrics;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport net.logstash.logback.marker.Markers;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ReportedMessageListener;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic class ReportedMessageMetricsListener implements ReportedMessageListener {\n\n  private final AccountsManager accountsManager;\n\n  // ReportMessageManager name used deliberately to preserve continuity of metrics\n  private static final String REPORTED_COUNTER_NAME = name(ReportedMessageMetricsListener.class, \"reported\");\n  private static final String REPORTER_COUNTER_NAME = name(ReportedMessageMetricsListener.class, \"reporter\");\n\n  private static final String COUNTRY_CODE_TAG_NAME = \"countryCode\";\n\n  private static final Logger logger = LoggerFactory.getLogger(ReportedMessageMetricsListener.class);\n\n  public ReportedMessageMetricsListener(final AccountsManager accountsManager) {\n    this.accountsManager = accountsManager;\n  }\n\n  @Override\n  public void handleMessageReported(final String sourceNumber, final UUID messageGuid, final UUID reporterUuid,\n      final Optional<byte[]> reportSpamToken) {\n\n    final String sourceCountryCode = Util.getCountryCode(sourceNumber);\n\n    Metrics.counter(REPORTED_COUNTER_NAME, COUNTRY_CODE_TAG_NAME, sourceCountryCode).increment();\n\n    accountsManager.getByAccountIdentifier(reporterUuid).ifPresent(reporter -> {\n      final String destinationCountryCode = Util.getCountryCode(reporter.getNumber());\n\n      logger.info(Markers.appendEntries(Map.of(\n              \"sourceCountry\", sourceCountryCode,\n              \"destinationCountry\", destinationCountryCode)),\n          \"Message reported\");\n\n      Metrics.counter(REPORTER_COUNTER_NAME, COUNTRY_CODE_TAG_NAME, destinationCountryCode).increment();\n    });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/TlsCertificateExpirationUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Gauge;\nimport io.micrometer.core.instrument.Metrics;\nimport java.security.KeyStore;\nimport java.security.KeyStoreException;\nimport java.security.cert.Certificate;\nimport java.security.cert.CertificateParsingException;\nimport java.security.cert.X509Certificate;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.HashMap;\nimport java.util.Iterator;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.eclipse.jetty.util.resource.Resource;\nimport org.eclipse.jetty.util.security.CertificateUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class TlsCertificateExpirationUtil {\n\n  private static final Logger logger = LoggerFactory.getLogger(TlsCertificateExpirationUtil.class);\n\n  private static final String CERTIFICATE_EXPIRATION_GAUGE_NAME = name(TlsCertificateExpirationUtil.class,\n      \"secondsUntilExpiration\");\n\n  public static void configureMetrics(final String keyStorePath, final String keyStorePassword, final String keyStoreType, final String keyStoreProvider) {\n\n    final KeyStore keyStore;\n    try {\n      keyStore = CertificateUtils.getKeyStore(Resource.newResource(keyStorePath), keyStoreType, keyStoreProvider,\n          keyStorePassword);\n\n    } catch (Exception e) {\n      throw new RuntimeException(\"Failed to load keystore \" + keyStorePath, e);\n    }\n\n    getIdentifiersAndExpirations(keyStore, keyStorePassword)\n        .forEach((id, expiration) ->\n            Gauge.builder(CERTIFICATE_EXPIRATION_GAUGE_NAME, expiration,\n                    then -> Duration.between(Instant.now(), then).toSeconds())\n                .tags(\"id\", id)\n                .strongReference(true)\n                .register(Metrics.globalRegistry)\n        );\n  }\n\n  @VisibleForTesting\n  static Map<String, Instant> getIdentifiersAndExpirations(final KeyStore keyStore, final String password) {\n\n    final Map<String, Instant> identifiersAndExpirations = new HashMap<>();\n\n    try {\n      for (final Iterator<String> it = keyStore.aliases().asIterator(); it.hasNext(); ) {\n\n        final Certificate certificate = keyStore.getCertificate(it.next());\n        if (certificate instanceof X509Certificate x509Certificate) {\n\n          final String name = getName(x509Certificate);\n          final String algorithm = x509Certificate.getPublicKey().getAlgorithm();\n          final Instant notAfter = Instant.ofEpochMilli(x509Certificate.getNotAfter().getTime());\n\n          final String identifier = name + \":\" + algorithm;\n          identifiersAndExpirations.put(identifier, notAfter);\n\n        } else {\n          logger.warn(\"Unexpected certificate type: {}\", certificate.getClass().getName());\n        }\n      }\n    } catch (final KeyStoreException e) {\n      // This should never happen - this exception is thrown if the keystore is not initialized, which\n      // CertificateUtils#getKeyStore does.\n      throw new RuntimeException(\"Failed to read keystore\", e);\n    } catch (final CertificateParsingException e) {\n      throw new IllegalArgumentException(\"Failed to parse certificate\", e);\n    }\n\n    return identifiersAndExpirations;\n  }\n\n  private static String getName(X509Certificate x509Certificate) throws CertificateParsingException {\n    return Optional.ofNullable(x509Certificate.getSubjectAlternativeNames())\n        .flatMap(sans -> sans.stream().findFirst())\n        .map(altNames -> {\n\n          // each list should be a tuple of\n          //   alternative name type ID (integer), name\n          if (altNames.size() != 2) {\n            logger.warn(\"Unexpected subject alternative name: {}\", altNames);\n            return null;\n          }\n\n          return switch ((Integer) altNames.getFirst()) {\n            case 2, 7 -> // dns, ip\n                altNames.getLast().toString();\n            default -> null;\n          };\n\n        })\n        .orElse(\"unknown\");\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/TrafficSource.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\npublic enum TrafficSource {\n    HTTP,\n    WEBSOCKET\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtil.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.WhisperServerVersion;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n/**\n * Utility class for extracting platform/version metrics tags from User-Agent strings.\n */\npublic class UserAgentTagUtil {\n\n  public static final String PLATFORM_TAG = \"platform\";\n  public static final String VERSION_TAG = \"clientVersion\";\n  public static final String OPERATING_SYSTEM_TAG = \"operatingSystem\";\n  public static final String OPERATING_SYSTEM_VERSION_TAG = \"operatingSystemVersion\";\n  public static final String LIBSIGNAL_VERSION_TAG = \"libsignalVersion\";\n\n  public static final String SERVER_UA =\n      String.format(\"Signal-Server/%s (%s)\", WhisperServerVersion.getServerVersion(), UUID.randomUUID());\n\n  private static final Pattern STANDARD_ADDITIONAL_SPECIFIERS_PATTERN =\n      Pattern.compile(\"^(Android|iOS|Windows|macOS|Linux)[ /](\\\\S+) libsignal/([\\\\d.]+).*$\", Pattern.CASE_INSENSITIVE);\n\n  private UserAgentTagUtil() {\n  }\n\n  public static Tag getPlatformTag(final ContainerRequestContext containerRequestContext) {\n    return getPlatformTag(containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT));\n  }\n\n  public static Tag getPlatformTag(@Nullable final String userAgentString) {\n\n    if (SERVER_UA.equals(userAgentString)) {\n      return Tag.of(PLATFORM_TAG, \"server\");\n    }\n\n    UserAgent userAgent = null;\n\n    try {\n      userAgent = UserAgentUtil.parseUserAgentString(userAgentString);\n    } catch (final UnrecognizedUserAgentException ignored) {\n    }\n\n    return getPlatformTag(userAgent);\n  }\n\n  public static Tag getPlatformTag(@Nullable final UserAgent userAgent) {\n    return Tag.of(PLATFORM_TAG, userAgent != null ? userAgent.platform().name().toLowerCase() : \"unrecognized\");\n  }\n\n  public static Optional<Tag> getClientVersionTag(@Nullable final String userAgentString,\n      final ClientReleaseManager clientReleaseManager) {\n\n    try {\n      return getClientVersionTag(UserAgentUtil.parseUserAgentString(userAgentString), clientReleaseManager);\n    } catch (final UnrecognizedUserAgentException e) {\n      return Optional.empty();\n    }\n  }\n\n  public static Optional<Tag> getClientVersionTag(@Nullable final UserAgent userAgent,\n      final ClientReleaseManager clientReleaseManager) {\n\n    if (userAgent == null) {\n      return Optional.empty();\n    }\n\n    return clientReleaseManager.isVersionActive(userAgent.platform(), userAgent.version())\n        ? Optional.of(Tag.of(VERSION_TAG, userAgent.version().toString()))\n        : Optional.empty();\n  }\n\n  public static Tags getAdditionalSpecifierTags(@Nullable final String userAgentString) {\n    UserAgent userAgent = null;\n\n    try {\n      userAgent = UserAgentUtil.parseUserAgentString(userAgentString);\n    } catch (final UnrecognizedUserAgentException ignored) {\n    }\n\n    return getAdditionalSpecifierTags(userAgent);\n  }\n\n  public static Tags getAdditionalSpecifierTags(@Nullable final UserAgent userAgent) {\n    if (userAgent == null || StringUtils.isBlank(userAgent.additionalSpecifiers())) {\n      return Tags.empty();\n    }\n\n    final Matcher matcher = STANDARD_ADDITIONAL_SPECIFIERS_PATTERN.matcher(userAgent.additionalSpecifiers());\n\n    return matcher.matches()\n        ? Tags.of(\n        OPERATING_SYSTEM_TAG, matcher.group(1),\n        OPERATING_SYSTEM_VERSION_TAG, matcher.group(2),\n        LIBSIGNAL_VERSION_TAG, matcher.group(3))\n        : Tags.empty();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/providers/MultiRecipientMessageProvider.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.providers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.dropwizard.util.DataSizeUnit;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.ws.rs.BadRequestException;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.MultivaluedMap;\nimport jakarta.ws.rs.core.NoContentException;\nimport jakarta.ws.rs.ext.MessageBodyReader;\nimport jakarta.ws.rs.ext.Provider;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.lang.annotation.Annotation;\nimport java.lang.reflect.Type;\nimport org.signal.libsignal.protocol.InvalidMessageException;\nimport org.signal.libsignal.protocol.InvalidVersionException;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\n\n@Provider\n@Consumes(MultiRecipientMessageProvider.MEDIA_TYPE)\npublic class MultiRecipientMessageProvider implements MessageBodyReader<SealedSenderMultiRecipientMessage> {\n\n  public static final String MEDIA_TYPE = \"application/vnd.signal-messenger.mrm\";\n  public static final int MAX_RECIPIENT_COUNT = 5000;\n  public static final int MAX_MESSAGE_SIZE = Math.toIntExact(32 + DataSizeUnit.KIBIBYTES.toBytes(256));\n\n  private static final DistributionSummary RECIPIENT_COUNT_DISTRIBUTION = DistributionSummary\n      .builder(name(MultiRecipientMessageProvider.class, \"recipients\"))\n      .register(Metrics.globalRegistry);\n\n  @Override\n  public boolean isReadable(Class<?> type, Type genericType, Annotation[] annotations, MediaType mediaType) {\n    return MEDIA_TYPE.equals(mediaType.toString()) && SealedSenderMultiRecipientMessage.class.isAssignableFrom(type);\n  }\n\n  @Override\n  public SealedSenderMultiRecipientMessage readFrom(Class<SealedSenderMultiRecipientMessage> type, Type genericType, Annotation[] annotations,\n      MediaType mediaType, MultivaluedMap<String, String> httpHeaders, InputStream entityStream)\n      throws IOException, WebApplicationException {\n    byte[] fullMessage = entityStream.readNBytes(MAX_MESSAGE_SIZE + MAX_RECIPIENT_COUNT * 100);\n    if (fullMessage.length == 0) {\n      throw new NoContentException(\"Empty body not allowed\");\n    }\n\n    try {\n      final SealedSenderMultiRecipientMessage message = SealedSenderMultiRecipientMessage.parse(fullMessage);\n      RECIPIENT_COUNT_DISTRIBUTION.record(message.getRecipients().size());\n      return message;\n    } catch (InvalidMessageException | InvalidVersionException e) {\n      throw new BadRequestException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/APNSender.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.eatthepath.pushy.apns.ApnsClient;\nimport com.eatthepath.pushy.apns.ApnsClientBuilder;\nimport com.eatthepath.pushy.apns.DeliveryPriority;\nimport com.eatthepath.pushy.apns.PushType;\nimport com.eatthepath.pushy.apns.auth.ApnsSigningKey;\nimport com.eatthepath.pushy.apns.util.SimpleApnsPayloadBuilder;\nimport com.eatthepath.pushy.apns.util.SimpleApnsPushNotification;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.lifecycle.Managed;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport org.whispersystems.textsecuregcm.configuration.ApnConfiguration;\n\npublic class APNSender implements Managed, PushNotificationSender {\n\n  private final ExecutorService executor;\n  private final String bundleId;\n  private final ApnsClient apnsClient;\n\n  @VisibleForTesting\n  static final String APN_NSE_NOTIFICATION_PAYLOAD = new SimpleApnsPayloadBuilder()\n      .setMutableContent(true)\n      .setLocalizedAlertMessage(\"APN_Message\")\n      .build();\n\n  @VisibleForTesting\n  static final String APN_BACKGROUND_PAYLOAD = new SimpleApnsPayloadBuilder()\n      .setContentAvailable(true)\n      .build();\n\n  @VisibleForTesting\n  static final Instant MAX_EXPIRATION = Instant.ofEpochMilli(Integer.MAX_VALUE * 1000L);\n\n  private static final String APNS_CA_FILENAME = \"apns-certificates.pem\";\n\n  private static final Timer SEND_NOTIFICATION_TIMER = Metrics.timer(name(APNSender.class, \"sendNotification\"));\n\n  public APNSender(ExecutorService executor, ApnConfiguration configuration)\n      throws IOException, NoSuchAlgorithmException, InvalidKeyException\n  {\n    this.executor = executor;\n    this.bundleId = configuration.bundleId();\n    this.apnsClient = new ApnsClientBuilder().setSigningKey(\n            ApnsSigningKey.loadFromInputStream(new ByteArrayInputStream(configuration.signingKey().value().getBytes()),\n                configuration.teamId().value(), configuration.keyId().value()))\n        .setTrustedServerCertificateChain(getClass().getResourceAsStream(APNS_CA_FILENAME))\n        .setApnsServer(configuration.sandbox() ? ApnsClientBuilder.DEVELOPMENT_APNS_HOST : ApnsClientBuilder.PRODUCTION_APNS_HOST)\n        .build();\n  }\n\n  @VisibleForTesting\n  public APNSender(ExecutorService executor, ApnsClient apnsClient, String bundleId) {\n    this.executor = executor;\n    this.apnsClient = apnsClient;\n    this.bundleId = bundleId;\n  }\n\n  @Override\n  public CompletableFuture<SendPushNotificationResult> sendNotification(final PushNotification notification) {\n    final String payload = switch (notification.notificationType()) {\n      case NOTIFICATION -> notification.urgent() ? APN_NSE_NOTIFICATION_PAYLOAD : APN_BACKGROUND_PAYLOAD;\n\n      case ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY -> new SimpleApnsPayloadBuilder()\n          .setMutableContent(true)\n          .setLocalizedAlertMessage(\"APN_Message\")\n          .addCustomProperty(\"attemptLoginContext\", notification.data())\n          .build();\n\n      case CHALLENGE -> new SimpleApnsPayloadBuilder()\n          .setContentAvailable(true)\n          .addCustomProperty(\"challenge\", notification.data())\n          .build();\n\n      case RATE_LIMIT_CHALLENGE -> new SimpleApnsPayloadBuilder()\n          .setContentAvailable(true)\n          .addCustomProperty(\"rateLimitChallenge\", notification.data())\n          .build();\n    };\n\n    final PushType pushType = switch (notification.notificationType()) {\n      case NOTIFICATION -> notification.urgent() ? PushType.ALERT : PushType.BACKGROUND;\n      case ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY -> PushType.ALERT;\n      case CHALLENGE, RATE_LIMIT_CHALLENGE -> PushType.BACKGROUND;\n    };\n\n    final DeliveryPriority deliveryPriority;\n\n    if (pushType == PushType.BACKGROUND) {\n      deliveryPriority = DeliveryPriority.CONSERVE_POWER;\n    } else {\n      deliveryPriority = notification.urgent() ? DeliveryPriority.IMMEDIATE : DeliveryPriority.CONSERVE_POWER;\n    }\n\n    final String collapseId =\n        (notification.notificationType() == PushNotification.NotificationType.NOTIFICATION && notification.urgent())\n            ? \"incoming-message\" : null;\n\n    final Instant start = Instant.now();\n\n    return apnsClient.sendNotification(new SimpleApnsPushNotification(notification.deviceToken(),\n        bundleId,\n        payload,\n        MAX_EXPIRATION,\n        deliveryPriority,\n        pushType,\n        collapseId))\n        .whenComplete((response, throwable) -> {\n          // Note that we deliberately run this small bit of non-blocking measurement on the \"send notification\" thread\n          // to avoid any measurement noise that could arise from dispatching to another executor and waiting in its\n          // queue\n          SEND_NOTIFICATION_TIMER.record(Duration.between(start, Instant.now()));\n        })\n        .thenApplyAsync(response -> {\n          final boolean accepted;\n          final Optional<String> rejectionReason;\n          final boolean unregistered;\n\n          if (response.isAccepted()) {\n            accepted = true;\n            rejectionReason = Optional.empty();\n            unregistered = false;\n          } else {\n            accepted = false;\n            rejectionReason = response.getRejectionReason();\n            unregistered = response.getRejectionReason().map(reason -> \"Unregistered\".equals(reason) || \"BadDeviceToken\".equals(reason) || \"ExpiredToken\".equals(reason))\n                .orElse(false);\n          }\n\n          return new SendPushNotificationResult(accepted, rejectionReason, unregistered, response.getTokenInvalidationTimestamp());\n        }, executor);\n  }\n\n  @Override\n  public void start() {\n  }\n\n  @Override\n  public void stop() {\n    this.apnsClient.close().join();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/FcmSender.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.auth.oauth2.GoogleCredentials;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.util.concurrent.ThreadFactoryBuilder;\nimport com.google.firebase.FirebaseApp;\nimport com.google.firebase.FirebaseOptions;\nimport com.google.firebase.ThreadManager;\nimport com.google.firebase.messaging.AndroidConfig;\nimport com.google.firebase.messaging.FirebaseMessaging;\nimport com.google.firebase.messaging.FirebaseMessagingException;\nimport com.google.firebase.messaging.Message;\nimport com.google.firebase.messaging.MessagingErrorCode;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ThreadFactory;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.GoogleApiUtil;\n\npublic class FcmSender implements PushNotificationSender {\n\n  private final ExecutorService executor;\n  private final FirebaseMessaging firebaseMessagingClient;\n\n  private static final Timer SEND_NOTIFICATION_TIMER = Metrics.timer(name(FcmSender.class, \"sendNotification\"));\n\n  private static final Logger logger = LoggerFactory.getLogger(FcmSender.class);\n\n  public FcmSender(ExecutorService executor, String credentials) throws IOException {\n    try (final ByteArrayInputStream credentialInputStream = new ByteArrayInputStream(credentials.getBytes(StandardCharsets.UTF_8))) {\n      FirebaseApp.initializeApp(FirebaseOptions.builder()\n          .setCredentials(GoogleCredentials.fromStream(credentialInputStream))\n          .setThreadManager(new ThreadManager() {\n            @Override\n            protected ExecutorService getExecutor(final FirebaseApp app) {\n              return executor;\n            }\n\n            @Override\n            protected void releaseExecutor(final FirebaseApp app, final ExecutorService executor) {\n              // Do nothing; the executor service is managed by Dropwizard\n            }\n\n            @Override\n            protected ThreadFactory getThreadFactory() {\n              return new ThreadFactoryBuilder()\n                  .setNameFormat(\"firebase-%d\")\n                  .build();\n            }\n          })\n          .build());\n    }\n\n    this.executor = executor;\n    this.firebaseMessagingClient = FirebaseMessaging.getInstance();\n  }\n\n  @VisibleForTesting\n  public FcmSender(ExecutorService executor, FirebaseMessaging firebaseMessagingClient) {\n    this.executor = executor;\n    this.firebaseMessagingClient = firebaseMessagingClient;\n  }\n\n  @Override\n  public CompletableFuture<SendPushNotificationResult> sendNotification(PushNotification pushNotification) {\n    Message.Builder builder = Message.builder()\n        .setToken(pushNotification.deviceToken())\n        .setAndroidConfig(AndroidConfig.builder()\n            .setPriority(pushNotification.urgent() ? AndroidConfig.Priority.HIGH : AndroidConfig.Priority.NORMAL)\n            .build());\n\n    final String key = switch (pushNotification.notificationType()) {\n      case NOTIFICATION -> \"newMessageAlert\";\n      case ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY -> \"attemptLoginContext\";\n      case CHALLENGE -> \"challenge\";\n      case RATE_LIMIT_CHALLENGE -> \"rateLimitChallenge\";\n    };\n\n    builder.putData(key, pushNotification.data() != null ? pushNotification.data() : \"\");\n\n    final Timer.Sample sample = Timer.start();\n\n    return GoogleApiUtil.toCompletableFuture(firebaseMessagingClient.sendAsync(builder.build()), executor)\n        .whenComplete((ignored, throwable) -> sample.stop(SEND_NOTIFICATION_TIMER))\n        .thenApply(ignored -> new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))\n        .exceptionally(ExceptionUtils.exceptionallyHandler(FirebaseMessagingException.class,\n            firebaseMessagingException -> {\n              final String errorCode;\n\n              if (firebaseMessagingException.getMessagingErrorCode() != null) {\n                errorCode = firebaseMessagingException.getMessagingErrorCode().name();\n              } else if (firebaseMessagingException.getHttpResponse() != null) {\n                errorCode = \"http\" + firebaseMessagingException.getHttpResponse().getStatusCode();\n              } else {\n                logger.warn(\"Received an FCM exception with no error code\", firebaseMessagingException);\n                errorCode = \"unknown\";\n              }\n\n              final boolean unregistered =\n                  firebaseMessagingException.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED;\n\n              return new SendPushNotificationResult(false, Optional.of(errorCode), unregistered, Optional.empty());\n            }));\n  }\n}\n\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/IdleDeviceNotificationScheduler.java",
    "content": "package org.whispersystems.textsecuregcm.push;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.annotations.VisibleForTesting;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.scheduler.JobScheduler;\nimport org.whispersystems.textsecuregcm.scheduler.SchedulingUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport javax.annotation.Nullable;\nimport java.io.IOException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.LocalTime;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\n\npublic class IdleDeviceNotificationScheduler extends JobScheduler {\n\n  private final AccountsManager accountsManager;\n  private final PushNotificationManager pushNotificationManager;\n  private final Clock clock;\n\n  @VisibleForTesting\n  record JobDescriptor(UUID accountIdentifier, byte deviceId, long lastSeen) {}\n\n  public IdleDeviceNotificationScheduler(final AccountsManager accountsManager,\n      final PushNotificationManager pushNotificationManager,\n      final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final String tableName,\n      final Duration jobExpiration,\n      final Clock clock) {\n\n    super(dynamoDbAsyncClient, tableName, jobExpiration, clock);\n\n    this.accountsManager = accountsManager;\n    this.pushNotificationManager = pushNotificationManager;\n    this.clock = clock;\n  }\n\n  @Override\n  public String getSchedulerName() {\n    return \"IdleDeviceNotification\";\n  }\n\n  @Override\n  protected CompletableFuture<String> processJob(@Nullable final byte[] jobData) {\n    final JobDescriptor jobDescriptor;\n\n    try {\n      jobDescriptor = SystemMapper.jsonMapper().readValue(jobData, JobDescriptor.class);\n    } catch (final IOException e) {\n      return CompletableFuture.failedFuture(e);\n    }\n\n    return accountsManager.getByAccountIdentifierAsync(jobDescriptor.accountIdentifier())\n        .thenCompose(maybeAccount -> maybeAccount.map(account ->\n                account.getDevice(jobDescriptor.deviceId()).map(device -> {\n                      if (jobDescriptor.lastSeen() != device.getLastSeen()) {\n                        return CompletableFuture.completedFuture(\"deviceSeenRecently\");\n                      }\n\n                      try {\n                        return pushNotificationManager\n                            .sendNewMessageNotification(account, jobDescriptor.deviceId(), true)\n                            .thenApply(ignored -> \"sent\");\n                      } catch (final NotPushRegisteredException e) {\n                        return CompletableFuture.completedFuture(\"deviceTokenDeleted\");\n                      }\n                    })\n                    .orElse(CompletableFuture.completedFuture(\"deviceDeleted\")))\n            .orElse(CompletableFuture.completedFuture(\"accountDeleted\")));\n  }\n\n  public CompletableFuture<Void> scheduleNotification(final Account account, final Device device, final LocalTime preferredDeliveryTime) {\n    final Instant runAt = SchedulingUtil.getNextRecommendedNotificationTime(account, preferredDeliveryTime, clock);\n\n    try {\n      return scheduleJob(runAt, SystemMapper.jsonMapper().writeValueAsBytes(\n          new JobDescriptor(account.getIdentifier(IdentityType.ACI), device.getId(), device.getLastSeen())));\n    } catch (final JsonProcessingException e) {\n      // This should never happen when serializing an `AccountAndDeviceIdentifier`\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/MessageAvailabilityListener.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport java.util.UUID;\n\n/**\n * A message availability listener handles message availability and presence events related to a client's open message\n * stream. Handler methods are run on dedicated threads and may safely perform blocking operations.\n * \n * @see RedisMessageAvailabilityManager#handleClientConnected(UUID, byte, MessageAvailabilityListener)\n */\npublic interface MessageAvailabilityListener {\n\n  /**\n   * Indicates that a new message is available in the connected client's message queue.\n   */\n  void handleNewMessageAvailable();\n\n  /**\n   * Indicates that messages for the client have been persisted from short-term storage to long-term storage.\n   */\n  void handleMessagesPersisted();\n\n  /**\n   * Indicates a newer instance of this client has started reading messages and the listener should close this client's\n   * underlying network connection.\n   */\n  void handleConflictingMessageConsumer();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/MessageSender.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\nimport static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.util.DataSize;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nullable;\nimport kotlin.Pair;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevices;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.MultiRecipientMismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.spam.MessageDeliveryListener;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n/**\n * A MessageSender sends Signal messages to destination devices. Messages may be \"normal\" user-to-user messages,\n * ephemeral (\"online\") messages like typing indicators, or delivery receipts.\n * <p/>\n * If a client is not actively connected to a Signal server to receive a message as soon as it is sent, the\n * MessageSender will send a push notification to the destination device if possible. Some messages may be designated\n * for \"online\" delivery only and will not be delivered (and clients will not be notified) if the destination device\n * isn't actively connected to a Signal server.\n *\n * @see ReceiptSender\n */\npublic class MessageSender {\n\n  private final MessagesManager messagesManager;\n  private final PushNotificationManager pushNotificationManager;\n\n  private final List<MessageDeliveryListener> messageDeliveryListeners = new ArrayList<>();\n\n  // Note that these names deliberately reference `MessageController` for metric continuity\n  private static final String REJECT_OVERSIZE_MESSAGE_COUNTER_NAME = name(MessageSender.class, \"rejectOversizeMessage\");\n  private static final String OVERSIZE_MESSAGE_WARNING_COUNTER_NAME = name(MessageSender.class, \"oversizeMessageWarning\");\n  private static final String CONTENT_SIZE_DISTRIBUTION_NAME = MetricsUtil.name(MessageSender.class, \"messageContentSize\");\n  private static final String EMPTY_MESSAGE_LIST_COUNTER_NAME = MetricsUtil.name(MessageSender.class, \"emptyMessageList\");\n\n  private static final String SEND_COUNTER_NAME = name(MessageSender.class, \"sendMessage\");\n  private static final String EPHEMERAL_TAG_NAME = \"ephemeral\";\n  private static final String CLIENT_ONLINE_TAG_NAME = \"clientOnline\";\n  private static final String URGENT_TAG_NAME = \"urgent\";\n  private static final String STORY_TAG_NAME = \"story\";\n  private static final String SEALED_SENDER_TAG_NAME = \"sealedSender\";\n  private static final String MULTI_RECIPIENT_TAG_NAME = \"multiRecipient\";\n  private static final String SYNC_MESSAGE_TAG_NAME = \"sync\";\n\n  @VisibleForTesting\n  public static final int MAX_MESSAGE_SIZE = (int) DataSize.kibibytes(256).toBytes();\n\n  private static final int OVERSIZE_MESSAGE_WARNING_THRESHOLD = (int) DataSize.kibibytes(96).toBytes();\n\n  @VisibleForTesting\n  static final byte NO_EXCLUDED_DEVICE_ID = -1;\n\n  public MessageSender(final MessagesManager messagesManager, final PushNotificationManager pushNotificationManager) {\n    this.messagesManager = messagesManager;\n    this.pushNotificationManager = pushNotificationManager;\n  }\n\n  public void addMessageDeliveryListener(final MessageDeliveryListener messageDeliveryListener) {\n    messageDeliveryListeners.add(messageDeliveryListener);\n  }\n\n  /**\n   * Sends messages to devices associated with the given destination account. If a destination device has a valid push\n   * notification token and does not have an active connection to a Signal server, then this method will also send a\n   * push notification to that device to announce the availability of new messages.\n   *\n   * @param destination the account to which to send messages\n   * @param destinationIdentifier the service identifier to which the messages are addressed\n   * @param messagesByDeviceId a map of device IDs to message payloads\n   * @param registrationIdsByDeviceId a map of device IDs to device registration IDs\n   * @param syncMessageSenderDeviceId if the message is a sync message (i.e. a message to other devices linked to the\n   *                                  caller's own account), contains the ID of the device that sent the message\n   * @param userAgent the User-Agent string for the sender; may be {@code null} if not known\n   *\n   * @throws MismatchedDevicesException if the given bundle of messages did not include a message for all required\n   * devices, contained messages for devices not linked to the destination account, or devices with outdated\n   * registration IDs\n   * @throws MessageTooLargeException if the given message payload is too large\n   */\n  public void sendMessages(final Account destination,\n      final ServiceIdentifier destinationIdentifier,\n      final Map<Byte, Envelope> messagesByDeviceId,\n      final Map<Byte, Integer> registrationIdsByDeviceId,\n      @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\") final Optional<Byte> syncMessageSenderDeviceId,\n      @Nullable final String userAgent) throws MismatchedDevicesException, MessageTooLargeException {\n\n    final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);\n\n    validateIndividualMessageBundle(destination,\n        destinationIdentifier,\n        messagesByDeviceId,\n        registrationIdsByDeviceId,\n        syncMessageSenderDeviceId,\n        platformTag);\n\n    messagesManager.insert(destination.getIdentifier(IdentityType.ACI), messagesByDeviceId)\n        .forEach((deviceId, destinationPresent) -> {\n          final Envelope message = messagesByDeviceId.get(deviceId);\n\n          if (!destinationPresent && !message.getEphemeral()) {\n            try {\n              pushNotificationManager.sendNewMessageNotification(destination, deviceId, message.getUrgent());\n            } catch (final NotPushRegisteredException ignored) {\n            }\n          }\n\n          final Tags tags = Tags.of(\n                  EPHEMERAL_TAG_NAME, String.valueOf(message.getEphemeral()),\n                  CLIENT_ONLINE_TAG_NAME, String.valueOf(destinationPresent),\n                  URGENT_TAG_NAME, String.valueOf(message.getUrgent()),\n                  STORY_TAG_NAME, String.valueOf(message.getStory()),\n                  SEALED_SENDER_TAG_NAME, String.valueOf(!message.hasSourceServiceId()),\n                  SYNC_MESSAGE_TAG_NAME, String.valueOf(syncMessageSenderDeviceId.isPresent()),\n                  MULTI_RECIPIENT_TAG_NAME, \"false\")\n              .and(platformTag);\n\n          Metrics.counter(SEND_COUNTER_NAME, tags).increment();\n\n          messageDeliveryListeners.forEach(messageDeliveryListener ->\n              messageDeliveryListener.handleMessageDelivered(destination,\n                  deviceId,\n                  message.getEphemeral(),\n                  message.getUrgent(),\n                  message.getStory(),\n                  !message.hasSourceServiceId(),\n                  false,\n                  syncMessageSenderDeviceId.isPresent()));\n        });\n  }\n\n  /**\n   * Sends messages to a group of recipients. If a destination device has a valid push notification token and does not\n   * have an active connection to a Signal server, then this method will also send a push notification to that device to\n   * announce the availability of new messages.\n   * <p>\n   * This method sends messages to all <em>resolved</em> recipients. In some cases, a caller may not be able to resolve\n   * all recipients to active accounts, but may still choose to send the message. Callers are responsible for rejecting\n   * the message if they require full resolution of all recipients, but some recipients could not be resolved.\n   *\n   * @param multiRecipientMessage the multi-recipient message to send to the given recipients\n   * @param resolvedRecipients a map of recipients to resolved Signal accounts\n   * @param clientTimestamp the time at which the sender reports the message was sent\n   * @param isStory {@code true} if the message is a story or {@code false otherwise}\n   * @param isEphemeral {@code true} if the message should only be delivered to devices with active connections or\n   * {@code false otherwise}\n   * @param isUrgent {@code true} if the message is urgent or {@code false otherwise}\n   * @param userAgent the User-Agent string for the sender; may be {@code null} if not known\n   *\n   * @return a future that completes when all messages have been inserted into delivery queues\n   *\n   * @throws MultiRecipientMismatchedDevicesException if the given multi-recipient message had did not have all required\n   * recipient devices for a recipient account, contained recipients for devices not linked to a destination account, or\n   * recipient devices with outdated registration IDs\n   * @throws MessageTooLargeException if the given message payload is too large\n   */\n  public CompletableFuture<Void> sendMultiRecipientMessage(final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients,\n      final long clientTimestamp,\n      final boolean isStory,\n      final boolean isEphemeral,\n      final boolean isUrgent,\n      @Nullable final String userAgent) throws MultiRecipientMismatchedDevicesException, MessageTooLargeException {\n\n    final Tag platformTag = UserAgentTagUtil.getPlatformTag(userAgent);\n\n    validateMultiRecipientMessageContentLength(multiRecipientMessage, isStory, platformTag);\n\n    final Map<ServiceIdentifier, MismatchedDevices> mismatchedDevicesByServiceIdentifier = new HashMap<>();\n\n    multiRecipientMessage.getRecipients().forEach((serviceId, recipient) -> {\n      if (!resolvedRecipients.containsKey(recipient)) {\n        // Callers are responsible for rejecting messages if they're missing recipients in a problematic way. If we run\n        // into an unresolved recipient here, just skip it.\n        return;\n      }\n\n      final Account account = resolvedRecipients.get(recipient);\n      final ServiceIdentifier serviceIdentifier = ServiceIdentifier.fromLibsignal(serviceId);\n\n      final Map<Byte, Integer> registrationIdsByDeviceId = recipient.getDevicesAndRegistrationIds()\n          .collect(Collectors.toMap(Pair::getFirst, pair -> (int) pair.getSecond()));\n\n      getMismatchedDevices(account, serviceIdentifier, registrationIdsByDeviceId, NO_EXCLUDED_DEVICE_ID)\n          .ifPresent(mismatchedDevices ->\n              mismatchedDevicesByServiceIdentifier.put(serviceIdentifier, mismatchedDevices));\n    });\n\n    if (!mismatchedDevicesByServiceIdentifier.isEmpty()) {\n      throw new MultiRecipientMismatchedDevicesException(mismatchedDevicesByServiceIdentifier);\n    }\n\n    return messagesManager.insertMultiRecipientMessage(multiRecipientMessage, resolvedRecipients, clientTimestamp,\n            isStory, isEphemeral, isUrgent)\n        .thenAccept(clientPresenceByAccountAndDevice ->\n            clientPresenceByAccountAndDevice.forEach((account, clientPresenceByDeviceId) ->\n                clientPresenceByDeviceId.forEach((deviceId, clientPresent) -> {\n                  if (!clientPresent && !isEphemeral) {\n                    try {\n                      pushNotificationManager.sendNewMessageNotification(account, deviceId, isUrgent);\n                    } catch (final NotPushRegisteredException ignored) {\n                    }\n                  }\n\n                  final Tags tags = Tags.of(\n                          EPHEMERAL_TAG_NAME, String.valueOf(isEphemeral),\n                          CLIENT_ONLINE_TAG_NAME, String.valueOf(clientPresent),\n                          URGENT_TAG_NAME, String.valueOf(isUrgent),\n                          STORY_TAG_NAME, String.valueOf(isStory),\n                          SEALED_SENDER_TAG_NAME, \"true\",\n                          SYNC_MESSAGE_TAG_NAME, \"false\",\n                          MULTI_RECIPIENT_TAG_NAME, \"true\")\n                      .and(platformTag);\n\n                  Metrics.counter(SEND_COUNTER_NAME, tags).increment();\n\n                  messageDeliveryListeners.forEach(messageDeliveryListener ->\n                      messageDeliveryListener.handleMessageDelivered(account,\n                          deviceId,\n                          isEphemeral,\n                          isUrgent,\n                          isStory,\n                          true,\n                          true,\n                          false));\n                })))\n        .thenRun(Util.NOOP);\n  }\n\n  /**\n   * Validates that a bundle of messages destined for an individual account is well-formed and may be delivered. Note\n   * that all checks performed by this method are also performed by\n   * {@link #sendMessages(Account, ServiceIdentifier, Map, Map, Optional, String)}; callers should only invoke this\n   * method if they need to verify that a bundle of individual messages is valid <em>before</em> trying to send the\n   * messages (i.e. if the caller must take some other action in conjunction with sending the messages and cannot\n   * reverse that action if message sending fails).\n   *\n   * @param destination the account to which to send messages\n   * @param destinationIdentifier the service identifier to which the messages are addressed\n   * @param messagesByDeviceId a map of device IDs to message payloads\n   * @param registrationIdsByDeviceId a map of device IDs to device registration IDs\n   * @param syncMessageSenderDeviceId if the message is a sync message (i.e. a message to other devices linked to the\n   *                                  caller's own account), contains the ID of the device that sent the message\n   * @param userAgent the User-Agent string for the sender; may be {@code null} if not known\n   *\n   * @throws MismatchedDevicesException if the given bundle of messages did not include a message for all required\n   * devices, contained messages for devices not linked to the destination account, or devices with outdated\n   * registration IDs\n   * @throws MessageTooLargeException if the given message payload is too large\n   */\n  public static void validateIndividualMessageBundle(final Account destination,\n      final ServiceIdentifier destinationIdentifier,\n      final Map<Byte, Envelope> messagesByDeviceId,\n      final Map<Byte, Integer> registrationIdsByDeviceId,\n      @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\") final Optional<Byte> syncMessageSenderDeviceId,\n      @Nullable final String userAgent) throws MessageTooLargeException, MismatchedDevicesException {\n\n    validateIndividualMessageBundle(destination,\n        destinationIdentifier,\n        messagesByDeviceId,\n        registrationIdsByDeviceId,\n        syncMessageSenderDeviceId,\n        UserAgentTagUtil.getPlatformTag(userAgent));\n  }\n\n  private static void validateIndividualMessageBundle(final Account destination,\n      final ServiceIdentifier destinationIdentifier,\n      final Map<Byte, Envelope> messagesByDeviceId,\n      final Map<Byte, Integer> registrationIdsByDeviceId,\n      @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\") final Optional<Byte> syncMessageSenderDeviceId,\n      final Tag platformTag) throws MismatchedDevicesException, MessageTooLargeException {\n\n    if (!destination.isIdentifiedBy(destinationIdentifier)) {\n      throw new IllegalArgumentException(\"Destination account not identified by destination service identifier\");\n    }\n\n    if (messagesByDeviceId.isEmpty()) {\n      Metrics.counter(EMPTY_MESSAGE_LIST_COUNTER_NAME,\n          Tags.of(SYNC_MESSAGE_TAG_NAME, String.valueOf(syncMessageSenderDeviceId.isPresent())).and(platformTag)).increment();\n    }\n\n    final byte excludedDeviceId;\n    if (syncMessageSenderDeviceId.isPresent()) {\n      if (messagesByDeviceId.values().stream().anyMatch(message -> StringUtils.isBlank(message.getSourceServiceId()) ||\n          !destination.isIdentifiedBy(ServiceIdentifier.valueOf(message.getSourceServiceId())))) {\n\n        throw new IllegalArgumentException(\"Sync message sender device ID specified, but one or more messages are not addressed to sender\");\n      }\n      excludedDeviceId = syncMessageSenderDeviceId.get();\n    } else {\n      if (messagesByDeviceId.values().stream().anyMatch(message -> StringUtils.isNotBlank(message.getSourceServiceId()) &&\n          destination.isIdentifiedBy(ServiceIdentifier.valueOf(message.getSourceServiceId())))) {\n\n        throw new IllegalArgumentException(\"Sync message sender device ID not specified, but one or more messages are addressed to sender\");\n      }\n      excludedDeviceId = NO_EXCLUDED_DEVICE_ID;\n    }\n\n    final Optional<MismatchedDevices> maybeMismatchedDevices = getMismatchedDevices(destination,\n        destinationIdentifier,\n        registrationIdsByDeviceId,\n        excludedDeviceId);\n\n    if (maybeMismatchedDevices.isPresent()) {\n      throw new MismatchedDevicesException(maybeMismatchedDevices.get());\n    }\n\n    validateIndividualMessageContentLength(messagesByDeviceId.values(), syncMessageSenderDeviceId.isPresent(), platformTag);\n  }\n\n  @VisibleForTesting\n  static void validateContentLength(final int contentLength,\n      final boolean isMultiRecipientMessage,\n      final boolean isSyncMessage,\n      final boolean isStory,\n      final Tag platformTag) throws MessageTooLargeException {\n\n    final boolean oversize = contentLength > MAX_MESSAGE_SIZE;\n\n    DistributionSummary.builder(CONTENT_SIZE_DISTRIBUTION_NAME)\n        .tags(Tags.of(platformTag,\n            Tag.of(\"oversize\", String.valueOf(oversize)),\n            Tag.of(\"multiRecipientMessage\", String.valueOf(isMultiRecipientMessage)),\n            Tag.of(\"syncMessage\", String.valueOf(isSyncMessage)),\n            Tag.of(\"story\", String.valueOf(isStory))))\n        .register(Metrics.globalRegistry)\n        .record(contentLength);\n\n    if (contentLength > OVERSIZE_MESSAGE_WARNING_THRESHOLD) {\n      Metrics.counter(OVERSIZE_MESSAGE_WARNING_COUNTER_NAME, Tags.of(platformTag,\n              Tag.of(\"multiRecipientMessage\", String.valueOf(isMultiRecipientMessage)),\n              Tag.of(\"syncMessage\", String.valueOf(isSyncMessage)),\n              Tag.of(\"story\", String.valueOf(isStory))))\n          .increment();\n    }\n\n    if (oversize) {\n      Metrics.counter(REJECT_OVERSIZE_MESSAGE_COUNTER_NAME, Tags.of(platformTag,\n              Tag.of(\"multiRecipientMessage\", String.valueOf(isMultiRecipientMessage)),\n              Tag.of(\"syncMessage\", String.valueOf(isSyncMessage)),\n              Tag.of(\"story\", String.valueOf(isStory))))\n          .increment();\n\n      throw new MessageTooLargeException();\n    }\n  }\n\n  @VisibleForTesting\n  static Optional<MismatchedDevices> getMismatchedDevices(final Account account,\n      final ServiceIdentifier serviceIdentifier,\n      final Map<Byte, Integer> registrationIdsByDeviceId,\n      final byte excludedDeviceId) {\n\n    final Set<Byte> accountDeviceIds = account.getDevices().stream()\n        .map(Device::getId)\n        .filter(deviceId -> deviceId != excludedDeviceId)\n        .collect(Collectors.toSet());\n\n    final Set<Byte> missingDeviceIds = new HashSet<>(accountDeviceIds);\n    missingDeviceIds.removeAll(registrationIdsByDeviceId.keySet());\n\n    final Set<Byte> extraDeviceIds = new HashSet<>(registrationIdsByDeviceId.keySet());\n    extraDeviceIds.removeAll(accountDeviceIds);\n\n    final Set<Byte> staleDeviceIds = registrationIdsByDeviceId.entrySet().stream()\n        // Filter out device IDs that aren't associated with the given account\n        .filter(entry -> !extraDeviceIds.contains(entry.getKey()))\n        .filter(entry -> {\n          final byte deviceId = entry.getKey();\n          final int registrationId = entry.getValue();\n\n          // We know the device must be present because we've already filtered out device IDs that aren't associated\n          // with the given account\n          final Device device = account.getDevice(deviceId).orElseThrow();\n          final int expectedRegistrationId = device.getRegistrationId(serviceIdentifier.identityType());\n\n          return registrationId != expectedRegistrationId;\n        })\n        .map(Map.Entry::getKey)\n        .collect(Collectors.toSet());\n\n    return (!missingDeviceIds.isEmpty() || !extraDeviceIds.isEmpty() || !staleDeviceIds.isEmpty())\n        ? Optional.of(new MismatchedDevices(missingDeviceIds, extraDeviceIds, staleDeviceIds))\n        : Optional.empty();\n  }\n\n  private static void validateIndividualMessageContentLength(final Iterable<Envelope> messages,\n      final boolean isSyncMessage,\n      final Tag platformTag) throws MessageTooLargeException {\n\n    for (final Envelope message : messages) {\n      MessageSender.validateContentLength(message.getContent().size(),\n          false,\n          isSyncMessage,\n          message.getStory(),\n          platformTag);\n    }\n  }\n\n  private static void validateMultiRecipientMessageContentLength(final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final boolean isStory,\n      final Tag platformTag) throws MessageTooLargeException {\n\n    for (final SealedSenderMultiRecipientMessage.Recipient recipient : multiRecipientMessage.getRecipients().values()) {\n      MessageSender.validateContentLength(multiRecipientMessage.messageSizeForRecipient(recipient),\n          true,\n          false,\n          isStory,\n          platformTag);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/MessageTooLargeException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport org.whispersystems.textsecuregcm.util.NoStackTraceException;\n\npublic class MessageTooLargeException extends NoStackTraceException {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/MessageUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.function.Tuple2;\nimport reactor.util.function.Tuples;\n\npublic class MessageUtil {\n\n  public static final int DEFAULT_MAX_FETCH_ACCOUNT_CONCURRENCY = 8;\n\n  private MessageUtil() {\n  }\n\n  /**\n   * Finds account records for all recipients named in the given multi-recipient manager. Note that the returned map\n   * of recipients to account records will omit entries for recipients that could not be resolved to active accounts;\n   * callers that require full resolution should check for a missing entries and take appropriate action.\n   *\n   * @param accountsManager the {@code AccountsManager} instance to use to find account records\n   * @param multiRecipientMessage the message for which to resolve recipients\n   *\n   * @return a map of recipients to account records\n   *\n   * @see #getUnresolvedRecipients(SealedSenderMultiRecipientMessage, Map)\n   */\n  public static Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolveRecipients(\n      final AccountsManager accountsManager,\n      final SealedSenderMultiRecipientMessage multiRecipientMessage) {\n\n    return resolveRecipients(accountsManager, multiRecipientMessage, DEFAULT_MAX_FETCH_ACCOUNT_CONCURRENCY);\n  }\n\n  /**\n   * Finds account records for all recipients named in the given multi-recipient manager. Note that the returned map\n   * of recipients to account records will omit entries for recipients that could not be resolved to active accounts;\n   * callers that require full resolution should check for a missing entries and take appropriate action.\n   *\n   * @param accountsManager the {@code AccountsManager} instance to use to find account records\n   * @param multiRecipientMessage the message for which to resolve recipients\n   * @param maxFetchAccountConcurrency the maximum number of concurrent account-retrieval operations\n   *\n   * @return a map of recipients to account records\n   *\n   * @see #getUnresolvedRecipients(SealedSenderMultiRecipientMessage, Map)\n   */\n  public static Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolveRecipients(\n      final AccountsManager accountsManager,\n      final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final int maxFetchAccountConcurrency) {\n\n    return Flux.fromIterable(multiRecipientMessage.getRecipients().entrySet())\n        .flatMap(serviceIdAndRecipient -> {\n          final ServiceIdentifier serviceIdentifier =\n              ServiceIdentifier.fromLibsignal(serviceIdAndRecipient.getKey());\n\n          return Mono.fromFuture(() -> accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n              .flatMap(Mono::justOrEmpty)\n              .map(account -> Tuples.of(serviceIdAndRecipient.getValue(), account));\n        }, maxFetchAccountConcurrency)\n        .collectMap(Tuple2::getT1, Tuple2::getT2)\n        .blockOptional()\n        .orElse(Collections.emptyMap());\n  }\n\n  /**\n   * Returns a list of recipients missing from the map of resolved recipients for a multi-recipient message.\n   *\n   * @param multiRecipientMessage the multi-recipient message\n   * @param resolvedRecipients the map of resolved recipients to check for missing entries\n   *\n   * @return a list of {@code ServiceIdentifiers} belonging to multi-recipient message recipients that are not present\n   * in the given map of {@code resolvedRecipients}\n   */\n  public static List<ServiceIdentifier> getUnresolvedRecipients(\n      final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients) {\n\n    return multiRecipientMessage.getRecipients().entrySet().stream()\n        .filter(entry -> !resolvedRecipients.containsKey(entry.getValue()))\n        .map(entry -> ServiceIdentifier.fromLibsignal(entry.getKey()))\n        .toList();\n  }\n\n  /**\n   * Checks if a multi-recipient message contains duplicate recipients.\n   *\n   * @param multiRecipientMessage the message to check for duplicate recipients\n   *\n   * @return {@code true} if the message contains duplicate recipients or {@code false} otherwise\n   */\n  public static boolean hasDuplicateDevices(final SealedSenderMultiRecipientMessage multiRecipientMessage) {\n    final boolean[] usedDeviceIds = new boolean[Device.MAXIMUM_DEVICE_ID + 1];\n\n    for (final SealedSenderMultiRecipientMessage.Recipient recipient : multiRecipientMessage.getRecipients().values()) {\n      if (recipient.getDevices().length == 1) {\n        // A recipient can't have repeated devices if they only have one device\n        continue;\n      }\n\n      Arrays.fill(usedDeviceIds, false);\n\n      for (final byte deviceId : recipient.getDevices()) {\n        if (usedDeviceIds[deviceId]) {\n          return true;\n        }\n\n        usedDeviceIds[deviceId] = true;\n      }\n    }\n\n    return false;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/NotPushRegisteredException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\npublic class NotPushRegisteredException extends Exception {\n  public NotPushRegisteredException() {\n    super();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/ProvisioningManager.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.pubsub.RedisPubSubAdapter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.function.Consumer;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.RedisOperation;\nimport org.whispersystems.textsecuregcm.storage.PubSubProtos;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\npublic class ProvisioningManager extends RedisPubSubAdapter<byte[], byte[]> implements Managed {\n\n  private final FaultTolerantRedisClient pubSubClient;\n  private final FaultTolerantPubSubConnection<byte[], byte[]> pubSubConnection;\n\n  private final Map<String, Consumer<PubSubProtos.PubSubMessage>> listenersByProvisioningAddress =\n      new ConcurrentHashMap<>();\n\n  private static final String ACTIVE_LISTENERS_GAUGE_NAME = name(ProvisioningManager.class, \"activeListeners\");\n\n  private static final String SEND_PROVISIONING_MESSAGE_COUNTER_NAME =\n      name(ProvisioningManager.class, \"sendProvisioningMessage\");\n\n  private static final String RECEIVE_PROVISIONING_MESSAGE_COUNTER_NAME =\n      name(ProvisioningManager.class, \"receiveProvisioningMessage\");\n\n  private static final String RETRY_NAME = ResilienceUtil.name(ProvisioningManager.class);\n\n  private static final Logger logger = LoggerFactory.getLogger(ProvisioningManager.class);\n\n  public ProvisioningManager(final FaultTolerantRedisClient pubSubClient) {\n    this.pubSubClient = pubSubClient;\n    this.pubSubConnection = pubSubClient.createBinaryPubSubConnection();\n\n    Metrics.gaugeMapSize(ACTIVE_LISTENERS_GAUGE_NAME, Tags.empty(), listenersByProvisioningAddress);\n  }\n\n  @Override\n  public void start() throws Exception {\n    pubSubConnection.usePubSubConnection(connection -> connection.addListener(this));\n  }\n\n  @Override\n  public void stop() throws Exception {\n    pubSubConnection.usePubSubConnection(connection -> connection.removeListener(this));\n  }\n\n  public void addListener(final String address, final Consumer<PubSubProtos.PubSubMessage> listener) {\n    listenersByProvisioningAddress.put(address, listener);\n    pubSubConnection.usePubSubConnection(connection -> connection.sync().subscribe(address.getBytes(StandardCharsets.UTF_8)));\n  }\n\n  public void removeListener(final String address) {\n    RedisOperation.unchecked(() ->\n        pubSubConnection.usePubSubConnection(connection -> connection.sync().unsubscribe(address.getBytes(StandardCharsets.UTF_8))));\n\n    listenersByProvisioningAddress.remove(address);\n  }\n\n  public boolean sendProvisioningMessage(final String address, final byte[] body) {\n    final PubSubProtos.PubSubMessage pubSubMessage = PubSubProtos.PubSubMessage.newBuilder()\n        .setType(PubSubProtos.PubSubMessage.Type.DELIVER)\n        .setContent(ByteString.copyFrom(body))\n        .build();\n\n    final boolean receiverPresent = ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeSupplier(() -> pubSubClient.withBinaryConnection(connection ->\n            connection.sync().publish(address.getBytes(StandardCharsets.UTF_8), pubSubMessage.toByteArray()) > 0));\n\n    Metrics.counter(SEND_PROVISIONING_MESSAGE_COUNTER_NAME, \"online\", String.valueOf(receiverPresent)).increment();\n\n    return receiverPresent;\n  }\n\n  @Override\n  public void message(final byte[] channel, final byte[] message) {\n    try {\n      final String address = new String(channel, StandardCharsets.UTF_8);\n      final PubSubProtos.PubSubMessage pubSubMessage = PubSubProtos.PubSubMessage.parseFrom(message);\n\n      if (pubSubMessage.getType() == PubSubProtos.PubSubMessage.Type.DELIVER) {\n        final Consumer<PubSubProtos.PubSubMessage> listener = listenersByProvisioningAddress.get(address);\n\n        boolean listenerPresent = false;\n\n        if (listener != null) {\n          listenerPresent = true;\n          listener.accept(pubSubMessage);\n        }\n\n        Metrics.counter(RECEIVE_PROVISIONING_MESSAGE_COUNTER_NAME, \"listenerPresent\", String.valueOf(listenerPresent)).increment();\n      }\n    } catch (final InvalidProtocolBufferException e) {\n      logger.warn(\"Failed to parse pub/sub message\", e);\n    }\n  }\n\n  @Override\n  public void unsubscribed(final byte[] channel, final long count) {\n    listenersByProvisioningAddress.remove(new String(channel, StandardCharsets.UTF_8));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotification.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport javax.annotation.Nullable;\n\npublic record PushNotification(String deviceToken,\n                               TokenType tokenType,\n                               NotificationType notificationType,\n                               @Nullable String data,\n                               @Nullable Account destination,\n                               @Nullable Device destinationDevice,\n                               boolean urgent) {\n\n  public enum NotificationType {\n    NOTIFICATION,\n    ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY,\n    CHALLENGE,\n    RATE_LIMIT_CHALLENGE\n  }\n\n  public enum TokenType {\n    FCM,\n    APN\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationManager.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.BiConsumer;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.Pair;\n\npublic class PushNotificationManager {\n\n  private final AccountsManager accountsManager;\n  private final APNSender apnSender;\n  private final FcmSender fcmSender;\n  private final PushNotificationScheduler pushNotificationScheduler;\n\n  private static final String SENT_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, \"sentPushNotification\");\n  private static final String FAILED_NOTIFICATION_COUNTER_NAME = name(PushNotificationManager.class, \"failedPushNotification\");\n  private static final String DEVICE_TOKEN_UNREGISTERED_COUNTER_NAME = name(PushNotificationManager.class, \"deviceTokenUnregistered\");\n\n  private static final Logger logger = LoggerFactory.getLogger(PushNotificationManager.class);\n\n  public PushNotificationManager(final AccountsManager accountsManager,\n      final APNSender apnSender,\n      final FcmSender fcmSender,\n      final PushNotificationScheduler pushNotificationScheduler) {\n\n    this.accountsManager = accountsManager;\n    this.apnSender = apnSender;\n    this.fcmSender = fcmSender;\n    this.pushNotificationScheduler = pushNotificationScheduler;\n  }\n\n  public CompletableFuture<Optional<SendPushNotificationResult>> sendNewMessageNotification(final Account destination, final byte destinationDeviceId, final boolean urgent) throws NotPushRegisteredException {\n    final Device device = destination.getDevice(destinationDeviceId).orElseThrow(NotPushRegisteredException::new);\n    final Pair<String, PushNotification.TokenType> tokenAndType = getToken(device);\n\n    return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(),\n        PushNotification.NotificationType.NOTIFICATION, null, destination, device, urgent));\n  }\n\n  public CompletableFuture<SendPushNotificationResult> sendRegistrationChallengeNotification(final String deviceToken, final PushNotification.TokenType tokenType, final String challengeToken) {\n    return sendNotification(new PushNotification(deviceToken, tokenType, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true))\n        .thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError(\"Responses must be present for urgent notifications\")));\n  }\n\n  public CompletableFuture<SendPushNotificationResult> sendRateLimitChallengeNotification(final Account destination, final String challengeToken)\n      throws NotPushRegisteredException {\n\n    final Device device = destination.getPrimaryDevice();\n    final Pair<String, PushNotification.TokenType> tokenAndType = getToken(device);\n\n    return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(),\n        PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, destination, device, true))\n        .thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError(\"Responses must be present for urgent notifications\")));\n  }\n\n  public CompletableFuture<SendPushNotificationResult> sendAttemptLoginNotification(final Account destination, final String context) throws NotPushRegisteredException {\n    final Device device = destination.getDevice(Device.PRIMARY_ID).orElseThrow(NotPushRegisteredException::new);\n    final Pair<String, PushNotification.TokenType> tokenAndType = getToken(device);\n\n    return sendNotification(new PushNotification(tokenAndType.first(), tokenAndType.second(),\n        PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY,\n        context, destination, device, true))\n        .thenApply(maybeResponse -> maybeResponse.orElseThrow(() -> new AssertionError(\"Responses must be present for urgent notifications\")));\n  }\n\n  public void handleMessagesRetrieved(final Account account, final Device device, final String userAgent) {\n    pushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors());\n  }\n\n  @VisibleForTesting\n  Pair<String, PushNotification.TokenType> getToken(final Device device) throws NotPushRegisteredException {\n    final Pair<String, PushNotification.TokenType> tokenAndType;\n\n    if (StringUtils.isNotBlank(device.getGcmId())) {\n      tokenAndType = new Pair<>(device.getGcmId(), PushNotification.TokenType.FCM);\n    } else if (StringUtils.isNotBlank(device.getApnId())) {\n      tokenAndType = new Pair<>(device.getApnId(), PushNotification.TokenType.APN);\n    } else {\n      throw new NotPushRegisteredException();\n    }\n\n    return tokenAndType;\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Optional<SendPushNotificationResult>> sendNotification(final PushNotification pushNotification) {\n    if (!pushNotification.urgent()) {\n      // Schedule a notification for some time in the future (possibly even now!) rather than sending a notification\n      // directly\n      return pushNotificationScheduler\n          .scheduleBackgroundNotification(pushNotification.tokenType(), pushNotification.destination(), pushNotification.destinationDevice())\n          .whenComplete(logErrors())\n          .thenApply(ignored -> Optional.<SendPushNotificationResult>empty())\n          .toCompletableFuture();\n    }\n\n    final PushNotificationSender sender = switch (pushNotification.tokenType()) {\n      case FCM -> fcmSender;\n      case APN -> apnSender;\n    };\n\n    return sender.sendNotification(pushNotification).whenComplete((result, throwable) -> {\n      if (throwable == null) {\n        Tags tags = Tags.of(\"tokenType\", pushNotification.tokenType().name(),\n            \"notificationType\", pushNotification.notificationType().name(),\n            \"urgent\", String.valueOf(pushNotification.urgent()),\n            \"accepted\", String.valueOf(result.accepted()),\n            \"unregistered\", String.valueOf(result.unregistered()));\n\n        if (result.errorCode().isPresent()) {\n          tags = tags.and(\"errorCode\", result.errorCode().get());\n        }\n\n        Metrics.counter(SENT_NOTIFICATION_COUNTER_NAME, tags).increment();\n\n        if (result.unregistered() && pushNotification.destination() != null\n            && pushNotification.destinationDevice() != null) {\n\n          handleDeviceUnregistered(pushNotification.destination(),\n              pushNotification.destinationDevice(),\n              pushNotification.tokenType(),\n              result.errorCode(),\n              result.unregisteredTimestamp());\n        }\n      } else {\n        logger.debug(\"Failed to deliver {} push notification to {} ({})\",\n            pushNotification.notificationType(), pushNotification.deviceToken(), pushNotification.tokenType(),\n            throwable);\n\n        Metrics.counter(FAILED_NOTIFICATION_COUNTER_NAME, \"cause\", throwable.getClass().getSimpleName()).increment();\n      }\n    })\n        .thenApply(Optional::of);\n  }\n\n  private static <T> BiConsumer<T, Throwable> logErrors() {\n    return (ignored, throwable) -> {\n      if (throwable != null) {\n        logger.warn(\"Failed push scheduling operation\", throwable);\n      }\n    };\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  private void handleDeviceUnregistered(final Account account,\n      final Device device,\n      final PushNotification.TokenType tokenType,\n      final Optional<String> maybeErrorCode,\n      final Optional<Instant> maybeTokenInvalidationTimestamp) {\n\n    final boolean tokenExpired = maybeTokenInvalidationTimestamp.map(tokenInvalidationTimestamp ->\n        tokenInvalidationTimestamp.isAfter(Instant.ofEpochMilli(device.getPushTimestamp()))).orElse(true);\n\n    if (tokenExpired) {\n      if (tokenType == PushNotification.TokenType.APN) {\n        pushNotificationScheduler.cancelScheduledNotifications(account, device).whenComplete(logErrors());\n      }\n\n      clearPushToken(account, device, tokenType);\n    }\n    Metrics.counter(DEVICE_TOKEN_UNREGISTERED_COUNTER_NAME,\n        \"errorCode\", maybeErrorCode.orElse(\"unknown\"),\n        \"isPrimary\", String.valueOf(device.isPrimary()),\n        \"hasUnregisteredTimestamp\", String.valueOf(maybeTokenInvalidationTimestamp.isPresent()),\n        \"tokenType\", tokenType.name(),\n        \"tokenExpired\", String.valueOf(tokenExpired)).increment();\n  }\n\n  private void clearPushToken(final Account account, final Device device, final PushNotification.TokenType tokenType) {\n    final String originalToken = getPushToken(device, tokenType);\n\n    if (originalToken == null) {\n      return;\n    }\n\n    // Reread the account to avoid marking the caller's account as stale. The consumers of this class tend to\n    // promise not to modify accounts. There's no need to force the caller to be considered mutable just for\n    // updating an uninstalled feedback timestamp though.\n    accountsManager.getByAccountIdentifier(account.getUuid()).ifPresent(rereadAccount ->\n        rereadAccount.getDevice(device.getId()).ifPresent(rereadDevice ->\n            accountsManager.updateDevice(rereadAccount, device.getId(), d -> {\n              // Don't clear the token if it's already changed\n              if (originalToken.equals(getPushToken(d, tokenType))) {\n                switch (tokenType) {\n                  case FCM -> d.setGcmId(null);\n                  case APN -> d.setApnId(null);\n                }\n              }\n            })));\n  }\n\n  private static String getPushToken(final Device device, final PushNotification.TokenType tokenType) {\n    return switch (tokenType) {\n      case FCM -> device.getGcmId();\n      case APN -> device.getApnId();\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationScheduler.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.Range;\nimport io.lettuce.core.ScriptOutputType;\nimport io.lettuce.core.SetArgs;\nimport io.lettuce.core.cluster.SlotHash;\nimport io.micrometer.core.instrument.Metrics;\nimport java.io.IOException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicLong;\nimport java.util.function.BiFunction;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.RedisClusterUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Mono;\n\npublic class PushNotificationScheduler implements Managed {\n\n  private static final Logger logger = LoggerFactory.getLogger(PushNotificationScheduler.class);\n\n  private static final String PENDING_BACKGROUND_APN_NOTIFICATIONS_KEY_PREFIX = \"PENDING_BACKGROUND_APN\";\n  private static final String PENDING_BACKGROUND_FCM_NOTIFICATIONS_KEY_PREFIX = \"PENDING_BACKGROUND_FCM\";\n  private static final String LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX = \"LAST_BACKGROUND_NOTIFICATION\";\n  private static final String PENDING_DELAYED_NOTIFICATIONS_KEY_PREFIX = \"DELAYED\";\n\n  @VisibleForTesting\n  static final String NEXT_SLOT_TO_PROCESS_KEY = \"pending_notification_next_slot\";\n\n  private static final Duration EXCEPTION_PAUSE = Duration.ofSeconds(3);\n\n  private static final String BACKGROUND_NOTIFICATION_SCHEDULED_COUNTER_NAME = name(PushNotificationScheduler.class, \"backgroundNotification\", \"scheduled\");\n  private static final String BACKGROUND_NOTIFICATION_SENT_COUNTER_NAME = name(PushNotificationScheduler.class, \"backgroundNotification\", \"sent\");\n\n  private static final String DELAYED_NOTIFICATION_SCHEDULED_COUNTER_NAME = name(PushNotificationScheduler.class, \"delayedNotificationScheduled\");\n  private static final String DELAYED_NOTIFICATION_SENT_COUNTER_NAME = name(PushNotificationScheduler.class, \"delayedNotificationSent\");\n  private static final String TOKEN_TYPE_TAG = \"tokenType\";\n  private static final String ACCEPTED_TAG = \"accepted\";\n\n  private final APNSender apnSender;\n  private final FcmSender fcmSender;\n  private final AccountsManager accountsManager;\n  private final FaultTolerantRedisClusterClient pushSchedulingCluster;\n  private final ScheduledExecutorService retryExecutor;\n  private final Clock clock;\n\n  private final ClusterLuaScript scheduleBackgroundNotificationScript;\n\n  private final Thread[] workerThreads;\n\n  @VisibleForTesting\n  static final Duration BACKGROUND_NOTIFICATION_PERIOD = Duration.ofMinutes(20);\n\n  private final AtomicBoolean running = new AtomicBoolean(false);\n\n  private static final String RETRY_NAME = ResilienceUtil.name(PushNotificationScheduler.class);\n\n  class NotificationWorker implements Runnable {\n\n    private final int maxConcurrency;\n\n    NotificationWorker(final int maxConcurrency) {\n      this.maxConcurrency = maxConcurrency;\n    }\n\n    @Override\n    public void run() {\n      do {\n        try {\n          final long entriesProcessed = processNextSlot();\n\n          if (entriesProcessed == 0) {\n            Util.sleep(1000);\n          }\n        } catch (Exception e) {\n          logger.warn(\"Exception while processing scheduled notifications\", e);\n\n          try {\n            Thread.sleep(EXCEPTION_PAUSE);\n          } catch (final InterruptedException _) {\n          }\n        }\n      } while (running.get());\n    }\n\n    private long processNextSlot() {\n      final int slot = (int) (pushSchedulingCluster.withCluster(connection ->\n          connection.sync().incr(NEXT_SLOT_TO_PROCESS_KEY)) % SlotHash.SLOT_COUNT);\n\n      return processScheduledBackgroundNotifications(PushNotification.TokenType.APN, slot)\n          + processScheduledBackgroundNotifications(PushNotification.TokenType.FCM, slot)\n          + processScheduledDelayedNotifications(slot);\n    }\n\n    @VisibleForTesting\n    long processScheduledBackgroundNotifications(PushNotification.TokenType tokenType, final int slot) {\n      return processScheduledNotifications(getPendingBackgroundNotificationQueueKey(tokenType, slot),\n          (account, device) -> sendBackgroundNotification(tokenType, account, device));\n    }\n\n\n    @VisibleForTesting\n    long processScheduledDelayedNotifications(final int slot) {\n      return processScheduledNotifications(getDelayedNotificationQueueKey(slot),\n          PushNotificationScheduler.this::sendDelayedNotification);\n    }\n\n    private long processScheduledNotifications(final String queueKey,\n        final BiFunction<Account, Device, CompletableFuture<Void>> sendNotificationFunction) {\n\n      final long currentTimeMillis = clock.millis();\n      final AtomicLong processedNotifications = new AtomicLong(0);\n\n      pushSchedulingCluster.useCluster(\n          connection -> connection.reactive().zrangebyscore(queueKey, Range.create(0, currentTimeMillis))\n              .flatMap(encodedAciAndDeviceId -> Mono.fromFuture(\n                  () -> getAccountAndDeviceFromPairString(encodedAciAndDeviceId)), maxConcurrency)\n              .flatMap(Mono::justOrEmpty)\n              .flatMap(accountAndDevice -> Mono.fromFuture(\n                          () -> sendNotificationFunction.apply(accountAndDevice.first(), accountAndDevice.second()))\n                      .then(Mono.defer(() -> connection.reactive().zrem(queueKey, encodeAciAndDeviceId(accountAndDevice.first(), accountAndDevice.second()))))\n                      .doOnSuccess(ignored -> processedNotifications.incrementAndGet()),\n                  maxConcurrency)\n              .then()\n              .block());\n\n      return processedNotifications.get();\n    }\n  }\n\n  public PushNotificationScheduler(final FaultTolerantRedisClusterClient pushSchedulingCluster,\n      final APNSender apnSender,\n      final FcmSender fcmSender,\n      final AccountsManager accountsManager,\n      final int dedicatedProcessWorkerThreadCount,\n      final int workerMaxConcurrency,\n      final ScheduledExecutorService retryExecutor) throws IOException {\n\n    this(pushSchedulingCluster,\n        apnSender,\n        fcmSender,\n        accountsManager,\n        Clock.systemUTC(),\n        dedicatedProcessWorkerThreadCount,\n        workerMaxConcurrency,\n        retryExecutor);\n  }\n\n  @VisibleForTesting\n  PushNotificationScheduler(final FaultTolerantRedisClusterClient pushSchedulingCluster,\n      final APNSender apnSender,\n      final FcmSender fcmSender,\n      final AccountsManager accountsManager,\n      final Clock clock,\n      final int dedicatedProcessThreadCount,\n      final int workerMaxConcurrency,\n      final ScheduledExecutorService retryExecutor) throws IOException {\n\n    this.apnSender = apnSender;\n    this.fcmSender = fcmSender;\n    this.accountsManager = accountsManager;\n    this.pushSchedulingCluster = pushSchedulingCluster;\n    this.clock = clock;\n\n    this.scheduleBackgroundNotificationScript = ClusterLuaScript.fromResource(pushSchedulingCluster,\n        \"lua/apn/schedule_background_notification.lua\", ScriptOutputType.VALUE);\n\n    this.workerThreads = new Thread[dedicatedProcessThreadCount];\n    this.retryExecutor = retryExecutor;\n\n    for (int i = 0; i < this.workerThreads.length; i++) {\n      this.workerThreads[i] = new Thread(new NotificationWorker(workerMaxConcurrency), \"PushNotificationScheduler-\" + i);\n    }\n  }\n\n  /**\n   * Schedule a background push notification to be sent some time in the future.\n   *\n   * @return A CompletionStage that completes when the notification has successfully been scheduled\n   *\n   * @throws IllegalArgumentException if the given device does not have a push token\n   */\n  public CompletionStage<Void> scheduleBackgroundNotification(final PushNotification.TokenType tokenType, final Account account, final Device device) {\n    if (StringUtils.isBlank(getPushToken(tokenType, device))) {\n      throw new IllegalArgumentException(\"Device must have an \" + tokenType + \" token\");\n    }\n    Metrics.counter(BACKGROUND_NOTIFICATION_SCHEDULED_COUNTER_NAME, \"type\", tokenType.name()).increment();\n    return scheduleBackgroundNotificationScript.executeAsync(\n            List.of(\n                getLastBackgroundNotificationTimestampKey(account, device),\n                getPendingBackgroundNotificationQueueKey(tokenType, account, device)),\n            List.of(\n                encodeAciAndDeviceId(account, device),\n                String.valueOf(clock.millis()),\n                String.valueOf(BACKGROUND_NOTIFICATION_PERIOD.toMillis())))\n        .thenRun(Util.NOOP);\n  }\n\n  /**\n   * Schedules a \"new message\" push notification to be delivered to the given device after at least the given duration.\n   * If another notification had previously been scheduled, calling this method will replace the previously-scheduled\n   * delivery time with the given time.\n   *\n   * @param account the account to which the target device belongs\n   * @param device the device to which to deliver a \"new message\" push notification\n   * @param minDelay the minimum delay after which to deliver the notification\n   *\n   * @return a future that completes once the notification has been scheduled\n   */\n  public CompletableFuture<Void> scheduleDelayedNotification(final Account account, final Device device, final Duration minDelay) {\n    final long deliveryTime = clock.instant().plus(minDelay).toEpochMilli();\n\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> pushSchedulingCluster.withCluster(connection ->\n                connection.async().zadd(getDelayedNotificationQueueKey(account, device),\n                    deliveryTime,\n                    encodeAciAndDeviceId(account, device)))\n            .thenRun(() -> Metrics.counter(DELAYED_NOTIFICATION_SCHEDULED_COUNTER_NAME,\n                    TOKEN_TYPE_TAG, getTokenType(device))\n                .increment()))\n        .toCompletableFuture();\n  }\n\n  /**\n   * Cancel scheduled notifications for the given account and device.\n   *\n   * @return A CompletionStage that completes when the scheduled notification has been cancelled.\n   */\n  public CompletionStage<Void> cancelScheduledNotifications(Account account, Device device) {\n    return CompletableFuture.allOf(\n        cancelBackgroundNotifications(PushNotification.TokenType.FCM, account, device),\n        cancelBackgroundNotifications(PushNotification.TokenType.APN, account, device),\n        cancelDelayedNotifications(account, device));\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Void> cancelBackgroundNotifications(final PushNotification.TokenType tokenType, final Account account, final Device device) {\n    return pushSchedulingCluster.withCluster(connection -> connection.async()\n            .zrem(getPendingBackgroundNotificationQueueKey(tokenType, account, device), encodeAciAndDeviceId(account, device)))\n        .thenRun(Util.NOOP)\n        .toCompletableFuture();\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Void> cancelDelayedNotifications(final Account account, final Device device) {\n    return pushSchedulingCluster.withCluster(connection ->\n            connection.async().zrem(getDelayedNotificationQueueKey(account, device),\n                encodeAciAndDeviceId(account, device)))\n        .thenRun(Util.NOOP)\n        .toCompletableFuture();\n  }\n\n  @Override\n  public synchronized void start() {\n    running.set(true);\n\n    for (final Thread workerThread : workerThreads) {\n      workerThread.start();\n    }\n  }\n\n  @Override\n  public synchronized void stop() throws InterruptedException {\n    running.set(false);\n\n    for (final Thread workerThread : workerThreads) {\n      workerThread.join();\n    }\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Void> sendBackgroundNotification(PushNotification.TokenType tokenType, final Account account, final Device device) {\n    final String pushToken = getPushToken(tokenType, device);\n    if (StringUtils.isBlank(pushToken)) {\n      return CompletableFuture.completedFuture(null);\n    }\n\n    final PushNotificationSender sender = switch (tokenType) {\n      case FCM -> fcmSender;\n      case APN -> apnSender;\n    };\n\n    // It's okay for the \"last notification\" timestamp to expire after the \"cooldown\" period has elapsed; a missing\n    // timestamp and a timestamp older than the period are functionally equivalent.\n    return pushSchedulingCluster.withCluster(connection -> connection.async().set(\n        getLastBackgroundNotificationTimestampKey(account, device),\n        String.valueOf(clock.millis()), new SetArgs().ex(BACKGROUND_NOTIFICATION_PERIOD)))\n        .thenCompose(ignored -> sender.sendNotification(new PushNotification(pushToken, tokenType, PushNotification.NotificationType.NOTIFICATION, null, account, device, false)))\n        .thenAccept(response -> Metrics.counter(BACKGROUND_NOTIFICATION_SENT_COUNTER_NAME,\n                ACCEPTED_TAG, String.valueOf(response.accepted()))\n            .increment())\n        .toCompletableFuture();\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Void> sendDelayedNotification(final Account account, final Device device) {\n    if (StringUtils.isAllBlank(device.getApnId(), device.getGcmId())) {\n      return CompletableFuture.completedFuture(null);\n    }\n\n    final boolean isApnsDevice = StringUtils.isNotBlank(device.getApnId());\n\n    final PushNotification pushNotification = new PushNotification(\n        isApnsDevice ? device.getApnId() : device.getGcmId(),\n        isApnsDevice ? PushNotification.TokenType.APN : PushNotification.TokenType.FCM,\n        PushNotification.NotificationType.NOTIFICATION,\n        null,\n        account,\n        device,\n        true);\n\n    final PushNotificationSender pushNotificationSender = isApnsDevice ? apnSender : fcmSender;\n\n    return pushNotificationSender.sendNotification(pushNotification)\n        .thenAccept(response -> Metrics.counter(DELAYED_NOTIFICATION_SENT_COUNTER_NAME,\n            TOKEN_TYPE_TAG, getTokenType(device),\n            ACCEPTED_TAG, String.valueOf(response.accepted()))\n            .increment());\n  }\n\n  @VisibleForTesting\n  static String encodeAciAndDeviceId(final Account account, final Device device) {\n    // Note: This does not include a device registration id. If a device is unlinked and a new device is linked with\n    // the original device's id, the new device might get the old device's scheduled push, or the new device might\n    // delay its own push because the old device had a recent push. An extra or delayed background push is harmless,\n    // so this is okay.\n    return account.getUuid() + \":\" + device.getId();\n  }\n\n  static Pair<UUID, Byte> decodeAciAndDeviceId(final String encoded) {\n    if (StringUtils.isBlank(encoded)) {\n      throw new IllegalArgumentException(\"Encoded ACI/device ID pair must not be blank\");\n    }\n\n    final int separatorIndex = encoded.indexOf(':');\n\n    if (separatorIndex == -1) {\n      throw new IllegalArgumentException(\"String did not contain a ':' separator\");\n    }\n\n    final UUID aci = UUID.fromString(encoded.substring(0, separatorIndex));\n    final byte deviceId = Byte.parseByte(encoded.substring(separatorIndex + 1));\n\n    return new Pair<>(aci, deviceId);\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Optional<Pair<Account, Device>>> getAccountAndDeviceFromPairString(final String endpoint) {\n    final Pair<UUID, Byte> aciAndDeviceId = decodeAciAndDeviceId(endpoint);\n\n    return accountsManager.getByAccountIdentifierAsync(aciAndDeviceId.first())\n        .thenApply(maybeAccount -> maybeAccount\n            .flatMap(account -> account.getDevice(aciAndDeviceId.second()).map(device -> new Pair<>(account, device))));\n  }\n\n  @VisibleForTesting\n  static String getPendingBackgroundNotificationQueueKey(final PushNotification.TokenType tokenType, final Account account, final Device device) {\n    return getPendingBackgroundNotificationQueueKey(tokenType, SlotHash.getSlot(encodeAciAndDeviceId(account, device)));\n  }\n\n  private static String getPendingBackgroundNotificationQueueKey(final PushNotification.TokenType tokenType, final int slot) {\n    final String prefix = switch (tokenType) {\n      case APN -> PENDING_BACKGROUND_APN_NOTIFICATIONS_KEY_PREFIX;\n      case FCM -> PENDING_BACKGROUND_FCM_NOTIFICATIONS_KEY_PREFIX;\n    };\n    return prefix + \"::{\" + RedisClusterUtil.getMinimalHashTag(slot) + \"}\";\n  }\n\n  private static String getLastBackgroundNotificationTimestampKey(final Account account, final Device device) {\n    return LAST_BACKGROUND_NOTIFICATION_TIMESTAMP_KEY_PREFIX + \"::{\" + encodeAciAndDeviceId(account, device) + \"}\";\n  }\n\n  @VisibleForTesting\n  static String getDelayedNotificationQueueKey(final Account account, final Device device) {\n    return getDelayedNotificationQueueKey(SlotHash.getSlot(encodeAciAndDeviceId(account, device)));\n  }\n\n  private static String getDelayedNotificationQueueKey(final int slot) {\n    return PENDING_DELAYED_NOTIFICATIONS_KEY_PREFIX + \"::{\" + RedisClusterUtil.getMinimalHashTag(slot) + \"}\";\n  }\n\n  @VisibleForTesting\n  Optional<Instant> getLastBackgroundApnsNotificationTimestamp(final Account account, final Device device) {\n    return Optional.ofNullable(\n        pushSchedulingCluster.withCluster(connection ->\n            connection.sync().get(getLastBackgroundNotificationTimestampKey(account, device))))\n        .map(timestampString -> Instant.ofEpochMilli(Long.parseLong(timestampString)));\n  }\n\n  @VisibleForTesting\n  Optional<Instant> getNextScheduledBackgroundNotificationTimestamp(PushNotification.TokenType tokenType, final Account account, final Device device) {\n    return Optional.ofNullable(\n            pushSchedulingCluster.withCluster(connection ->\n                connection.sync().zscore(getPendingBackgroundNotificationQueueKey(tokenType, account, device),\n                    encodeAciAndDeviceId(account, device))))\n        .map(timestamp -> Instant.ofEpochMilli(timestamp.longValue()));\n  }\n\n  @VisibleForTesting\n  Optional<Instant> getNextScheduledDelayedNotificationTimestamp(final Account account, final Device device) {\n    return Optional.ofNullable(\n            pushSchedulingCluster.withCluster(connection ->\n                connection.sync().zscore(getDelayedNotificationQueueKey(account, device),\n                    encodeAciAndDeviceId(account, device))))\n        .map(timestamp -> Instant.ofEpochMilli(timestamp.longValue()));\n  }\n\n  private static String getTokenType(final Device device) {\n    if (StringUtils.isNotBlank(device.getApnId())) {\n      return \"apns\";\n    } else if (StringUtils.isNotBlank(device.getGcmId())) {\n      return \"fcm\";\n    } else {\n      return \"unknown\";\n    }\n  }\n\n  private static String getPushToken(final PushNotification.TokenType tokenType, final Device device) {\n    return switch (tokenType) {\n      case FCM -> device.getGcmId();\n      case APN -> device.getApnId();\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/PushNotificationSender.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport java.util.concurrent.CompletableFuture;\n\npublic interface PushNotificationSender {\n\n  CompletableFuture<SendPushNotificationResult> sendNotification(PushNotification notification);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/ReceiptSender.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ExecutorService;\nimport java.util.stream.Collectors;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class ReceiptSender {\n\n  private final MessageSender messageSender;\n  private final AccountsManager accountManager;\n  private final ExecutorService executor;\n\n  private static final Logger logger = LoggerFactory.getLogger(ReceiptSender.class);\n\n  public ReceiptSender(final AccountsManager accountManager, final MessageSender messageSender,\n      final ExecutorService executor) {\n    this.accountManager = accountManager;\n    this.messageSender = messageSender;\n    this.executor = executor;\n  }\n\n  public void sendReceipt(ServiceIdentifier sourceIdentifier, byte sourceDeviceId, AciServiceIdentifier destinationIdentifier, long messageId) {\n    if (sourceIdentifier.equals(destinationIdentifier)) {\n      return;\n    }\n\n    executor.submit(() -> {\n      try {\n        accountManager.getByAccountIdentifier(destinationIdentifier.uuid()).ifPresentOrElse(\n            destinationAccount -> {\n              final Envelope message = Envelope.newBuilder()\n                  .setServerTimestamp(System.currentTimeMillis())\n                  .setSourceServiceId(sourceIdentifier.toServiceIdentifierString())\n                  .setSourceDevice(sourceDeviceId)\n                  .setDestinationServiceId(destinationIdentifier.toServiceIdentifierString())\n                  .setClientTimestamp(messageId)\n                  .setType(Envelope.Type.SERVER_DELIVERY_RECEIPT)\n                  .setUrgent(false)\n                  .build();\n\n              final Map<Byte, Envelope> messagesByDeviceId = destinationAccount.getDevices().stream()\n                  .collect(Collectors.toMap(Device::getId, ignored -> message));\n\n              final Map<Byte, Integer> registrationIdsByDeviceId = destinationAccount.getDevices().stream()\n                  .collect(Collectors.toMap(Device::getId,\n                      device -> device.getRegistrationId(destinationIdentifier.identityType())));\n\n              try {\n                messageSender.sendMessages(destinationAccount,\n                    destinationIdentifier,\n                    messagesByDeviceId,\n                    registrationIdsByDeviceId,\n                    Optional.empty(),\n                    UserAgentTagUtil.SERVER_UA);\n              } catch (final Exception e) {\n                logger.warn(\"Could not send delivery receipt\", e);\n              }\n            },\n            () -> logger.info(\"No longer registered: {}\", destinationIdentifier)\n        );\n\n      } catch (final Exception e) {\n        // this exception is most likely a Dynamo timeout or a Redis timeout/circuit breaker\n        logger.warn(\"Could not send delivery receipt\", e);\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/RedisMessageAvailabilityManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.cluster.SlotHash;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.lettuce.core.cluster.pubsub.RedisClusterPubSubAdapter;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Function;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubClusterConnection;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.RedisClusterUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n/**\n * The Redis message availability manager distributes events related to client presence and message availability to\n * registered listeners. In the current Signal server implementation, clients generally interact with the service by\n * opening a dual-purpose WebSocket. The WebSocket serves as both a delivery mechanism for messages and as a channel\n * for the client to issue API requests to the server. Clients are considered \"present\" if they have an open WebSocket\n * connection and are therefore likely to receive messages as soon as they're delivered to the server. Redis message\n * availability managers ensure that clients have at most one active message delivery channel at a time on a\n * best-effort basis.\n *\n * @implNote The Redis message availability manager uses the Redis 7 sharded pub/sub system to distribute events. This\n * system makes a best effort to ensure that a given client has only a single open connection across the fleet of\n * servers, but cannot guarantee at-most-one behavior.\n *\n * @see MessageAvailabilityListener\n * @see org.whispersystems.textsecuregcm.storage.MessagesManager#insert(UUID, Map)\n */\npublic class RedisMessageAvailabilityManager extends RedisClusterPubSubAdapter<byte[], byte[]> implements Managed {\n\n  private final FaultTolerantRedisClusterClient clusterClient;\n  private final Executor listenerEventExecutor;\n\n  // Note that this MUST be a single-threaded executor; its function is to process tasks that should usually be\n  // non-blocking, but can rarely block, and do so in the order in which those tasks were submitted.\n  private final Executor asyncOperationQueueingExecutor;\n\n  @Nullable\n  private FaultTolerantPubSubClusterConnection<byte[], byte[]> pubSubConnection;\n\n  private final Map<AccountAndDeviceIdentifier, MessageAvailabilityListener> listenersByAccountAndDeviceIdentifier;\n\n  private final UUID serverId = UUID.randomUUID();\n\n  private final byte[] CLIENT_CONNECTED_EVENT_BYTES = ClientEvent.newBuilder()\n      .setClientConnected(ClientConnectedEvent.newBuilder()\n          .setServerId(UUIDUtil.toByteString(serverId))\n          .build())\n      .build()\n      .toByteArray();\n\n  private static final Counter PUBLISH_CLIENT_CONNECTION_EVENT_ERROR_COUNTER =\n      Metrics.counter(MetricsUtil.name(RedisMessageAvailabilityManager.class, \"publishClientConnectionEventError\"));\n\n  private static final Counter UNSUBSCRIBE_ERROR_COUNTER =\n      Metrics.counter(MetricsUtil.name(RedisMessageAvailabilityManager.class, \"unsubscribeError\"));\n\n  private static final Counter PUB_SUB_EVENT_WITHOUT_LISTENER_COUNTER =\n      Metrics.counter(MetricsUtil.name(RedisMessageAvailabilityManager.class, \"pubSubEventWithoutListener\"));\n\n  private static final Counter MESSAGE_AVAILABLE_WITHOUT_LISTENER_COUNTER =\n      Metrics.counter(MetricsUtil.name(RedisMessageAvailabilityManager.class, \"messageAvailableWithoutListener\"));\n\n  private static final String LISTENER_GAUGE_NAME =\n      MetricsUtil.name(RedisMessageAvailabilityManager.class, \"listeners\");\n\n  private static final Logger logger = LoggerFactory.getLogger(RedisMessageAvailabilityManager.class);\n\n  @VisibleForTesting\n  record AccountAndDeviceIdentifier(UUID accountIdentifier, byte deviceId) {\n  }\n\n  public RedisMessageAvailabilityManager(final FaultTolerantRedisClusterClient clusterClient,\n      final Executor listenerEventExecutor,\n      final Executor asyncOperationQueueingExecutor) {\n\n    this.clusterClient = clusterClient;\n    this.listenerEventExecutor = listenerEventExecutor;\n    this.asyncOperationQueueingExecutor = asyncOperationQueueingExecutor;\n\n    this.listenersByAccountAndDeviceIdentifier =\n        Metrics.gaugeMapSize(LISTENER_GAUGE_NAME, Tags.empty(), new ConcurrentHashMap<>());\n  }\n\n  @Override\n  public synchronized void start() {\n    this.pubSubConnection = clusterClient.createBinaryPubSubConnection();\n    this.pubSubConnection.usePubSubConnection(connection -> connection.addListener(this));\n\n    pubSubConnection.subscribeToClusterTopologyChangedEvents(this::resubscribe);\n  }\n\n  @Override\n  public synchronized void stop() {\n    if (pubSubConnection != null) {\n      pubSubConnection.usePubSubConnection(connection -> {\n        connection.removeListener(this);\n        connection.close();\n      });\n    }\n\n    pubSubConnection = null;\n  }\n\n  /**\n   * Marks the given device as \"present\" for message delivery and registers a listener for new messages and conflicting\n   * connections. If the given device already has a presence registered with this manager, that presence is displaced\n   * immediately and the listener's {@link MessageAvailabilityListener#handleConflictingMessageConsumer()} method is called.\n   *\n   * @param accountIdentifier the account identifier for the newly-connected device\n   * @param deviceId the ID of the newly-connected device within the given account\n   * @param listener the listener to notify when new messages or conflicting connections arrive for the newly-connected\n   *                 device\n   *\n   * @return a future that completes when the new device's presence has been registered; the future may fail if a\n   * pub/sub subscription could not be established, in which case callers should close the client's connection to the\n   * server\n   */\n  public CompletionStage<Void> handleClientConnected(final UUID accountIdentifier, final byte deviceId, final MessageAvailabilityListener listener) {\n    if (pubSubConnection == null) {\n      throw new IllegalStateException(\"WebSocket connection event manager not started\");\n    }\n\n    final byte[] eventChannel = getClientEventChannel(accountIdentifier, deviceId);\n    final AtomicReference<MessageAvailabilityListener> displacedListener = new AtomicReference<>();\n    final AtomicReference<CompletionStage<Void>> subscribeFuture = new AtomicReference<>();\n\n    // Note that we're relying on some specific implementation details of `ConcurrentHashMap#compute(...)`. In\n    // particular, the behavioral contract for `ConcurrentHashMap#compute(...)` says:\n    //\n    // > The entire method invocation is performed atomically. The supplied function is invoked exactly once per\n    // > invocation of this method. Some attempted update operations on this map by other threads may be blocked while\n    // > computation is in progress, so the computation should be short and simple.\n    //\n    // This provides a mechanism to make sure that we enqueue subscription/unsubscription operations in the same order\n    // as adding/removing listeners from the map and helps us avoid races and conflicts. Note that the enqueued\n    // operation is asynchronous; we're not blocking on it in the scope of the `compute` operation.\n    listenersByAccountAndDeviceIdentifier.compute(new AccountAndDeviceIdentifier(accountIdentifier, deviceId),\n        (key, existingListener) -> {\n          subscribeFuture.set(CompletableFuture.supplyAsync(() -> pubSubConnection.withPubSubConnection(connection ->\n                  connection.async().ssubscribe(eventChannel)), asyncOperationQueueingExecutor)\n              .thenCompose(Function.identity()));\n\n          if (existingListener != null) {\n            displacedListener.set(existingListener);\n          }\n\n          return listener;\n        });\n\n    if (displacedListener.get() != null) {\n      listenerEventExecutor.execute(() -> displacedListener.get().handleConflictingMessageConsumer());\n    }\n\n    return subscribeFuture.get()\n        .thenCompose(ignored -> clusterClient.withBinaryCluster(connection -> connection.async()\n            .spublish(eventChannel, CLIENT_CONNECTED_EVENT_BYTES)))\n        .handle((ignored, throwable) -> {\n          if (throwable != null) {\n            PUBLISH_CLIENT_CONNECTION_EVENT_ERROR_COUNTER.increment();\n          }\n\n          return null;\n        });\n  }\n\n  /**\n   * Removes the \"presence\" and event listener for the given device. Callers should call this method when the client's\n   * underlying network connection has closed.\n   *\n   * @param accountIdentifier the identifier of the account for the disconnected device\n   * @param deviceId the ID of the disconnected device within the given account\n   *\n   * @return a future that completes when the presence and event listener have been removed\n   */\n  public CompletionStage<Void> handleClientDisconnected(final UUID accountIdentifier, final byte deviceId) {\n    if (pubSubConnection == null) {\n      throw new IllegalStateException(\"WebSocket connection event manager not started\");\n    }\n\n    final AtomicReference<CompletionStage<Void>> unsubscribeFuture = new AtomicReference<>();\n\n    // Note that we're relying on some specific implementation details of `ConcurrentHashMap#compute(...)`. In\n    // particular, the behavioral contract for `ConcurrentHashMap#compute(...)` says:\n    //\n    // > The entire method invocation is performed atomically. The supplied function is invoked exactly once per\n    // > invocation of this method. Some attempted update operations on this map by other threads may be blocked while\n    // > computation is in progress, so the computation should be short and simple.\n    //\n    // This provides a mechanism to make sure that we enqueue subscription/unsubscription operations in the same order\n    // as adding/removing listeners from the map and helps us avoid races and conflicts. Note that the enqueued\n    // operation is asynchronous; we're not blocking on it in the scope of the `compute` operation.\n    listenersByAccountAndDeviceIdentifier.compute(new AccountAndDeviceIdentifier(accountIdentifier, deviceId),\n        (ignored, existingListener) -> {\n          unsubscribeFuture.set(CompletableFuture.supplyAsync(() -> pubSubConnection.withPubSubConnection(connection ->\n                      connection.async().sunsubscribe(getClientEventChannel(accountIdentifier, deviceId)))\n                  .thenRun(Util.NOOP), asyncOperationQueueingExecutor)\n              .thenCompose(Function.identity()));\n\n          return null;\n        });\n\n    return unsubscribeFuture.get().whenComplete((ignored, throwable) -> {\n      if (throwable != null) {\n        UNSUBSCRIBE_ERROR_COUNTER.increment();\n      }\n    });\n  }\n\n  /**\n   * Tests whether a client with the given account/device is connected to this manager instance.\n   *\n   * @param accountUuid the account identifier for the client to check\n   * @param deviceId the ID of the device within the given account\n   *\n   * @return {@code true} if a client with the given account/device is connected to this manager instance or\n   * {@code false} if the client is not connected at all or is connected to a different manager instance\n   */\n  public boolean isLocallyPresent(final UUID accountUuid, final byte deviceId) {\n    return listenersByAccountAndDeviceIdentifier.containsKey(new AccountAndDeviceIdentifier(accountUuid, deviceId));\n  }\n\n  @VisibleForTesting\n  void resubscribe(final ClusterTopologyChangedEvent clusterTopologyChangedEvent) {\n    final boolean[] changedSlots = RedisClusterUtil.getChangedSlots(clusterTopologyChangedEvent);\n\n    final Map<Integer, List<byte[]>> eventChannelsBySlot = new HashMap<>();\n\n    // Organize subscriptions by slot so we can issue a smaller number of larger resubscription commands\n    listenersByAccountAndDeviceIdentifier.keySet()\n            .stream()\n            .map(accountAndDeviceIdentifier -> getClientEventChannel(accountAndDeviceIdentifier.accountIdentifier(), accountAndDeviceIdentifier.deviceId()))\n            .forEach(clientEventChannel -> {\n              final int slot = SlotHash.getSlot(clientEventChannel);\n\n              if (changedSlots[slot]) {\n                eventChannelsBySlot.computeIfAbsent(slot, ignored -> new ArrayList<>()).add(clientEventChannel);\n              }\n            });\n\n    // Issue one resubscription command per affected slot\n    eventChannelsBySlot.forEach((slot, eventChannels) -> {\n      if (pubSubConnection != null) {\n        pubSubConnection.usePubSubConnection(connection ->\n            connection.sync().ssubscribe(eventChannels.toArray(byte[][]::new)));\n      }\n    });\n  }\n\n  /**\n   * Unsubscribes for notifications for the given account and device identifier if and only if no listener is registered\n   * for that account and device identifier.\n   *\n   * @param accountAndDeviceIdentifier the account and device identifier for which to stop receiving notifications\n   */\n  void unsubscribeIfMissingListener(final AccountAndDeviceIdentifier accountAndDeviceIdentifier) {\n    listenersByAccountAndDeviceIdentifier.compute(accountAndDeviceIdentifier, (ignored, existingListener) -> {\n      if (existingListener == null && pubSubConnection != null) {\n        // Enqueue, but do not block on, an \"unsubscribe\" operation\n        asyncOperationQueueingExecutor.execute(() -> pubSubConnection.usePubSubConnection(connection ->\n            connection.async().sunsubscribe(getClientEventChannel(accountAndDeviceIdentifier.accountIdentifier(),\n                accountAndDeviceIdentifier.deviceId()))));\n      }\n\n      // Make no change to the existing listener whether present or absent\n      return existingListener;\n    });\n  }\n\n  @Override\n  public void smessage(final RedisClusterNode node, final byte[] shardChannel, final byte[] message) {\n    final ClientEvent clientEvent;\n\n    try {\n      clientEvent = ClientEvent.parseFrom(message);\n    } catch (final InvalidProtocolBufferException e) {\n      logger.error(\"Failed to parse pub/sub event protobuf\", e);\n      return;\n    }\n\n    final AccountAndDeviceIdentifier accountAndDeviceIdentifier = parseClientEventChannel(shardChannel);\n\n    @Nullable final MessageAvailabilityListener listener =\n        listenersByAccountAndDeviceIdentifier.get(accountAndDeviceIdentifier);\n\n    if (listener != null) {\n      switch (clientEvent.getEventCase()) {\n        case NEW_MESSAGE_AVAILABLE -> listener.handleNewMessageAvailable();\n\n        case CLIENT_CONNECTED -> {\n          // Only act on new connections to other event manager instances; we'll learn about displacements in THIS\n          // instance when we update the listener map in `handleClientConnected`\n          if (!this.serverId.equals(UUIDUtil.fromByteString(clientEvent.getClientConnected().getServerId()))) {\n            listenerEventExecutor.execute(listener::handleConflictingMessageConsumer);\n          }\n        }\n\n        case MESSAGES_PERSISTED -> listenerEventExecutor.execute(listener::handleMessagesPersisted);\n\n        default -> logger.warn(\"Unexpected client event type: {}\", clientEvent.getClass());\n      }\n    } else {\n      PUB_SUB_EVENT_WITHOUT_LISTENER_COUNTER.increment();\n\n      listenerEventExecutor.execute(() -> unsubscribeIfMissingListener(accountAndDeviceIdentifier));\n\n      if (clientEvent.getEventCase() == ClientEvent.EventCase.NEW_MESSAGE_AVAILABLE) {\n        MESSAGE_AVAILABLE_WITHOUT_LISTENER_COUNTER.increment();\n      }\n    }\n  }\n\n  public static byte[] getClientEventChannel(final UUID accountIdentifier, final byte deviceId) {\n    return (\"client_presence::{\" + accountIdentifier + \"::\" + deviceId + \"}\").getBytes(StandardCharsets.UTF_8);\n  }\n\n  private static AccountAndDeviceIdentifier parseClientEventChannel(final byte[] eventChannelBytes) {\n    final String eventChannel = new String(eventChannelBytes, StandardCharsets.UTF_8);\n    final int uuidStart = \"client_presence::{\".length();\n\n    final UUID accountIdentifier = UUID.fromString(eventChannel.substring(uuidStart, uuidStart + 36));\n    final byte deviceId = Byte.parseByte(eventChannel.substring(uuidStart + 38, eventChannel.length() - 1));\n\n    return new AccountAndDeviceIdentifier(accountIdentifier, deviceId);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/push/SendPushNotificationResult.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport java.time.Instant;\nimport java.util.Optional;\n\npublic record SendPushNotificationResult(boolean accepted,\n                                         Optional<String> errorCode,\n                                         boolean unregistered,\n                                         Optional<Instant> unregisteredTimestamp) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/AbstractFaultTolerantPubSubConnection.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.pubsub.StatefulRedisPubSubConnection;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\n\nabstract class AbstractFaultTolerantPubSubConnection<K, V, C extends StatefulRedisPubSubConnection<K, V>> {\n\n  private final String name;\n  private final C pubSubConnection;\n\n  private final Timer executeTimer;\n\n  protected AbstractFaultTolerantPubSubConnection(final String name, final C pubSubConnection) {\n    this.name = name;\n    this.pubSubConnection = pubSubConnection;\n\n    this.executeTimer = Metrics.timer(name(getClass(), \"execute\"), \"clusterName\", name + \"-pubsub\");\n  }\n\n  protected String getName() {\n    return name;\n  }\n\n  public void usePubSubConnection(final Consumer<C> consumer) {\n    try {\n      executeTimer.record(() -> consumer.accept(pubSubConnection));\n    } catch (final Throwable t) {\n      if (t instanceof RedisException) {\n        throw (RedisException) t;\n      } else {\n        throw new RedisException(t);\n      }\n    }\n  }\n\n  public <T> T withPubSubConnection(final Function<C, T> function) {\n    try {\n      return executeTimer.record(() -> function.apply(pubSubConnection));\n    } catch (final Throwable t) {\n      if (t instanceof RedisException) {\n        throw (RedisException) t;\n      } else {\n        throw new RedisException(t);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScript.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.RedisNoScriptException;\nimport io.lettuce.core.ScriptOutputType;\nimport io.lettuce.core.cluster.api.StatefulRedisClusterConnection;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport reactor.core.publisher.Flux;\n\npublic class ClusterLuaScript {\n\n  private final FaultTolerantRedisClusterClient redisCluster;\n  private final ScriptOutputType scriptOutputType;\n  private final String script;\n  private final String sha;\n\n  private static final String[] STRING_ARRAY = new String[0];\n  private static final byte[][] BYTE_ARRAY_ARRAY = new byte[0][];\n\n  private static final Logger log = LoggerFactory.getLogger(ClusterLuaScript.class);\n\n  public static ClusterLuaScript fromResource(final FaultTolerantRedisClusterClient redisCluster,\n      final String resource,\n      final ScriptOutputType scriptOutputType) throws IOException {\n\n    try (final InputStream inputStream = ClusterLuaScript.class.getClassLoader().getResourceAsStream(resource)) {\n      if (inputStream == null) {\n        throw new IllegalArgumentException(\"Script not found: \" + resource);\n      }\n\n      return new ClusterLuaScript(redisCluster,\n          new String(inputStream.readAllBytes(), StandardCharsets.UTF_8),\n          scriptOutputType);\n    }\n  }\n\n  @VisibleForTesting\n  ClusterLuaScript(final FaultTolerantRedisClusterClient redisCluster,\n      final String script,\n      final ScriptOutputType scriptOutputType) {\n\n    this.redisCluster = redisCluster;\n    this.scriptOutputType = scriptOutputType;\n    this.script = script;\n\n    try {\n      this.sha = HexFormat.of().formatHex(MessageDigest.getInstance(\"SHA-1\").digest(script.getBytes(StandardCharsets.UTF_8)));\n    } catch (final NoSuchAlgorithmException e) {\n      // All Java implementations are required to support SHA-1, so this should never happen\n      throw new AssertionError(e);\n    }\n  }\n\n  @VisibleForTesting\n  String getSha() {\n    return sha;\n  }\n\n  public Object execute(final List<String> keys, final List<String> args) {\n    return redisCluster.withCluster(connection ->\n        execute(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));\n  }\n\n  public CompletableFuture<Object> executeAsync(final List<String> keys, final List<String> args) {\n    return redisCluster.withCluster(connection ->\n        executeAsync(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));\n  }\n\n  public Flux<Object> executeReactive(final List<String> keys, final List<String> args) {\n    return redisCluster.withCluster(connection ->\n        executeReactive(connection, keys.toArray(STRING_ARRAY), args.toArray(STRING_ARRAY)));\n  }\n\n  public Object executeBinary(final List<byte[]> keys, final List<byte[]> args) {\n    return redisCluster.withBinaryCluster(connection ->\n        execute(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));\n  }\n\n  public CompletableFuture<Object> executeBinaryAsync(final List<byte[]> keys, final List<byte[]> args) {\n    return redisCluster.withBinaryCluster(connection ->\n        executeAsync(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));\n  }\n\n  public Flux<Object> executeBinaryReactive(final List<byte[]> keys, final List<byte[]> args) {\n    return redisCluster.withBinaryCluster(connection ->\n        executeReactive(connection, keys.toArray(BYTE_ARRAY_ARRAY), args.toArray(BYTE_ARRAY_ARRAY)));\n  }\n\n  private <T> Object execute(final StatefulRedisClusterConnection<T, T> connection, final T[] keys, final T[] args) {\n    try {\n      try {\n        return connection.sync().evalsha(sha, scriptOutputType, keys, args);\n      } catch (final RedisNoScriptException e) {\n        return connection.sync().eval(script, scriptOutputType, keys, args);\n      }\n    } catch (final Exception e) {\n      log.warn(\"Failed to execute script\", e);\n      throw e;\n    }\n  }\n\n  private <T> CompletableFuture<Object> executeAsync(final StatefulRedisClusterConnection<T, T> connection,\n      final T[] keys, final T[] args) {\n\n    return connection.async().evalsha(sha, scriptOutputType, keys, args)\n        .exceptionallyCompose(throwable -> {\n          if (throwable instanceof RedisNoScriptException) {\n            return connection.async().eval(script, scriptOutputType, keys, args);\n          }\n\n          log.warn(\"Failed to execute script\", throwable);\n          throw new RedisException(throwable);\n        }).toCompletableFuture();\n  }\n\n  private <T> Flux<Object> executeReactive(final StatefulRedisClusterConnection<T, T> connection,\n      final T[] keys, final T[] args) {\n\n    return connection.reactive().evalsha(sha, scriptOutputType, keys, args)\n        .onErrorResume(RedisNoScriptException.class, _ -> connection.reactive().eval(script, scriptOutputType, keys, args))\n        .doOnError(throwable -> log.warn(\"Failed to execute script\", throwable));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/ConnectionEventLogger.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.event.connection.ConnectionEvent;\nimport io.lettuce.core.resource.ClientResources;\nimport io.micrometer.core.instrument.Metrics;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class ConnectionEventLogger {\n\n  private static final String EVENT_COUNTER_NAME = name(ConnectionEventLogger.class, \"events\");\n\n  private static final Logger logger = LoggerFactory.getLogger(ConnectionEventLogger.class);\n\n  public static void logConnectionEvents(final ClientResources clientResources) {\n\n    clientResources.eventBus().get().subscribe(event -> {\n      if (event instanceof ConnectionEvent) {\n        logger.debug(\"Connection event: {}\", event);\n      } else if (event instanceof ClusterTopologyChangedEvent) {\n        logger.info(\"Cluster topology changed: {}\", event);\n      }\n\n      Metrics.counter(EVENT_COUNTER_NAME, \"type\", event.getClass().getSimpleName()).increment();\n    });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubClusterConnection.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.github.resilience4j.retry.Retry;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport reactor.core.scheduler.Scheduler;\nimport java.util.function.Consumer;\n\npublic class FaultTolerantPubSubClusterConnection<K, V> extends AbstractFaultTolerantPubSubConnection<K, V, StatefulRedisClusterPubSubConnection<K, V>> {\n\n  private final Logger logger = LoggerFactory.getLogger(FaultTolerantPubSubClusterConnection.class);\n\n  private final Retry resubscribeRetry;\n  private final Scheduler topologyChangedEventScheduler;\n\n  protected FaultTolerantPubSubClusterConnection(final String name,\n      final StatefulRedisClusterPubSubConnection<K, V> pubSubConnection,\n      final Retry resubscribeRetry,\n      final Scheduler topologyChangedEventScheduler) {\n\n    super(name, pubSubConnection);\n\n    pubSubConnection.setNodeMessagePropagation(true);\n\n    this.resubscribeRetry = resubscribeRetry;\n    this.topologyChangedEventScheduler = topologyChangedEventScheduler;\n  }\n\n  public void subscribeToClusterTopologyChangedEvents(final Consumer<ClusterTopologyChangedEvent> eventHandler) {\n\n    usePubSubConnection(connection -> connection.getResources().eventBus().get()\n        .filter(event -> {\n          // If we use shared `ClientResources` for multiple clients, we may receive topology change events for clusters\n          // other than our own. Filter for clusters that have at least one node in common with our current view of our\n          // partitions.\n          if (event instanceof ClusterTopologyChangedEvent clusterTopologyChangedEvent) {\n            return withPubSubConnection(c -> c.getPartitions().stream().anyMatch(redisClusterNode ->\n                clusterTopologyChangedEvent.before().contains(redisClusterNode) ||\n                clusterTopologyChangedEvent.after().contains(redisClusterNode)));\n          }\n\n          return false;\n        })\n        .subscribeOn(topologyChangedEventScheduler)\n        .subscribe(event -> {\n          logger.info(\"Got topology change event for {}, resubscribing all keyspace notifications\", getName());\n\n          resubscribeRetry.executeRunnable(() -> {\n            try {\n              eventHandler.accept((ClusterTopologyChangedEvent) event);\n            } catch (final RuntimeException e) {\n              logger.warn(\"Resubscribe for {} failed\", getName(), e);\n              throw e;\n            }\n          });\n        }));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubConnection.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.lettuce.core.pubsub.StatefulRedisPubSubConnection;\n\npublic class FaultTolerantPubSubConnection<K, V> extends AbstractFaultTolerantPubSubConnection<K, V, StatefulRedisPubSubConnection<K, V>> {\n\n  protected FaultTolerantPubSubConnection(final String name,\n      final StatefulRedisPubSubConnection<K, V> pubSubConnection) {\n\n    super(name, pubSubConnection);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClient.java",
    "content": "package org.whispersystems.textsecuregcm.redis;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.lettuce.core.ClientOptions;\nimport io.lettuce.core.RedisClient;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.RedisURI;\nimport io.lettuce.core.TimeoutOptions;\nimport io.lettuce.core.api.StatefulRedisConnection;\nimport io.lettuce.core.codec.ByteArrayCodec;\nimport io.lettuce.core.pubsub.StatefulRedisPubSubConnection;\nimport io.lettuce.core.resource.ClientResources;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\nimport org.whispersystems.textsecuregcm.configuration.RedisConfiguration;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\npublic class FaultTolerantRedisClient {\n\n  private final String name;\n\n  private final RedisClient redisClient;\n\n  private final StatefulRedisConnection<String, String> stringConnection;\n  private final StatefulRedisConnection<byte[], byte[]> binaryConnection;\n\n  private final List<StatefulRedisPubSubConnection<?, ?>> pubSubConnections = new ArrayList<>();\n\n  private final CircuitBreaker circuitBreaker;\n\n  public FaultTolerantRedisClient(final String name,\n                                  final RedisConfiguration redisConfiguration,\n                                  final ClientResources.Builder clientResourcesBuilder) {\n\n    this(name, clientResourcesBuilder,\n        RedisUriUtil.createRedisUriWithTimeout(redisConfiguration.getUri(), redisConfiguration.getTimeout()),\n        redisConfiguration.getTimeout(),\n        redisConfiguration.getCircuitBreakerConfigurationName() != null\n            ? ResilienceUtil.getCircuitBreakerRegistry().circuitBreaker(getCircuitBreakerName(name), redisConfiguration.getCircuitBreakerConfigurationName())\n            : ResilienceUtil.getCircuitBreakerRegistry().circuitBreaker(getCircuitBreakerName(name)));\n  }\n\n  private static String getCircuitBreakerName(final String name) {\n    return ResilienceUtil.name(FaultTolerantRedisClient.class, name);\n  }\n\n  @VisibleForTesting\n  FaultTolerantRedisClient(String name,\n                           final ClientResources.Builder clientResourcesBuilder,\n                           final RedisURI redisUri,\n                           final Duration commandTimeout,\n                           final CircuitBreaker circuitBreaker) {\n\n    this.name = name;\n\n    // Lettuce will issue a CLIENT SETINFO command unconditionally if these fields are set (and they are by default),\n    // which can generate a bunch of spurious warnings in versions of Redis before 7.2.0.\n    //\n    // See:\n    //\n    // - https://github.com/redis/lettuce/pull/2823\n    // - https://github.com/redis/lettuce/issues/2817\n    redisUri.setClientName(null);\n    redisUri.setLibraryName(null);\n    redisUri.setLibraryVersion(null);\n\n    this.redisClient = RedisClient.create(clientResourcesBuilder.build(), redisUri);\n    final ClientOptions.Builder clientOptionsBuilder = ClientOptions.builder()\n        .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)\n        // for asynchronous commands\n        .timeoutOptions(TimeoutOptions.builder()\n            .fixedTimeout(commandTimeout)\n            .build())\n        .publishOnScheduler(true);\n\n    NettyUtil.setSocketTimeoutsIfApplicable(clientOptionsBuilder);\n\n    this.redisClient.setOptions(clientOptionsBuilder.build());\n\n    this.stringConnection = redisClient.connect();\n    this.binaryConnection = redisClient.connect(ByteArrayCodec.INSTANCE);\n\n    this.circuitBreaker = circuitBreaker;\n  }\n\n  public void shutdown() {\n    stringConnection.close();\n\n    for (final StatefulRedisPubSubConnection<?, ?> pubSubConnection : pubSubConnections) {\n      pubSubConnection.close();\n    }\n\n    redisClient.shutdown();\n  }\n\n  public String getName() {\n    return name;\n  }\n\n  public void useConnection(final Consumer<StatefulRedisConnection<String, String>> consumer) {\n    useConnection(stringConnection, consumer);\n  }\n\n  public <T> T withConnection(final Function<StatefulRedisConnection<String, String>, T> function) {\n    return withConnection(stringConnection, function);\n  }\n\n  public void useBinaryConnection(final Consumer<StatefulRedisConnection<byte[], byte[]>> consumer) {\n    useConnection(binaryConnection, consumer);\n  }\n\n  public <T> T withBinaryConnection(final Function<StatefulRedisConnection<byte[], byte[]>, T> function) {\n    return withConnection(binaryConnection, function);\n  }\n\n  private <K, V> void useConnection(final StatefulRedisConnection<K, V> connection,\n      final Consumer<StatefulRedisConnection<K, V>> consumer) {\n    try {\n      circuitBreaker.executeRunnable(() -> consumer.accept(connection));\n    } catch (final Throwable t) {\n      if (t instanceof RedisException) {\n        throw (RedisException) t;\n      } else {\n        throw new RedisException(t);\n      }\n    }\n  }\n\n  private <T, K, V> T withConnection(final StatefulRedisConnection<K, V> connection,\n      final Function<StatefulRedisConnection<K, V>, T> function) {\n    try {\n      return circuitBreaker.executeCallable(() -> function.apply(connection));\n    } catch (final Throwable t) {\n      if (t instanceof RedisException) {\n        throw (RedisException) t;\n      } else {\n        throw new RedisException(t);\n      }\n    }\n  }\n\n  public FaultTolerantPubSubConnection<String, String> createPubSubConnection() {\n    final StatefulRedisPubSubConnection<String, String> pubSubConnection = redisClient.connectPubSub();\n    pubSubConnections.add(pubSubConnection);\n\n    return new FaultTolerantPubSubConnection<>(name, pubSubConnection);\n  }\n\n  public FaultTolerantPubSubConnection<byte[], byte[]> createBinaryPubSubConnection() {\n    final StatefulRedisPubSubConnection<byte[], byte[]> pubSubConnection = redisClient.connectPubSub(ByteArrayCodec.INSTANCE);\n    pubSubConnections.add(pubSubConnection);\n\n    return new FaultTolerantPubSubConnection<>(name, pubSubConnection);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClusterClient.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.github.resilience4j.core.IntervalFunction;\nimport io.github.resilience4j.retry.Retry;\nimport io.github.resilience4j.retry.RetryConfig;\nimport io.lettuce.core.ClientOptions;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.RedisURI;\nimport io.lettuce.core.TimeoutOptions;\nimport io.lettuce.core.cluster.ClusterClientOptions;\nimport io.lettuce.core.cluster.ClusterTopologyRefreshOptions;\nimport io.lettuce.core.cluster.RedisClusterClient;\nimport io.lettuce.core.cluster.api.StatefulRedisClusterConnection;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;\nimport io.lettuce.core.codec.ByteArrayCodec;\nimport io.lettuce.core.resource.ClientResources;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.configuration.RedisClusterConfiguration;\nimport reactor.core.scheduler.Schedulers;\n\n/**\n * A fault-tolerant access manager for a Redis cluster. Each shard in the cluster has a dedicated circuit breaker.\n *\n * @see LettuceShardCircuitBreaker\n */\npublic class FaultTolerantRedisClusterClient {\n\n  private final String name;\n\n  private final RedisClusterClient clusterClient;\n\n  private final StatefulRedisClusterConnection<String, String> stringConnection;\n  private final StatefulRedisClusterConnection<byte[], byte[]> binaryConnection;\n\n  private final List<StatefulRedisClusterPubSubConnection<?, ?>> pubSubConnections = new ArrayList<>();\n\n  private final Retry topologyChangedEventRetry;\n\n\n  public FaultTolerantRedisClusterClient(final String name,\n      final RedisClusterConfiguration clusterConfiguration,\n      final ClientResources.Builder clientResourcesBuilder) {\n\n    this(name, clientResourcesBuilder,\n        Collections.singleton(RedisUriUtil.createRedisUriWithTimeout(clusterConfiguration.getConfigurationUri(),\n            clusterConfiguration.getTimeout())),\n        clusterConfiguration.getTimeout(),\n        clusterConfiguration.getCircuitBreakerConfigurationName());\n\n  }\n\n  FaultTolerantRedisClusterClient(final String name,\n      final ClientResources.Builder clientResourcesBuilder,\n      final Iterable<RedisURI> redisUris,\n      final Duration commandTimeout,\n      @Nullable final String circuitBreakerConfigurationName) {\n\n    this.name = name;\n\n    // Lettuce will issue a CLIENT SETINFO command unconditionally if these fields are set (and they are by default),\n    // which can generate a bunch of spurious warnings in versions of Redis before 7.2.0.\n    //\n    // See:\n    //\n    // - https://github.com/redis/lettuce/pull/2823\n    // - https://github.com/redis/lettuce/issues/2817\n    redisUris.forEach(redisUri -> {\n      redisUri.setClientName(null);\n      redisUri.setLibraryName(null);\n      redisUri.setLibraryVersion(null);\n    });\n\n    final LettuceShardCircuitBreaker lettuceShardCircuitBreaker =\n        new LettuceShardCircuitBreaker(name, circuitBreakerConfigurationName);\n\n    this.clusterClient = RedisClusterClient.create(\n        clientResourcesBuilder.nettyCustomizer(lettuceShardCircuitBreaker).\n            build(),\n        redisUris);\n\n    final ClusterClientOptions.Builder clusterClientOptionsBuilder = ClusterClientOptions.builder()\n        .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)\n        .validateClusterNodeMembership(false)\n        .topologyRefreshOptions(ClusterTopologyRefreshOptions.builder()\n            .enableAllAdaptiveRefreshTriggers()\n            .build())\n        // for asynchronous commands\n        .timeoutOptions(TimeoutOptions.builder()\n            .fixedTimeout(commandTimeout)\n            .build())\n        .publishOnScheduler(true);\n\n    NettyUtil.setSocketTimeoutsIfApplicable(clusterClientOptionsBuilder);\n\n    this.clusterClient.setOptions(clusterClientOptionsBuilder.build());\n\n    this.stringConnection = clusterClient.connect();\n    this.binaryConnection = clusterClient.connect(ByteArrayCodec.INSTANCE);\n\n    // create a synthetic topology changed event to notify shard circuit breakers of initial upstreams\n    clusterClient.getResources().eventBus().publish(\n        new ClusterTopologyChangedEvent(Collections.emptyList(), clusterClient.getPartitions().getPartitions()));\n\n    final RetryConfig topologyChangedEventRetryConfig = RetryConfig.custom()\n        .maxAttempts(Integer.MAX_VALUE)\n        .intervalFunction(\n            IntervalFunction.ofExponentialRandomBackoff(Duration.ofSeconds(1), 1.5, Duration.ofSeconds(30)))\n        .build();\n\n    this.topologyChangedEventRetry = Retry.of(name + \"-topologyChangedRetry\", topologyChangedEventRetryConfig);\n  }\n\n  public void shutdown() {\n    stringConnection.close();\n    binaryConnection.close();\n\n    for (final StatefulRedisClusterPubSubConnection<?, ?> pubSubConnection : pubSubConnections) {\n      pubSubConnection.close();\n    }\n\n    clusterClient.shutdown();\n  }\n\n  public String getName() {\n    return name;\n  }\n\n  public void useCluster(final Consumer<StatefulRedisClusterConnection<String, String>> consumer) {\n    useConnection(stringConnection, consumer);\n  }\n\n  public <T> T withCluster(final Function<StatefulRedisClusterConnection<String, String>, T> function) {\n    return withConnection(stringConnection, function);\n  }\n\n  public void useBinaryCluster(final Consumer<StatefulRedisClusterConnection<byte[], byte[]>> consumer) {\n    useConnection(binaryConnection, consumer);\n  }\n\n  public <T> T withBinaryCluster(final Function<StatefulRedisClusterConnection<byte[], byte[]>, T> function) {\n    return withConnection(binaryConnection, function);\n  }\n\n  private <K, V> void useConnection(final StatefulRedisClusterConnection<K, V> connection,\n      final Consumer<StatefulRedisClusterConnection<K, V>> consumer) {\n    try {\n      consumer.accept(connection);\n    } catch (final Throwable t) {\n      if (t instanceof RedisException) {\n        throw (RedisException) t;\n      } else {\n        throw new RedisException(t);\n      }\n    }\n  }\n\n  private <T, K, V> T withConnection(final StatefulRedisClusterConnection<K, V> connection,\n      final Function<StatefulRedisClusterConnection<K, V>, T> function) {\n    try {\n      return function.apply(connection);\n    } catch (final Throwable t) {\n      if (t instanceof RedisException) {\n        throw (RedisException) t;\n      } else {\n        throw new RedisException(t);\n      }\n    }\n  }\n\n  public FaultTolerantPubSubClusterConnection<String, String> createPubSubConnection() {\n    final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();\n    pubSubConnections.add(pubSubConnection);\n\n    return new FaultTolerantPubSubClusterConnection<>(name, pubSubConnection, topologyChangedEventRetry,\n        Schedulers.newSingle(name + \"-redisPubSubEvents\", true));\n  }\n\n  public FaultTolerantPubSubClusterConnection<byte[], byte[]> createBinaryPubSubConnection() {\n    final StatefulRedisClusterPubSubConnection<byte[], byte[]> pubSubConnection = clusterClient.connectPubSub(ByteArrayCodec.INSTANCE);\n    pubSubConnections.add(pubSubConnection);\n\n    return new FaultTolerantPubSubClusterConnection<>(name, pubSubConnection, topologyChangedEventRetry,\n        Schedulers.newSingle(name + \"-redisPubSubEvents\", true));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/LettuceShardCircuitBreaker.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.github.resilience4j.circuitbreaker.CallNotPermittedException;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.lettuce.core.RedisNoScriptException;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.protocol.CommandHandler;\nimport io.lettuce.core.protocol.CompleteableCommand;\nimport io.lettuce.core.protocol.RedisCommand;\nimport io.lettuce.core.resource.NettyCustomizer;\nimport io.netty.channel.Channel;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.channel.ChannelOutboundHandlerAdapter;\nimport io.netty.channel.ChannelPromise;\nimport java.net.SocketAddress;\nimport java.util.Collection;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.StreamSupport;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\n/**\n * Adds a circuit breaker to every Netty {@link Channel} that gets created, so that a single unhealthy shard does not\n * impact all cluster operations.\n * <p>\n * For metrics to be registered, users <em>must</em> create a synthetic {@link ClusterTopologyChangedEvent} after the\n * initial connection. For example:\n * <pre>\n *   clusterClient.connect();\n *   clusterClient.getResources().eventBus().publish(\n *         new ClusterTopologyChangedEvent(Collections.emptyList(), clusterClient.getPartitions().getPartitions()));\n * </pre>\n */\npublic class LettuceShardCircuitBreaker implements NettyCustomizer {\n\n  private static final Logger logger = LoggerFactory.getLogger(LettuceShardCircuitBreaker.class);\n\n  private final String clusterName;\n  @Nullable\n  private final String circuitBreakerConfigurationName;\n\n  public LettuceShardCircuitBreaker(final String clusterName, @Nullable final String circuitBreakerConfigurationName) {\n    this.clusterName = clusterName;\n    this.circuitBreakerConfigurationName = circuitBreakerConfigurationName;\n  }\n\n  @Override\n  public void afterChannelInitialized(final Channel channel) {\n    final ChannelCircuitBreakerHandler channelCircuitBreakerHandler =\n        new ChannelCircuitBreakerHandler(clusterName, circuitBreakerConfigurationName);\n\n    final String commandHandlerName = StreamSupport.stream(channel.pipeline().spliterator(), false)\n        .filter(entry -> entry.getValue() instanceof CommandHandler)\n        .map(Map.Entry::getKey)\n        .findFirst()\n        .orElseThrow();\n    channel.pipeline().addBefore(commandHandlerName, null, channelCircuitBreakerHandler);\n  }\n\n  static final class ChannelCircuitBreakerHandler extends ChannelOutboundHandlerAdapter {\n\n    private static final Logger logger = LoggerFactory.getLogger(ChannelCircuitBreakerHandler.class);\n\n    private static final String CLUSTER_TAG_NAME = \"cluster\";\n    private static final String SHARD_ADDRESS_TAG_NAME = \"shard\";\n\n    private final String clusterName;\n    @Nullable private final String circuitBreakerConfigurationName;\n\n    private String shardAddress;\n\n    @VisibleForTesting\n    CircuitBreaker breaker;\n\n    public ChannelCircuitBreakerHandler(final String name, @Nullable final String circuitBreakerConfigurationName) {\n      this.clusterName = name;\n      this.circuitBreakerConfigurationName = circuitBreakerConfigurationName;\n    }\n\n    @Override\n    public void connect(final ChannelHandlerContext ctx, final SocketAddress remoteAddress,\n        final SocketAddress localAddress, final ChannelPromise promise) throws Exception {\n      super.connect(ctx, remoteAddress, localAddress, promise);\n      // Unfortunately, the Channel's remote address is null until connect() is called, so we have to wait to initialize\n      // the breaker with the remote’s name.\n      // There is a Channel attribute, io.lettuce.core.ConnectionBuilder.REDIS_URI, but this does not always\n      // match remote address, as it is inherited from the Bootstrap attributes and not updated for the Channel connection\n\n      // In some cases, like the default connection, the remote address includes the DNS hostname, which we want to exclude.\n      shardAddress = StringUtils.substringAfter(remoteAddress.toString(), \"/\");\n\n      final String circuitBreakerName =\n          ResilienceUtil.name(LettuceShardCircuitBreaker.class, \"%s/%s\".formatted(clusterName, shardAddress));\n\n      final Map<String, String> tags = Map.of(\n          CLUSTER_TAG_NAME, clusterName,\n          SHARD_ADDRESS_TAG_NAME, shardAddress);\n\n      breaker = circuitBreakerConfigurationName != null\n          ? ResilienceUtil.getCircuitBreakerRegistry().circuitBreaker(circuitBreakerName, circuitBreakerConfigurationName, tags)\n          : ResilienceUtil.getCircuitBreakerRegistry().circuitBreaker(circuitBreakerName, tags);\n    }\n\n    @Override\n    public void write(final ChannelHandlerContext ctx, final Object msg, final ChannelPromise promise)\n        throws Exception {\n\n      logger.trace(\"Breaker state is {}\", breaker.getState());\n\n      // There are two types of RedisCommands that are not CompleteableCommand:\n      // - io.lettuce.core.protocol.Command\n      // - io.lettuce.core.protocol.PristineFallbackCommand\n      //\n      // The former always get wrapped by one of the other command types, and the latter is only used in an edge case\n      // to consume responses.\n      if (msg instanceof RedisCommand<?, ?, ?> rc && rc instanceof CompleteableCommand<?> command) {\n        try {\n          instrumentCommand(command);\n        } catch (final CallNotPermittedException e) {\n          rc.completeExceptionally(e);\n          promise.tryFailure(e);\n          return;\n        }\n\n      } else if (msg instanceof Collection<?> collection &&\n          !collection.isEmpty() &&\n          collection.stream().allMatch(obj -> obj instanceof RedisCommand && obj instanceof CompleteableCommand<?>)) {\n\n        @SuppressWarnings(\"unchecked\") final Collection<RedisCommand<?, ?, ?>> commandCollection =\n            (Collection<RedisCommand<?, ?, ?>>) collection;\n\n        try {\n          // If we have a collection of commands, we only acquire a single permit for the whole batch (since there's\n          // only a single write promise to fail). We choose a single command from the collection to sample for failure.\n          instrumentCommand((CompleteableCommand<?>) commandCollection.iterator().next());\n        } catch (final CallNotPermittedException e) {\n          commandCollection.forEach(redisCommand -> redisCommand.completeExceptionally(e));\n          promise.tryFailure(e);\n          return;\n        }\n      } else {\n        logger.warn(\"Unexpected msg type: {}\", msg.getClass());\n      }\n\n      super.write(ctx, msg, promise);\n    }\n\n    private void instrumentCommand(final CompleteableCommand<?> command) throws CallNotPermittedException {\n      breaker.acquirePermission();\n\n      // state can change in acquirePermission()\n      logger.trace(\"Breaker is permitted: {}\", breaker.getState());\n\n      final long startNanos = System.nanoTime();\n\n      command.onComplete((ignored, throwable) -> {\n        final long durationNanos = System.nanoTime() - startNanos;\n\n        // RedisNoScriptException doesn’t indicate a fault the breaker can protect\n        if (throwable != null && !(throwable instanceof RedisNoScriptException)) {\n          breaker.onError(durationNanos, TimeUnit.NANOSECONDS, throwable);\n          logger.warn(\"Command completed with error for: {}/{}\", clusterName, shardAddress, throwable);\n        } else {\n          breaker.onSuccess(durationNanos, TimeUnit.NANOSECONDS);\n        }\n      });\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/NettyUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.lettuce.core.ClientOptions;\nimport io.lettuce.core.SocketOptions;\nimport io.lettuce.core.resource.EpollProvider;\nimport java.time.Duration;\n\npublic class NettyUtil {\n  static final Duration TCP_KEEPALIVE_IDLE = Duration.ofSeconds(30);\n  static final Duration TCP_KEEPALIVE_INTERVAL = Duration.ofSeconds(30);\n  static final Duration TCP_USER_TIMEOUT = Duration.ofSeconds(30);\n\n  static void setSocketTimeoutsIfApplicable(final ClientOptions.Builder clientOptionsBuilder) {\n    if (EpollProvider.isAvailable()) {\n      // These socket options are only available with epoll native transport.\n      clientOptionsBuilder.socketOptions(SocketOptions.builder()\n          .keepAlive(SocketOptions.KeepAliveOptions.builder()\n              .interval(TCP_KEEPALIVE_INTERVAL)\n              .idle(TCP_KEEPALIVE_IDLE)\n              .enable()\n              .build())\n          .tcpUserTimeout(SocketOptions.TcpUserTimeoutOptions.builder()\n              .enable()\n              .tcpUserTimeout(TCP_USER_TIMEOUT)\n              .build())\n          .build());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/RedisOperation.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.lettuce.core.RedisException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class RedisOperation {\n\n  private static final Logger logger = LoggerFactory.getLogger(RedisOperation.class);\n\n  /**\n   * Executes the given task and logs and discards any {@link RedisException} that may be thrown. This method should be\n   * used for best-effort tasks like gathering metrics.\n   *\n   * @param runnable the Redis-related task to be executed\n   */\n  public static void unchecked(final Runnable runnable) {\n    try {\n      runnable.run();\n    } catch (RedisException e) {\n      logger.warn(\"Redis failure\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/redis/RedisUriUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.lettuce.core.RedisURI;\nimport java.time.Duration;\n\npublic class RedisUriUtil {\n\n  public static RedisURI createRedisUriWithTimeout(final String uri, final Duration timeout) {\n    final RedisURI redisUri = RedisURI.create(uri);\n    // for synchronous commands and the initial connection\n    redisUri.setTimeout(timeout);\n    return redisUri;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/ClientType.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\npublic enum ClientType {\n  IOS,\n  ANDROID_WITH_FCM,\n  ANDROID_WITHOUT_FCM,\n  UNKNOWN\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/IdentityTokenCallCredentials.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.registration;\n\nimport com.google.auth.oauth2.ExternalAccountCredentials;\nimport com.google.auth.oauth2.ImpersonatedCredentials;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.lifecycle.Managed;\nimport io.github.resilience4j.core.IntervalFunction;\nimport io.github.resilience4j.retry.Retry;\nimport io.github.resilience4j.retry.RetryConfig;\nimport io.grpc.CallCredentials;\nimport io.grpc.Metadata;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class IdentityTokenCallCredentials extends CallCredentials implements Managed {\n  private static final Duration IDENTITY_TOKEN_LIFETIME = Duration.ofHours(1);\n  private static final Duration IDENTITY_TOKEN_REFRESH_BUFFER = Duration.ofMinutes(10);\n\n  static final Metadata.Key<String> AUTHORIZATION_METADATA_KEY =\n      Metadata.Key.of(\"Authorization\", Metadata.ASCII_STRING_MARSHALLER);\n\n  private static final Logger logger = LoggerFactory.getLogger(IdentityTokenCallCredentials.class);\n\n  private final Retry retry;\n  private final ImpersonatedCredentials impersonatedCredentials;\n  private final String audience;\n  private final ScheduledFuture<?> scheduledFuture;\n\n  private volatile Pair<String, RuntimeException> currentIdentityToken;\n\n  IdentityTokenCallCredentials(\n      final RetryConfig retryConfig,\n      final ImpersonatedCredentials impersonatedCredentials,\n      final String audience,\n      final ScheduledExecutorService scheduledExecutorService) {\n    this.impersonatedCredentials = impersonatedCredentials;\n    this.audience = audience;\n    this.retry = Retry.of(\"identity-token-fetch\", retryConfig);\n    scheduledFuture = scheduledExecutorService.scheduleAtFixedRate(this::refreshIdentityToken,\n        IDENTITY_TOKEN_LIFETIME.minus(IDENTITY_TOKEN_REFRESH_BUFFER).toMillis(),\n        IDENTITY_TOKEN_LIFETIME.minus(IDENTITY_TOKEN_REFRESH_BUFFER).toMillis(),\n        TimeUnit.MILLISECONDS);\n  }\n\n  public static IdentityTokenCallCredentials fromCredentialConfig(\n      final String credentialConfigJson,\n      final String audience,\n      final ScheduledExecutorService scheduledExecutorService) throws IOException {\n    try (final InputStream configInputStream = new ByteArrayInputStream(\n        credentialConfigJson.getBytes(StandardCharsets.UTF_8))) {\n      final ExternalAccountCredentials credentials = ExternalAccountCredentials.fromStream(configInputStream);\n      final ImpersonatedCredentials impersonatedCredentials = ImpersonatedCredentials.create(credentials,\n          credentials.getServiceAccountEmail(), null, List.of(), (int) IDENTITY_TOKEN_LIFETIME.toSeconds());\n\n      final IdentityTokenCallCredentials identityTokenCallCredentials = new IdentityTokenCallCredentials(\n          RetryConfig.custom()\n              .retryOnException(throwable -> true)\n              .maxAttempts(Integer.MAX_VALUE)\n              .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(\n                      Duration.ofMillis(100), 1.5, Duration.ofSeconds(5)))\n              .build(), impersonatedCredentials, audience, scheduledExecutorService);\n\n      // Make sure credentials are initially populated\n      identityTokenCallCredentials.refreshIdentityToken();\n\n      return identityTokenCallCredentials;\n    }\n  }\n\n  @VisibleForTesting\n  void refreshIdentityToken() {\n    retry.executeRunnable(() -> {\n      try {\n        impersonatedCredentials.getSourceCredentials().refresh();\n        this.currentIdentityToken = Pair.of(\n            impersonatedCredentials.idTokenWithAudience(audience, null).getTokenValue(),\n            null);\n      } catch (final IOException e) {\n        logger.warn(\"Failed to retrieve identity token\", e);\n        final UncheckedIOException wrapped = new UncheckedIOException(e);\n        this.currentIdentityToken = Pair.of(null, wrapped);\n        throw wrapped;\n      } catch (final RuntimeException e) {\n        logger.error(\"Failed to retrieve identity token\", e);\n        this.currentIdentityToken = Pair.of(null, e);\n        throw e;\n      }\n    });\n  }\n\n  @Override\n  public void applyRequestMetadata(final RequestInfo requestInfo,\n      final Executor appExecutor,\n      final MetadataApplier applier) {\n\n    final Pair<String, RuntimeException> pair = currentIdentityToken;\n    if (pair.getRight() != null) {\n      throw pair.getRight();\n    }\n\n    final String identityTokenValue = pair.getLeft();\n\n    if (identityTokenValue != null) {\n      final Metadata metadata = new Metadata();\n      metadata.put(AUTHORIZATION_METADATA_KEY, \"Bearer \" + identityTokenValue);\n\n      applier.apply(metadata);\n    }\n  }\n\n  @Override\n  public void stop() {\n    synchronized (this) {\n      if (!scheduledFuture.isDone()) {\n        scheduledFuture.cancel(true);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/MessageTransport.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\n/**\n * A message transport is a medium via which verification codes can be delivered to a destination phone.\n */\npublic enum MessageTransport {\n  SMS,\n  VOICE\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationFraudException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\npublic class RegistrationFraudException extends Exception {\n  public RegistrationFraudException(final RegistrationServiceSenderException cause) {\n    super(null, cause, true, false);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceClient.java",
    "content": "package org.whispersystems.textsecuregcm.registration;\n\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.lifecycle.Managed;\nimport io.grpc.CallCredentials;\nimport io.grpc.ChannelCredentials;\nimport io.grpc.Deadline;\nimport io.grpc.Grpc;\nimport io.grpc.ManagedChannel;\nimport io.grpc.TlsChannelCredentials;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.util.Base64;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.TimeUnit;\nimport javax.annotation.Nullable;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.registration.rpc.CheckVerificationCodeRequest;\nimport org.signal.registration.rpc.CreateRegistrationSessionRequest;\nimport org.signal.registration.rpc.GetRegistrationSessionMetadataRequest;\nimport org.signal.registration.rpc.RegistrationServiceGrpc;\nimport org.signal.registration.rpc.RegistrationSessionMetadata;\nimport org.signal.registration.rpc.SendVerificationCodeRequest;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.controllers.VerificationSessionRateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureUtil;\n\npublic class RegistrationServiceClient implements Managed {\n\n  private static final Base64.Encoder BASE64_UNPADDED_ENCODER = Base64.getEncoder().withoutPadding();\n\n  private final ManagedChannel channel;\n  private final RegistrationServiceGrpc.RegistrationServiceFutureStub stub;\n  private final Executor callbackExecutor;\n  private final byte[] collationKeySalt;\n\n  /**\n   * @param from an e164 in a {@code long} representation e.g. {@code 18005550123}\n   * @return the e164 in a {@code String} representation (e.g. {@code \"+18005550123\"})\n   * @throws IllegalArgumentException if the number cannot be parsed to a string\n   */\n  static String convertNumeralE164ToString(long from) {\n\n    try {\n      final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance()\n          .parse(\"+\" + from, null);\n      return PhoneNumberUtil.getInstance()\n          .format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);\n    } catch (final NumberParseException e) {\n      throw new IllegalArgumentException(\"could not parse to phone number\", e);\n    }\n  }\n\n  public RegistrationServiceClient(final String host,\n      final int port,\n      final CallCredentials callCredentials,\n      final String caCertificatePem,\n      final byte[] collationKeySalt,\n      final Executor callbackExecutor) throws IOException {\n\n    try (final ByteArrayInputStream certificateInputStream = new ByteArrayInputStream(caCertificatePem.getBytes(StandardCharsets.UTF_8))) {\n      final ChannelCredentials tlsChannelCredentials = TlsChannelCredentials.newBuilder()\n          .trustManager(certificateInputStream)\n          .build();\n\n      this.channel = Grpc.newChannelBuilderForAddress(host, port, tlsChannelCredentials)\n          .idleTimeout(1, TimeUnit.MINUTES)\n          .build();\n    }\n\n    this.stub = RegistrationServiceGrpc.newFutureStub(channel).withCallCredentials(callCredentials);\n    this.collationKeySalt = collationKeySalt;\n    this.callbackExecutor = callbackExecutor;\n\n    // Fail fast: reject bad keys\n    try {\n      getInitializedMac(collationKeySalt);\n    } catch (final InvalidKeyException e) {\n      throw new IllegalArgumentException(e);\n    }\n  }\n\n  public CompletableFuture<RegistrationServiceSession> createRegistrationSession(\n      final Phonenumber.PhoneNumber phoneNumber,\n      final String sourceHost,\n      final boolean accountExistsWithPhoneNumber,\n      @Nullable final String clientMcc,\n      @Nullable final String clientMnc,\n      final Duration timeout) {\n\n    final long e164 = Long.parseLong(\n        PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164).substring(1));\n    final String rateLimitCollationKey = hmac(sourceHost);\n\n    return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout))\n        .createSession(CreateRegistrationSessionRequest.newBuilder()\n            .setE164(e164)\n            .setAccountExistsWithE164(accountExistsWithPhoneNumber)\n            .setRateLimitCollationKey(rateLimitCollationKey)\n            .setMcc(clientMcc != null ? clientMcc : \"\")\n            .setMnc(clientMnc != null ? clientMnc : \"\")\n            .build()), callbackExecutor)\n        .thenApply(response -> switch (response.getResponseCase()) {\n          case SESSION_METADATA -> buildSessionResponseFromMetadata(response.getSessionMetadata());\n\n          case ERROR -> {\n            switch (response.getError().getErrorType()) {\n              case CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(\n                  new RateLimitExceededException(response.getError().getMayRetry()\n                      ? Duration.ofSeconds(response.getError().getRetryAfterSeconds())\n                      : null\n                  ));\n              case CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER -> throw new IllegalArgumentException();\n              default -> throw new RuntimeException(\n                  \"Unrecognized error type from registration service: \" + response.getError().getErrorType());\n            }\n          }\n\n          case RESPONSE_NOT_SET -> throw new RuntimeException(\"No response from registration service\");\n        });\n  }\n\n  public CompletableFuture<RegistrationServiceSession> sendVerificationCode(final byte[] sessionId,\n      final MessageTransport messageTransport,\n      final ClientType clientType,\n      @Nullable final String acceptLanguage,\n      @Nullable final String senderOverride,\n      final Duration timeout) {\n\n    final SendVerificationCodeRequest.Builder requestBuilder = SendVerificationCodeRequest.newBuilder()\n        .setSessionId(ByteString.copyFrom(sessionId))\n        .setTransport(getRpcMessageTransport(messageTransport))\n        .setClientType(getRpcClientType(clientType));\n\n    if (StringUtils.isNotBlank(acceptLanguage)) {\n      requestBuilder.setAcceptLanguage(acceptLanguage);\n    }\n\n    if (StringUtils.isNotBlank(senderOverride)) {\n      requestBuilder.setSenderName(senderOverride);\n    }\n\n    return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout))\n        .sendVerificationCode(requestBuilder.build()), callbackExecutor)\n        .thenApply(response -> {\n          if (response.hasError()) {\n            switch (response.getError().getErrorType()) {\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(\n                  new VerificationSessionRateLimitExceededException(\n                      buildSessionResponseFromMetadata(response.getSessionMetadata()),\n                      response.getError().getMayRetry()\n                          ? Duration.ofSeconds(response.getError().getRetryAfterSeconds())\n                          : null,\n                      true));\n\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND -> throw new CompletionException(\n                  new RegistrationServiceException(null));\n\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_ALREADY_VERIFIED -> throw new CompletionException(\n                  new RegistrationServiceException(buildSessionResponseFromMetadata(response.getSessionMetadata())));\n\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_REJECTED -> throw new CompletionException(\n                  RegistrationServiceSenderException.rejected(response.getError().getMayRetry()));\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_SUSPECTED_FRAUD ->\n                  throw new CompletionException(new RegistrationFraudException(\n                      RegistrationServiceSenderException.rejected(response.getError().getMayRetry())));\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_ILLEGAL_ARGUMENT -> throw new CompletionException(\n                  RegistrationServiceSenderException.illegalArgument(response.getError().getMayRetry()));\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED -> throw new CompletionException(\n                  RegistrationServiceSenderException.unknown(response.getError().getMayRetry()));\n              case SEND_VERIFICATION_CODE_ERROR_TYPE_TRANSPORT_NOT_ALLOWED -> throw new CompletionException(\n                  new TransportNotAllowedException(buildSessionResponseFromMetadata(response.getSessionMetadata())));\n\n              default -> throw new CompletionException(\n                  new RuntimeException(\"Failed to send verification code: \" + response.getError().getErrorType()));\n            }\n          } else {\n            return buildSessionResponseFromMetadata(response.getSessionMetadata());\n          }\n        });\n  }\n\n  public CompletableFuture<RegistrationServiceSession> checkVerificationCode(final byte[] sessionId,\n      final String verificationCode,\n      final Duration timeout) {\n    return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout))\n        .checkVerificationCode(CheckVerificationCodeRequest.newBuilder()\n            .setSessionId(ByteString.copyFrom(sessionId))\n            .setVerificationCode(verificationCode)\n            .build()), callbackExecutor)\n        .thenApply(response -> {\n          if (response.hasError()) {\n            switch (response.getError().getErrorType()) {\n              case CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED -> throw new CompletionException(\n                  new VerificationSessionRateLimitExceededException(\n                      buildSessionResponseFromMetadata(response.getSessionMetadata()),\n                      response.getError().getMayRetry()\n                          ? Duration.ofSeconds(response.getError().getRetryAfterSeconds())\n                          : null,\n                      true));\n\n              case CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT, CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPT_EXPIRED ->\n                  throw new CompletionException(\n                      new RegistrationServiceException(buildSessionResponseFromMetadata(response.getSessionMetadata()))\n                  );\n\n              case CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND -> throw new CompletionException(\n                  new RegistrationServiceException(null)\n              );\n\n              default -> throw new CompletionException(\n                  new RuntimeException(\"Failed to check verification code: \" + response.getError().getErrorType()));\n            }\n          } else {\n            return buildSessionResponseFromMetadata(response.getSessionMetadata());\n          }\n        });\n  }\n\n  public CompletableFuture<Optional<RegistrationServiceSession>> getSession(final byte[] sessionId,\n      final Duration timeout) {\n    return CompletableFutureUtil.toCompletableFuture(stub.withDeadline(toDeadline(timeout)).getSessionMetadata(\n        GetRegistrationSessionMetadataRequest.newBuilder()\n            .setSessionId(ByteString.copyFrom(sessionId)).build()), callbackExecutor)\n        .thenApply(response -> {\n          if (response.hasError()) {\n            switch (response.getError().getErrorType()) {\n              case GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND -> {\n                return Optional.empty();\n              }\n              default -> throw new RuntimeException(\"Failed to get session: \" + response.getError().getErrorType());\n            }\n          }\n\n          return Optional.of(buildSessionResponseFromMetadata(response.getSessionMetadata()));\n        });\n  }\n\n  private static RegistrationServiceSession buildSessionResponseFromMetadata(\n      final RegistrationSessionMetadata sessionMetadata) {\n    return new RegistrationServiceSession(sessionMetadata.getSessionId().toByteArray(),\n        convertNumeralE164ToString(sessionMetadata.getE164()), sessionMetadata);\n  }\n\n  private static Deadline toDeadline(final Duration timeout) {\n    return Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS);\n  }\n\n  private static org.signal.registration.rpc.ClientType getRpcClientType(final ClientType clientType) {\n    return switch (clientType) {\n      case IOS -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_IOS;\n      case ANDROID_WITH_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITH_FCM;\n      case ANDROID_WITHOUT_FCM -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_ANDROID_WITHOUT_FCM;\n      case UNKNOWN -> org.signal.registration.rpc.ClientType.CLIENT_TYPE_UNSPECIFIED;\n    };\n  }\n\n  private static org.signal.registration.rpc.MessageTransport getRpcMessageTransport(final MessageTransport transport) {\n    return switch (transport) {\n      case SMS -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_SMS;\n      case VOICE -> org.signal.registration.rpc.MessageTransport.MESSAGE_TRANSPORT_VOICE;\n    };\n  }\n\n  @Override\n  public void start() throws Exception {\n  }\n\n  @Override\n  public void stop() throws Exception {\n    if (channel != null) {\n      channel.shutdown();\n    }\n  }\n\n  private String hmac(String sourceHost) {\n      final Mac hmacSha256 = getInitializedMac();\n      hmacSha256.update(sourceHost.getBytes(StandardCharsets.UTF_8));\n\n      return BASE64_UNPADDED_ENCODER.encodeToString(hmacSha256.doFinal());\n  }\n\n  private Mac getInitializedMac() {\n    try {\n      return getInitializedMac(collationKeySalt);\n    } catch (final InvalidKeyException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n\n  private static Mac getInitializedMac(byte[] key) throws InvalidKeyException {\n    try {\n      final Mac hmacSha256 = Mac.getInstance(\"HmacSHA256\");\n      hmacSha256.init(new SecretKeySpec(key, \"HmacSHA256\"));\n      return hmacSha256;\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\n\n/**\n * When the Registration Service returns an error, it will also return the latest {@link RegistrationServiceSession}\n * data, so that clients may have the latest details on requesting and submitting codes.\n */\npublic class RegistrationServiceException extends Exception {\n\n  private final RegistrationServiceSession registrationServiceSession;\n\n  public RegistrationServiceException(final RegistrationServiceSession registrationServiceSession) {\n    super(null, null, true, false);\n    this.registrationServiceSession = registrationServiceSession;\n  }\n\n  /**\n   * @return if empty, the session that encountered should be considered non-existent and may be discarded\n   */\n  public Optional<RegistrationServiceSession> getRegistrationSession() {\n    return Optional.ofNullable(registrationServiceSession);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/RegistrationServiceSenderException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\n\n/**\n * An error from an SMS/voice provider (“sender”) downstream of Registration Service is mapped to a {@link Reason}, and\n * may be permanent.\n */\npublic class RegistrationServiceSenderException extends Exception {\n\n  private final Reason reason;\n  private final boolean permanent;\n\n  public static RegistrationServiceSenderException illegalArgument(final boolean permanent) {\n    return new RegistrationServiceSenderException(Reason.ILLEGAL_ARGUMENT, permanent);\n  }\n\n  public static RegistrationServiceSenderException rejected(final boolean permanent) {\n    return new RegistrationServiceSenderException(Reason.PROVIDER_REJECTED, permanent);\n  }\n\n  public static RegistrationServiceSenderException unknown(final boolean permanent) {\n    return new RegistrationServiceSenderException(Reason.PROVIDER_UNAVAILABLE, permanent);\n  }\n\n  private RegistrationServiceSenderException(final Reason reason, final boolean permanent) {\n    super(null, null, true, false);\n    this.reason = reason;\n    this.permanent = permanent;\n  }\n\n  public Reason getReason() {\n    return reason;\n  }\n\n  public boolean isPermanent() {\n    return permanent;\n  }\n\n  public enum Reason {\n\n    @JsonProperty(\"providerUnavailable\")\n    PROVIDER_UNAVAILABLE,\n    @JsonProperty(\"providerRejected\")\n    PROVIDER_REJECTED,\n    @JsonProperty(\"illegalArgument\")\n    ILLEGAL_ARGUMENT\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/TransportNotAllowedException.java",
    "content": "package org.whispersystems.textsecuregcm.registration;\n\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\n\n/**\n * Indicates that a request to send a verification code failed because the destination number does not support the\n * requested transport (e.g. the caller asked to send an SMS to a landline number).\n */\npublic class TransportNotAllowedException extends RegistrationServiceException {\n\n  public TransportNotAllowedException(RegistrationServiceSession registrationServiceSession) {\n    super(registrationServiceSession);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/registration/VerificationSession.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport java.time.Instant;\nimport java.util.List;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.storage.SerializedExpireableJsonDynamoStore;\nimport org.whispersystems.textsecuregcm.telephony.CarrierData;\n\n/**\n * Server-internal stored session object. Primarily used by\n * {@link org.whispersystems.textsecuregcm.controllers.VerificationController} to manage the steps required to begin\n * requesting codes from Registration Service, in order to get a verified session to be provided to\n * {@link org.whispersystems.textsecuregcm.controllers.RegistrationController}.\n *\n * @param sessionId               the session ID returned by Registration Service\n * @param pushChallenge           the value of a push challenge sent to a client, after it submitted a push token\n * @param carrierData             information about the phone number's carrier if available\n * @param requestedInformation    information requested that a client send to the server\n * @param submittedInformation    information that a client has submitted and that the server has verified\n * @param smsSenderOverride       if present, indicates a sender override argument that should be forwarded to the\n *                                Registration Service when requesting a code\n * @param voiceSenderOverride     if present, indicates a sender override argument that should be forwarded to the\n *                                Registration Service when requesting a code\n * @param allowedToRequestCode    whether the client is allowed to request a code. This request will be forwarded to\n *                                Registration Service\n * @param createdTimestamp        when this session was created\n * @param updatedTimestamp        when this session was updated\n * @param remoteExpirationSeconds when the remote\n *                                {@link org.whispersystems.textsecuregcm.entities.RegistrationServiceSession} expires\n * @see org.whispersystems.textsecuregcm.entities.RegistrationServiceSession\n * @see org.whispersystems.textsecuregcm.entities.VerificationSessionResponse\n */\npublic record VerificationSession(\n    String sessionId,\n    @Nullable String pushChallenge,\n    @Nullable CarrierData carrierData,\n    List<Information> requestedInformation,\n    List<Information> submittedInformation,\n    @Nullable String smsSenderOverride,\n    @Nullable String voiceSenderOverride,\n    boolean allowedToRequestCode,\n    long createdTimestamp,\n    long updatedTimestamp,\n    long remoteExpirationSeconds) implements SerializedExpireableJsonDynamoStore.Expireable {\n\n  @Override\n  public long getExpirationEpochSeconds() {\n    return Instant.ofEpochMilli(updatedTimestamp).plusSeconds(remoteExpirationSeconds).getEpochSecond();\n  }\n\n  public enum Information {\n    @JsonProperty(\"pushChallenge\")\n    PUSH_CHALLENGE,\n    @JsonProperty(\"captcha\")\n    CAPTCHA\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/s3/ManagedSupplier.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport io.dropwizard.lifecycle.Managed;\nimport java.util.function.Supplier;\n\npublic interface ManagedSupplier<T> extends Supplier<T>, Managed {\n\n  @Override\n  default void start() throws Exception {\n    // noop\n  }\n\n  @Override\n  default void stop() throws Exception {\n    // noop\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/s3/PolicySigner.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.Key;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.HexFormat;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\n\npublic class PolicySigner {\n\n  private final String awsAccessSecret;\n  private final String region;\n\n  private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern(\"yyyyMMdd\");\n\n  public PolicySigner(final String awsAccessSecret, final String region) {\n    this.awsAccessSecret = awsAccessSecret;\n    this.region = region;\n  }\n\n  // See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html\n  public String getSignature(final ZonedDateTime now, final String policy) {\n    final Mac mac;\n\n    try {\n      mac = Mac.getInstance(\"HmacSHA256\");\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(\"Every implementation of the Java platform is required to support HmacSHA256\", e);\n    }\n\n    try {\n      mac.init(toHmacKey((\"AWS4\" + awsAccessSecret).getBytes(StandardCharsets.UTF_8)));\n      final byte[] dateKey = mac.doFinal(now.format(DATE_FORMAT).getBytes(StandardCharsets.UTF_8));\n\n      mac.init(toHmacKey(dateKey));\n      final byte[] dateRegionKey = mac.doFinal(region.getBytes(StandardCharsets.UTF_8));\n\n      mac.init(toHmacKey(dateRegionKey));\n      final byte[] dateRegionServiceKey = mac.doFinal(\"s3\".getBytes(StandardCharsets.UTF_8));\n\n      mac.init(toHmacKey(dateRegionServiceKey));\n      final byte[] signingKey = mac.doFinal(\"aws4_request\".getBytes(StandardCharsets.UTF_8));\n\n      mac.init(toHmacKey(signingKey));\n\n      return HexFormat.of().formatHex(mac.doFinal(policy.getBytes(StandardCharsets.UTF_8)));\n    } catch (final InvalidKeyException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  private static Key toHmacKey(final byte[] bytes) {\n    return new SecretKeySpec(bytes, \"HmacSHA256\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/s3/PostPolicyGenerator.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.ZonedDateTime;\nimport java.time.format.DateTimeFormatter;\nimport java.util.Base64;\nimport org.whispersystems.textsecuregcm.util.Pair;\n\npublic class PostPolicyGenerator {\n\n  public static final DateTimeFormatter AWS_DATE_TIME = DateTimeFormatter.ofPattern(\"yyyyMMdd'T'HHmmssX\");\n  private static final DateTimeFormatter CREDENTIAL_DATE = DateTimeFormatter.ofPattern(\"yyyyMMdd\");\n\n  private final String region;\n  private final String bucket;\n  private final String awsAccessId;\n\n  public PostPolicyGenerator(final String region, final String bucket, final String awsAccessId) {\n    this.region = region;\n    this.bucket = bucket;\n    this.awsAccessId = awsAccessId;\n  }\n\n  public Pair<String, String> createFor(final ZonedDateTime now, final String object, final int maxSizeInBytes) {\n    final String expiration = now.plusMinutes(30).format(DateTimeFormatter.ISO_INSTANT);\n    final String credentialDate = now.format(CREDENTIAL_DATE);\n    final String requestDate = now.format(AWS_DATE_TIME);\n    final String credential = String.format(\"%s/%s/%s/s3/aws4_request\", awsAccessId, credentialDate, region);\n\n    final String policy = String.format(\"\"\"\n        {\n          \"expiration\": \"%s\",\n          \"conditions\": [\n            {\"bucket\": \"%s\"},\n            {\"key\": \"%s\"},\n            {\"acl\": \"private\"},\n            [\"starts-with\", \"$Content-Type\", \"\"],\n            [\"content-length-range\", 1, %d],\n\n            {\"x-amz-credential\": \"%s\"},\n            {\"x-amz-algorithm\": \"AWS4-HMAC-SHA256\"},\n            {\"x-amz-date\": \"%s\" }\n          ]\n        }\n        \"\"\", expiration, bucket, object, maxSizeInBytes, credential, requestDate);\n\n    return new Pair<>(credential, Base64.getEncoder().encodeToString(policy.getBytes(StandardCharsets.UTF_8)));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/s3/S3MonitoringSupplier.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport static java.util.Objects.requireNonNull;\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.InputStream;\nimport java.lang.invoke.MethodHandles;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Function;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory;\nimport org.whispersystems.textsecuregcm.s3.ManagedSupplier;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\n\npublic class S3MonitoringSupplier<T> implements ManagedSupplier<T> {\n\n  private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());\n\n  private final Timer refreshTimer;\n\n  private final Counter refreshErrors;\n\n  private final AtomicReference<T> holder;\n\n  private final S3ObjectMonitor monitor;\n\n  private final Function<InputStream, T> parser;\n\n\n  public S3MonitoringSupplier(\n      final ScheduledExecutorService executor,\n      final AwsCredentialsProvider awsCredentialsProvider,\n      final S3ObjectMonitorFactory cfg,\n      final Function<InputStream, T> parser,\n      final T initial,\n      final String name) {\n    this.refreshTimer = Metrics.timer(name(S3MonitoringSupplier.class, name, \"refresh\"));\n    this.refreshErrors = Metrics.counter(name(S3MonitoringSupplier.class, name, \"refreshErrors\"));\n    this.holder = new AtomicReference<>(initial);\n    this.parser = requireNonNull(parser);\n    this.monitor = cfg.build(awsCredentialsProvider, executor);\n  }\n\n  @Override\n  public T get() {\n    return requireNonNull(holder.get());\n  }\n\n  @Override\n  public void start() throws Exception {\n    monitor.start(this::handleObjectChange);\n  }\n\n  @Override\n  public void stop() throws Exception {\n    monitor.stop();\n  }\n\n  private void handleObjectChange(final InputStream inputStream) {\n    refreshTimer.record(() -> {\n      // parser function is supposed to close the input stream\n      try {\n        holder.set(parser.apply(inputStream));\n      } catch (final Exception e) {\n        log.error(\"failed to update internal state from the monitored object\", e);\n        refreshErrors.increment();\n      }\n    });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/s3/S3ObjectMonitor.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Consumer;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.core.ResponseInputStream;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.S3ClientBuilder;\nimport software.amazon.awssdk.services.s3.S3Configuration;\nimport software.amazon.awssdk.services.s3.model.GetObjectRequest;\nimport software.amazon.awssdk.services.s3.model.GetObjectResponse;\nimport software.amazon.awssdk.services.s3.model.HeadObjectRequest;\nimport software.amazon.awssdk.services.s3.model.HeadObjectResponse;\nimport javax.annotation.Nullable;\n\n/**\n * An S3 object monitor watches a specific object in an S3 bucket and notifies a listener if that object changes.\n */\npublic class S3ObjectMonitor {\n\n  private final String s3Bucket;\n  private final String objectKey;\n  private final long maxObjectSize;\n\n  private final ScheduledExecutorService refreshExecutorService;\n  private final Duration refreshInterval;\n  private ScheduledFuture<?> refreshFuture;\n\n  private final AtomicReference<String> lastETag = new AtomicReference<>();\n\n  private final S3Client s3Client;\n\n  private static final Logger log = LoggerFactory.getLogger(S3ObjectMonitor.class);\n\n  public S3ObjectMonitor(\n      final AwsCredentialsProvider awsCredentialsProvider,\n      final String s3Region,\n      final String s3Bucket,\n      final String objectKey,\n      final long maxObjectSize,\n      final ScheduledExecutorService refreshExecutorService,\n      final Duration refreshInterval) {\n\n    this(S3Client.builder()\n            .region(Region.of(s3Region))\n            .credentialsProvider(awsCredentialsProvider)\n            .build(),\n        s3Bucket,\n        objectKey,\n        maxObjectSize,\n        refreshExecutorService,\n        refreshInterval);\n  }\n\n  // construction with s3Endpoint\n  public S3ObjectMonitor(final AwsCredentialsProvider awsCredentialsProvider,\n      final URI s3Endpoint,\n      final String s3Region,\n      final String s3Bucket,\n      final String objectKey,\n      final long maxObjectSize,\n      final ScheduledExecutorService refreshExecutorService,\n      final Duration refreshInterval) {\n    this(S3Client.builder()\n            .region(Region.of(s3Region))\n            .credentialsProvider(awsCredentialsProvider)\n            .endpointOverride(s3Endpoint)\n            .serviceConfiguration(S3Configuration.builder()\n                .pathStyleAccessEnabled(true).build())\n            .build(),\n        s3Bucket,\n        objectKey,\n        maxObjectSize,\n        refreshExecutorService,\n        refreshInterval);\n  }\n\n  @VisibleForTesting\n  S3ObjectMonitor(\n      final S3Client s3Client,\n      final String s3Bucket,\n      final String objectKey,\n      final long maxObjectSize,\n      final ScheduledExecutorService refreshExecutorService,\n      final Duration refreshInterval) {\n\n    this.s3Client = s3Client;\n    this.s3Bucket = s3Bucket;\n    this.objectKey = objectKey;\n    this.maxObjectSize = maxObjectSize;\n\n    this.refreshExecutorService = refreshExecutorService;\n    this.refreshInterval = refreshInterval;\n  }\n\n  public synchronized void start(final Consumer<InputStream> changeListener) {\n    if (refreshFuture != null) {\n      throw new RuntimeException(\"S3 object manager already started\");\n    }\n\n    // Run the first request immediately/blocking, then start subsequent calls.\n    log.info(\"Initial request for s3://{}/{}\", s3Bucket, objectKey);\n    refresh(changeListener);\n\n    refreshFuture = refreshExecutorService\n        .scheduleAtFixedRate(() -> refresh(changeListener), refreshInterval.toMillis(), refreshInterval.toMillis(),\n            TimeUnit.MILLISECONDS);\n  }\n\n  public synchronized void stop() {\n    if (refreshFuture != null) {\n      refreshFuture.cancel(true);\n    }\n  }\n\n  /**\n   * Immediately returns the monitored S3 object regardless of whether it has changed since it was last retrieved.\n   *\n   * @return the current version of the monitored S3 object.  Caller should close() this upon completion.\n   * @throws IOException if the retrieved S3 object is larger than the configured maximum size\n   */\n  @VisibleForTesting\n  ResponseInputStream<GetObjectResponse> getObject() throws IOException {\n    final ResponseInputStream<GetObjectResponse> response = s3Client.getObject(GetObjectRequest.builder()\n        .key(objectKey)\n        .bucket(s3Bucket)\n        .build());\n\n    lastETag.set(response.response().eTag());\n\n    if (response.response().contentLength() <= maxObjectSize) {\n      return response;\n    } else {\n      log.warn(\"Object at s3://{}/{} has a size of {} bytes, which exceeds the maximum allowed size of {} bytes\",\n          s3Bucket, objectKey, response.response().contentLength(), maxObjectSize);\n      response.abort();\n      throw new IOException(\"S3 object too large\");\n    }\n  }\n\n  /**\n   * Polls S3 for object metadata and notifies the listener provided at construction time if and only if the object has\n   * changed since the last call to {@link #getObject()} or {@code refresh()}.\n   */\n  @VisibleForTesting\n  void refresh(final Consumer<InputStream> changeListener) {\n    try {\n      final HeadObjectResponse objectMetadata = s3Client.headObject(HeadObjectRequest.builder()\n          .bucket(s3Bucket)\n          .key(objectKey)\n          .build());\n\n      final String initialETag = lastETag.get();\n      final String refreshedETag = objectMetadata.eTag();\n\n      if (!StringUtils.equals(initialETag, refreshedETag) && lastETag.compareAndSet(initialETag, refreshedETag)) {\n        try (final ResponseInputStream<GetObjectResponse> response = getObject()) {\n          log.info(\"Object at s3://{}/{} has changed; new eTag is {} and object size is {} bytes\",\n              s3Bucket, objectKey, response.response().eTag(), response.response().contentLength());\n          changeListener.accept(response);\n        }\n      }\n    } catch (final Exception e) {\n      log.warn(\"Failed to refresh monitored object\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/scheduler/JobScheduler.java",
    "content": "package org.whispersystems.textsecuregcm.scheduler;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.retry.Retry;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport javax.annotation.Nullable;\nimport java.nio.ByteBuffer;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ThreadLocalRandom;\n\n/**\n * A job scheduler maintains a delay queue of tasks to be run at some time in the future. Callers schedule jobs with\n * the {@link #scheduleJob(Instant, byte[])} method, and concrete subclasses actually execute jobs by implementing the\n * {@link #processJob(byte[])} method. Some entity must call {@link #processAvailableJobs()} to actually find and\n * process jobs that are ready for execution.\n */\npublic abstract class JobScheduler {\n\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n  private final Duration jobExpiration;\n  private final Clock clock;\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  // The name of this scheduler (DynamoDB string)\n  @VisibleForTesting\n  public static final String KEY_SCHEDULER_NAME = \"S\";\n\n  // The timestamp (and additional random data; please see #buildRunAtAttribute for details) for the job\n  // (DynamoDB byte array)\n  @VisibleForTesting\n  public static final String ATTR_RUN_AT = \"T\";\n\n  // Additional application-specific data for the job (DynamoDB byte array)\n  private static final String ATTR_JOB_DATA = \"D\";\n\n  // The time at which this job should be garbage-collected if not already deleted (DynamoDB number;\n  // seconds from the epoch)\n  private static final String ATTR_TTL = \"E\";\n\n  private static final String SCHEDULE_JOB_COUNTER_NAME = MetricsUtil.name(JobScheduler.class, \"scheduleJob\");\n  private static final String PROCESS_JOB_COUNTER_NAME = MetricsUtil.name(JobScheduler.class, \"processJob\");\n\n  private static final String SCHEDULER_NAME_TAG = \"schedulerName\";\n  private static final String OUTCOME_TAG = \"outcome\";\n\n  private static final int MAX_CONCURRENCY = 16;\n\n  protected JobScheduler(final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final String tableName,\n      final Duration jobExpiration,\n      final Clock clock) {\n\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n    this.jobExpiration = jobExpiration;\n    this.clock = clock;\n  }\n\n  /**\n   * Returns the unique name of this scheduler. Scheduler names are used to \"namespace\" jobs.\n   *\n   * @return the unique name of this scheduler\n   */\n  public abstract String getSchedulerName();\n\n  /**\n   * Processes a previously-scheduled job.\n   *\n   * @param jobData opaque, application-specific data provided at the time the job was scheduled\n   *\n   * @return A future that yields a brief, human-readable status code when the job has been fully processed. On\n   * successful completion, the job will be deleted. The job will not be deleted if the future completes exceptionally.\n   */\n  protected abstract CompletableFuture<String> processJob(@Nullable byte[] jobData);\n\n  /**\n   * Schedules a job to run at or after the given {@code runAt} time. Concrete implementations must override this method\n   * to expose it publicly, or provide some more application-appropriate method for public callers.\n   *\n   * @param runAt the time at or after which to run the job\n   * @param jobData application-specific data describing the job; may be {@code null}\n   *\n   * @return a future that completes when the job has been scheduled\n   */\n  protected CompletableFuture<Void> scheduleJob(final Instant runAt, @Nullable final byte[] jobData) {\n    return Mono.fromFuture(() -> scheduleJob(buildRunAtAttribute(runAt), runAt.plus(jobExpiration), jobData))\n        .retryWhen(Retry.backoff(8, Duration.ofSeconds(1)).maxBackoff(Duration.ofSeconds(4)))\n        .toFuture()\n        .thenRun(() -> Metrics.counter(SCHEDULE_JOB_COUNTER_NAME, SCHEDULER_NAME_TAG, getSchedulerName()).increment());\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Void> scheduleJob(final AttributeValue runAt, final Instant expiration, @Nullable final byte[] jobData) {\n    final Map<String, AttributeValue> item = new HashMap<>(Map.of(\n        KEY_SCHEDULER_NAME, AttributeValue.fromS(getSchedulerName()),\n        ATTR_RUN_AT, runAt,\n        ATTR_TTL, AttributeValue.fromN(String.valueOf(expiration.getEpochSecond()))));\n\n    if (jobData != null) {\n      item.put(ATTR_JOB_DATA, AttributeValue.fromB(SdkBytes.fromByteArray(jobData)));\n    }\n\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .item(item)\n            .conditionExpression(\"attribute_not_exists(#schedulerName)\")\n            .expressionAttributeNames(Map.of(\"#schedulerName\", KEY_SCHEDULER_NAME))\n            .build())\n        .thenRun(Util.NOOP);\n  }\n\n  /**\n   * Finds and processes all jobs whose {@code runAt} time is less than or equal to the current time. Scheduled jobs\n   * will be deleted once they have been processed successfully.\n   *\n   * @return a future that completes when all available jobs have been processed\n   *\n   * @see #processJob(byte[])\n   */\n  public Mono<Void> processAvailableJobs() {\n    return Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n            .tableName(tableName)\n            .keyConditionExpression(\"#schedulerName = :schedulerName AND #runAt <= :maxRunAt\")\n            .expressionAttributeNames(Map.of(\n                \"#schedulerName\", KEY_SCHEDULER_NAME,\n                \"#runAt\", ATTR_RUN_AT))\n            .expressionAttributeValues(Map.of(\n                \":schedulerName\", AttributeValue.fromS(getSchedulerName()),\n                \":maxRunAt\", buildMaxRunAtAttribute(clock.instant())))\n            .build())\n        .items())\n        .flatMap(item -> {\n          final byte[] jobData = item.containsKey(ATTR_JOB_DATA)\n              ? item.get(ATTR_JOB_DATA).b().asByteArray()\n              : null;\n\n          return Mono.fromFuture(() -> processJob(jobData))\n              .doOnNext(outcome -> Metrics.counter(PROCESS_JOB_COUNTER_NAME,\n                      SCHEDULER_NAME_TAG, getSchedulerName(),\n                      OUTCOME_TAG, outcome)\n                  .increment())\n              .then(Mono.fromFuture(() -> deleteJob(item.get(KEY_SCHEDULER_NAME), item.get(ATTR_RUN_AT))))\n              .onErrorResume(throwable -> {\n                logger.warn(\"Failed to process job\", throwable);\n                return Mono.empty();\n              });\n        }, MAX_CONCURRENCY)\n        .then();\n  }\n\n  private CompletableFuture<Void> deleteJob(final AttributeValue schedulerName, final AttributeValue runAt) {\n    return dynamoDbAsyncClient.deleteItem(DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_SCHEDULER_NAME, schedulerName,\n                ATTR_RUN_AT, runAt))\n            .build())\n        .thenRun(Util.NOOP);\n  }\n\n  /**\n   * Constructs an attribute value that contains a sort key that will be greater than any sort key generated for an\n   * earlier {@code runAt} time and less than a sort key generated for a later {@code runAt} time. The returned value\n   * begins with the 8-byte, big-endian representation of the given {@code runAt} time in milliseconds since the epoch\n   * and ends with a random 8-byte suffix. The random suffix ensures that multiple jobs scheduled for the same\n   * {@code runAt} time will have distinct primary keys; the order in which jobs scheduled at the same time will be\n   * executed is also random as a result.\n   *\n   * @param runAt the time for which to generate a sort key\n   *\n   * @return a probably-unique sort key for the given {@code runAt} time\n   */\n  AttributeValue buildRunAtAttribute(final Instant runAt) {\n    return buildRunAtAttribute(runAt, ThreadLocalRandom.current().nextLong());\n  }\n\n  @VisibleForTesting\n  AttributeValue buildRunAtAttribute(final Instant runAt, final long salt) {\n    return AttributeValue.fromB(SdkBytes.fromByteBuffer(ByteBuffer.allocate(24)\n        .putLong(runAt.toEpochMilli())\n        .putLong(clock.millis())\n        .putLong(salt)\n        .flip()));\n  }\n\n  /**\n   * Constructs a sort key value that is greater than or equal to all other sort keys for jobs with the same or earlier\n   * {@code runAt} time.\n   *\n   * @param runAt the maximum scheduled time for jobs to match\n   *\n   * @return an attribute value for a sort key that is greater than or equal to the sort key for all other jobs\n   * scheduled to run at or before the given {@code runAt} time\n   */\n  static AttributeValue buildMaxRunAtAttribute(final Instant runAt) {\n    return AttributeValue.fromB(SdkBytes.fromByteBuffer(ByteBuffer.allocate(24)\n        .putLong(runAt.toEpochMilli())\n        .putLong(0xfffffffffffffffL)\n        .putLong(0xfffffffffffffffL)\n        .flip()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtil.java",
    "content": "package org.whispersystems.textsecuregcm.scheduler;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberToTimeZonesMapper;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.LocalTime;\nimport java.time.ZoneId;\nimport java.time.ZonedDateTime;\nimport java.time.zone.ZoneRules;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport io.micrometer.core.instrument.Metrics;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\npublic class SchedulingUtil {\n  private static final String PARSED_TIMEZONE_COUNTER_NAME = MetricsUtil.name(SchedulingUtil.class, \"parsedTimezone\");\n  private static final String HAS_TIMEZONE_TAG_NAME = \"hasTimezone\";\n\n  /**\n   * Gets a present or future time at which to send a notification to a device associated with the given account. This\n   * is mainly intended to facilitate scheduling notifications such that they arrive during a recipient's waking hours.\n   * <p/>\n   * This method will attempt to use a timezone derived from the account's phone number to choose an appropriate time\n   * to send a notification. If a timezone cannot be derived from the account's phone number, then this method will\n   * default to the preferred time in the server's timezone.\n   *\n   * @param account the account that will receive the notification\n   * @param preferredTime the preferred local time (e.g. \"noon\") at which to deliver the notification\n   * @param clock a source of the current time\n   *\n   * @return the next time in the present or future at which to send a notification for the target account\n   */\n  public static Instant getNextRecommendedNotificationTime(final Account account,\n      final LocalTime preferredTime,\n      final Clock clock) {\n\n    final ZonedDateTime candidateNotificationTime = getZoneId(account.getNumber(), clock)\n        .map(zoneId -> {\n          Metrics.counter(PARSED_TIMEZONE_COUNTER_NAME, HAS_TIMEZONE_TAG_NAME, String.valueOf(true)).increment();\n          return ZonedDateTime.now(clock.withZone(zoneId)).with(preferredTime);\n        })\n        .orElseGet(() -> {\n          Metrics.counter(PARSED_TIMEZONE_COUNTER_NAME, HAS_TIMEZONE_TAG_NAME, String.valueOf(false)).increment();\n          return ZonedDateTime.now(ZoneId.systemDefault()).with(preferredTime);\n        });\n\n    if (candidateNotificationTime.toInstant().isBefore(clock.instant())) {\n      // We've missed our opportunity today, so go for the same time tomorrow\n      return candidateNotificationTime.plusDays(1).toInstant();\n    } else {\n      // The best time to send a notification hasn't happened yet today\n      return candidateNotificationTime.toInstant();\n    }\n  }\n\n  @VisibleForTesting\n  public static Optional<ZoneId> getZoneId(final String e164, final Clock clock) {\n    try {\n      final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().parse(e164, null);\n\n      List<String> timeZonesForNumber =\n          PhoneNumberToTimeZonesMapper.getInstance().getTimeZonesForNumber(phoneNumber);\n\n      if (isUnknownTimeZone(timeZonesForNumber)) {\n        // The more general getTimeZonesForNumber has a guard on PhoneNumberType.UNKONWN, returning the “unknown”\n        // time zone for these numbers. In these cases, we'll first fall back to the geographical lookup, which looks up\n        // a result based on the country code and national significant number.\n        timeZonesForNumber  =\n            PhoneNumberToTimeZonesMapper.getInstance().getTimeZonesForGeographicalNumber(phoneNumber);\n\n        if (isUnknownTimeZone(timeZonesForNumber)) {\n          return Optional.empty();\n        }\n      }\n\n      final Instant now = clock.instant();\n\n      // Consider each unique ZoneRules and pick an arbitrary representative ZoneId for it\n      final Map<ZoneRules, ZoneId> byOffset = timeZonesForNumber\n          .stream()\n          .map(id -> {\n            try {\n              return ZoneId.of(id);\n            } catch (final Exception e) {\n              return null;\n            }\n          })\n          .filter(Objects::nonNull)\n          .collect(Collectors.toMap(\n              ZoneId::getRules,\n              Function.identity(),\n              (_, v2) -> v2));\n\n      // Sort the ZoneRules by the offsets they produce\n      final List<ZoneRules> zoneRulesSortedByOffset = byOffset.keySet()\n          .stream()\n          .sorted(Comparator.comparing(z -> z.getOffset(now)))\n          .toList();\n\n      // Select the \"middle\" ZoneRule and return one of the ZoneIds that have that ZoneRule\n      return Optional.of(byOffset.get(zoneRulesSortedByOffset.get(zoneRulesSortedByOffset.size() / 2)));\n    } catch (final NumberParseException e) {\n      return Optional.empty();\n    }\n  }\n\n  static boolean isUnknownTimeZone(final List<String> timeZonesForNumber) {\n    return timeZonesForNumber.equals(List.of(PhoneNumberToTimeZonesMapper.getUnknownTimeZone()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClient.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.securestorage;\n\nimport static org.whispersystems.textsecuregcm.util.HeaderUtils.basicAuthHeader;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.security.cert.CertificateException;\nimport java.time.Duration;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.util.HttpUtils;\n\n/**\n * A client for sending requests to Signal's secure storage service on behalf of authenticated users.\n */\npublic class SecureStorageClient {\n\n  private final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator;\n  private final URI deleteUri;\n  private final FaultTolerantHttpClient httpClient;\n\n  @VisibleForTesting\n  static final String DELETE_PATH = \"/v1/storage\";\n\n  public SecureStorageClient(final ExternalServiceCredentialsGenerator storageServiceCredentialsGenerator,\n      final Executor executor, final\n  ScheduledExecutorService retryExecutor, final SecureStorageServiceConfiguration configuration)\n      throws CertificateException {\n    this.storageServiceCredentialsGenerator = storageServiceCredentialsGenerator;\n    this.deleteUri = URI.create(configuration.uri()).resolve(DELETE_PATH);\n    this.httpClient = FaultTolerantHttpClient.newBuilder(\"secure-storage\", executor)\n        .withCircuitBreaker(configuration.circuitBreakerConfigurationName())\n        .withRetry(configuration.retryConfigurationName(), retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_1_1)\n        .withConnectTimeout(Duration.ofSeconds(10))\n        .withRedirect(HttpClient.Redirect.NEVER)\n        .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_3)\n        .withTrustedServerCertificates(configuration.storageCaCertificates().toArray(new String[0]))\n        .build();\n  }\n\n  public CompletableFuture<Void> deleteStoredData(final UUID accountUuid) {\n    final ExternalServiceCredentials credentials = storageServiceCredentialsGenerator.generateForUuid(accountUuid);\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(deleteUri)\n        .DELETE()\n        .header(HttpHeaders.AUTHORIZATION, basicAuthHeader(credentials))\n        .build();\n\n        return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> {\n            if (HttpUtils.isSuccessfulResponse(response.statusCode())) {\n                return null;\n            }\n\n            throw new SecureStorageException(\"Failed to delete storage service data: \" + response.statusCode());\n        });\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageException.java",
    "content": "/*\n * Copyright 2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.securestorage;\n\npublic class SecureStorageException extends RuntimeException {\n\n    public SecureStorageException(final String message) {\n        super(message);\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/securevaluerecovery/SecureValueRecoveryClient.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.securevaluerecovery;\n\nimport static org.whispersystems.textsecuregcm.util.HeaderUtils.basicAuthHeader;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.security.cert.CertificateException;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Supplier;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.util.HttpUtils;\n\n/**\n * A client for sending requests to Signal's secure value recovery service on behalf of authenticated users.\n */\npublic class SecureValueRecoveryClient {\n\n  private static final Logger logger = LoggerFactory.getLogger(SecureValueRecoveryClient.class);\n\n  private final ExternalServiceCredentialsGenerator secureValueRecoveryCredentialsGenerator;\n  private final URI deleteUri;\n  private final Supplier<List<Integer>> allowedDeletionErrorStatusCodes;\n  private final FaultTolerantHttpClient httpClient;\n\n  @VisibleForTesting\n  static final String DELETE_PATH = \"/v1/delete\";\n\n  public SecureValueRecoveryClient(\n      final ExternalServiceCredentialsGenerator secureValueRecoveryCredentialsGenerator,\n      final Executor executor, final ScheduledExecutorService retryExecutor,\n      final SecureValueRecoveryConfiguration configuration,\n      Supplier<List<Integer>> allowedDeletionErrorStatusCodes)\n      throws CertificateException {\n    this.secureValueRecoveryCredentialsGenerator = secureValueRecoveryCredentialsGenerator;\n    this.deleteUri = URI.create(configuration.uri()).resolve(DELETE_PATH);\n    this.allowedDeletionErrorStatusCodes = allowedDeletionErrorStatusCodes;\n    this.httpClient = FaultTolerantHttpClient.newBuilder(\"secure-value-recovery\", executor)\n        .withCircuitBreaker(configuration.circuitBreakerConfigurationName())\n        .withRetry(configuration.retryConfigurationName(), retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_1_1)\n        .withConnectTimeout(Duration.ofSeconds(10))\n        .withRedirect(HttpClient.Redirect.NEVER)\n        .withSecurityProtocol(FaultTolerantHttpClient.SECURITY_PROTOCOL_TLS_1_2)\n        .withTrustedServerCertificates(configuration.svrCaCertificates().toArray(new String[0]))\n        .build();\n  }\n\n  public CompletableFuture<Void> removeData(final UUID accountUuid) {\n    return removeData(accountUuid.toString());\n  }\n\n  public CompletableFuture<Void> removeData(final String userIdentifier) {\n\n    final ExternalServiceCredentials credentials = secureValueRecoveryCredentialsGenerator.generateFor(userIdentifier);\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(deleteUri)\n        .DELETE()\n        .header(HttpHeaders.AUTHORIZATION, basicAuthHeader(credentials))\n        .build();\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()).thenApply(response -> {\n      if (HttpUtils.isSuccessfulResponse(response.statusCode())) {\n        return null;\n      }\n\n      final List<Integer> allowedErrors = allowedDeletionErrorStatusCodes.get();\n      if (allowedErrors.contains(response.statusCode())) {\n        logger.warn(\"Ignoring failure to delete svr entry for identifier {} with status {}\",\n            userIdentifier, response.statusCode());\n        return null;\n      }\n      logger.warn(\"Failed to delete svr entry for identifier {} with status {}\", userIdentifier, response.statusCode());\n      throw new SecureValueRecoveryException(\"Failed to delete backup\", String.valueOf(response.statusCode()));\n    });\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/securevaluerecovery/SecureValueRecoveryException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.securevaluerecovery;\n\npublic class SecureValueRecoveryException extends RuntimeException {\n  private final String statusCode;\n\n  public SecureValueRecoveryException(final String message, final String statusCode) {\n    super(message);\n    this.statusCode = statusCode;\n  }\n\n  public String getStatusCode() {\n    return statusCode;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/ChallengeConstraintChecker.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.spam;\n\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\npublic interface ChallengeConstraintChecker {\n\n  record ChallengeConstraints(boolean pushPermitted, Optional<Float> captchaScoreThreshold) {}\n\n  /**\n   * Retrieve constraints for captcha and push challenges\n   *\n   * @param authenticatedAccount The authenticated account attempting to request or solve a challenge\n   * @return ChallengeConstraints indicating what constraints should be applied to challenges\n   */\n  ChallengeConstraints challengeConstraints(ContainerRequestContext requestContext, Account authenticatedAccount);\n\n  static ChallengeConstraintChecker noop() {\n    return (account, ctx) -> new ChallengeConstraints(true, Optional.empty());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/ChallengeType.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\npublic enum ChallengeType {\n    PUSH,\n    CAPTCHA\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/GrpcChallengeResponse.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\nimport io.grpc.StatusRuntimeException;\nimport java.util.function.Function;\nimport javax.annotation.Nullable;\nimport org.signal.chat.messages.ChallengeRequired;\n\n/// A gRPC status or a challenge message to communicate to callers that a message has been flagged as potential spam.\npublic class GrpcChallengeResponse {\n\n  @Nullable\n  private final StatusRuntimeException statusException;\n\n  @Nullable\n  private final ChallengeRequired response;\n\n  private GrpcChallengeResponse(final @Nullable StatusRuntimeException statusException,\n                                @Nullable final ChallengeRequired response) {\n    this.statusException = statusException;\n    this.response = response;\n    if (!((statusException == null) ^ (response == null))) {\n      throw new IllegalArgumentException(\"exactly one of statusException and response must be non-null\");\n    }\n  }\n\n  /// Constructs a new response object with the given status and no challenge\n  ///\n  /// @param status the status to send to callers\n  /// @return a new response object with the given status and no challenge\n  public static GrpcChallengeResponse withStatusException(final StatusRuntimeException status) {\n    return new GrpcChallengeResponse(status, null);\n  }\n\n  /// Constructs a new response object with the given challenge message.\n  ///\n  /// @param response the challenge message to send to the caller\n  /// @return a new response object with the given challenge message\n  public static GrpcChallengeResponse withResponse(final ChallengeRequired response) {\n    return new GrpcChallengeResponse(null, response);\n  }\n\n  /// Returns the challenge message contained within this response or throws the contained status as a\n  /// [StatusRuntimeException] if no challenge message is specified.\n  ///\n  /// @return the [ChallengeRequired] message\n  /// @throws StatusRuntimeException if no challenge message is specified\n  public ChallengeRequired getResponseOrThrowStatus() throws StatusRuntimeException {\n    if (statusException != null) {\n      throw statusException;\n    }\n    return response;\n  }\n\n  /// If this response contains a challenge message, throw a status generated using statusMapper. Otherwise, throw the\n  /// status.\n  ///\n  /// @param statusMapper A function that converts a [ChallengeRequired] message into a\n  ///                     [StatusRuntimeException]\n  /// @throws StatusRuntimeException the contained or mapped status exception\n  public void throwStatusOr(Function<ChallengeRequired, StatusRuntimeException> statusMapper)\n      throws StatusRuntimeException {\n    if (statusException != null) {\n      throw statusException;\n    }\n    throw statusMapper.apply(response);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/MessageDeliveryListener.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\nimport org.whispersystems.textsecuregcm.storage.Account;\n\npublic interface MessageDeliveryListener {\n\n  void handleMessageDelivered(Account destinationAccount,\n      byte destinationDeviceId,\n      boolean ephemeral,\n      boolean urgent,\n      boolean story,\n      boolean sealedSender,\n      boolean multiRecipient,\n      boolean sync);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/MessageType.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\npublic enum MessageType {\n  INDIVIDUAL_IDENTIFIED_SENDER,\n  SYNC,\n  INDIVIDUAL_SEALED_SENDER,\n  MULTI_RECIPIENT_SEALED_SENDER,\n  INDIVIDUAL_STORY,\n  MULTI_RECIPIENT_STORY,\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/RateLimitChallengeListener.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\n\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport java.io.IOException;\n\npublic interface RateLimitChallengeListener {\n\n  void handleRateLimitChallengeAnswered(Account account, ChallengeType type);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/RegistrationFraudChecker.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.spam;\n\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\n\npublic interface RegistrationFraudChecker {\n\n  record VerificationCheck(Optional<VerificationSession> updatedSession, Optional<Float> scoreThreshold) {\n    public static VerificationCheck DEFAULT = new VerificationCheck(Optional.empty(), Optional.empty());\n  }\n\n  /**\n   * Determine if a registration attempt is suspicious\n   *\n   * @param requestContext      The request context for an update verification session attempt\n   * @param verificationSession The target verification session\n   * @param e164                The target phone number\n   * @param request             The information to add to the verification session\n   *\n   * @return a VerificationCheck including updates to the verification session that should be sent to the caller\n   * along with other constraints to enforce when evaluating the UpdateVerificationSessionRequest\n   */\n  VerificationCheck checkVerificationAttempt(\n      final ContainerRequestContext requestContext,\n      final VerificationSession verificationSession,\n      final String e164,\n      final UpdateVerificationSessionRequest request);\n\n  /**\n   * Determine if an attempt to send a verification code is suspicious\n   *\n   * @param requestContext      The request context for a \"send code\" attempt\n   * @param verificationSession The target verification session\n   * @param e164                The target phone number\n   *\n   * @return a VerificationCheck including updates to the verification session that should be sent to the caller\n   * along with other constraints to enforce when evaluating the request to send a verification code\n   */\n  VerificationCheck checkSendVerificationCodeAttempt(\n      final ContainerRequestContext requestContext,\n      final VerificationSession verificationSession,\n      final String e164);\n\n  static RegistrationFraudChecker noop() {\n    return new RegistrationFraudChecker() {\n\n      @Override\n      public VerificationCheck checkVerificationAttempt(final ContainerRequestContext requestContext,\n          final VerificationSession verificationSession, final String e164,\n          final UpdateVerificationSessionRequest request) {\n\n        return VerificationCheck.DEFAULT;\n      }\n\n      @Override\n      public VerificationCheck checkSendVerificationCodeAttempt(final ContainerRequestContext requestContext,\n          final VerificationSession verificationSession, final String e164) {\n\n        return VerificationCheck.DEFAULT;\n      }\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/RegistrationRecoveryChecker.java",
    "content": "package org.whispersystems.textsecuregcm.spam;\n\nimport jakarta.ws.rs.container.ContainerRequestContext;\n\npublic interface RegistrationRecoveryChecker {\n\n  /**\n   * Determine if a registration recovery attempt should be allowed or not\n   *\n   * @param requestContext The container request context for a registration recovery attempt\n   * @param e164           The E164 formatted phone number of the requester\n   * @return true if the registration recovery attempt is allowed, false otherwise.\n   */\n  boolean checkRegistrationRecoveryAttempt(final ContainerRequestContext requestContext, final String e164);\n\n  static RegistrationRecoveryChecker noop() {\n    return (ignoredCtx, ignoredE164) ->  true;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamCheckResult.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\nimport java.util.Optional;\n\n/**\n * The result of a spam check. May contain a response to relay to the caller if a message was identified as potential\n * spam or a spam reporting token to include in the delivered message.\n *\n * @param response a transport-appropriate response to return to the sender if the message was identified as potential\n *                 spam, or empty if processing should continue as normal\n * @param token a spam-reporting token to include in the outbound message, or empty if no token applies to the message\n *\n * @param <T> the type of response for messages identified as potential spam\n */\npublic record SpamCheckResult<T>(Optional<T> response, Optional<byte[]> token) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamChecker.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.spam;\n\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Response;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\npublic interface SpamChecker {\n\n  /**\n   * Determine if a message sent to an individual recipient via HTTP may be spam.\n   *\n   * @param messageType      the type of message to check\n   * @param requestContext   the request context for a message send attempt\n   * @param maybeSource      the sender of the message, could be empty if this as message sent with sealed sender\n   * @param maybeDestination the destination of the message, could be empty if the destination does not exist or could\n   *                         not be retrieved\n   * @param destinationIdentifier the service identifier for the destination account\n   * @return A {@link SpamCheckResult}\n   */\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(\n      final MessageType messageType,\n      final ContainerRequestContext requestContext,\n      final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> maybeSource,\n      final Optional<Account> maybeDestination,\n      final ServiceIdentifier destinationIdentifier);\n\n  /**\n   * Determine if a message sent to multiple recipients via HTTP may be spam.\n   *\n   * @param messageType      the type of message to check\n   * @param requestContext   the request context for a message send attempt\n   * @return A {@link SpamCheckResult}\n   */\n  SpamCheckResult<Response> checkForMultiRecipientSpamHttp(\n      final MessageType messageType,\n      final ContainerRequestContext requestContext);\n\n  /**\n   * Determine if a message sent to an individual recipient via gRPC may be spam.\n   *\n   * @param messageType      the type of message to check\n   * @param maybeSource      the sender of the message, could be empty if this as message sent with sealed sender\n   * @param maybeDestination the destination of the message, could be empty if the destination does not exist or could\n   *                         not be retrieved\n   * @param destinationIdentifier the service identifier for the destination account\n   * @return A {@link SpamCheckResult}\n   */\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  SpamCheckResult<GrpcChallengeResponse> checkForIndividualRecipientSpamGrpc(\n      final MessageType messageType,\n      final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,\n      final Optional<Account> maybeDestination,\n      final ServiceIdentifier destinationIdentifier);\n\n  /**\n   * Determine if a message sent to multiple recipients via gRPC may be spam.\n   *\n   * @param messageType the type of message to check\n   *\n   * @return A {@link SpamCheckResult}\n   */\n  SpamCheckResult<GrpcChallengeResponse> checkForMultiRecipientSpamGrpc(final MessageType messageType);\n\n\n  static SpamChecker noop() {\n    return new SpamChecker() {\n\n      @Override\n      public SpamCheckResult<Response> checkForIndividualRecipientSpamHttp(final MessageType messageType,\n          final ContainerRequestContext requestContext,\n          final Optional<org.whispersystems.textsecuregcm.auth.AuthenticatedDevice> maybeSource,\n          final Optional<Account> maybeDestination,\n          final ServiceIdentifier destinationIdentifier) {\n\n        return new SpamCheckResult<>(Optional.empty(), Optional.empty());\n      }\n\n      @Override\n      public SpamCheckResult<Response> checkForMultiRecipientSpamHttp(final MessageType messageType,\n          final ContainerRequestContext requestContext) {\n\n        return new SpamCheckResult<>(Optional.empty(), Optional.empty());\n      }\n\n      @Override\n      public SpamCheckResult<GrpcChallengeResponse> checkForIndividualRecipientSpamGrpc(final MessageType messageType,\n          final Optional<org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice> maybeSource,\n          final Optional<Account> maybeDestination,\n          final ServiceIdentifier destinationIdentifier) {\n\n        return new SpamCheckResult<>(Optional.empty(), Optional.empty());\n      }\n\n      @Override\n      public SpamCheckResult<GrpcChallengeResponse> checkForMultiRecipientSpamGrpc(\n          final MessageType messageType) {\n\n        return new SpamCheckResult<>(Optional.empty(), Optional.empty());\n      }\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/spam/SpamFilter.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.spam;\n\nimport io.dropwizard.configuration.ConfigurationValidationException;\nimport io.dropwizard.core.cli.ConfiguredCommand;\nimport io.dropwizard.lifecycle.Managed;\nimport jakarta.validation.Validator;\nimport java.io.IOException;\nimport java.util.Collection;\nimport java.util.function.Function;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.captcha.CaptchaClient;\nimport org.whispersystems.textsecuregcm.storage.ReportedMessageListener;\n\n/**\n * A spam filter provides various checkers and listeners to detect and respond to patterns of spam and fraud.\n * <p/>\n * Spam filters are managed components that are generally loaded dynamically via a {@link java.util.ServiceLoader}.\n * Their {@link #configure(String, Validator)} method will be called prior to be adding to the server's pool of {@link Managed}\n * objects.\n * <p/>\n */\npublic interface SpamFilter extends Managed {\n\n  /**\n   * Configures this spam filter. This method will be called before the filter is added to the server's pool of managed\n   * objects and before the server processes any requests.\n   *\n   * @param environmentName the name of the environment in which this filter is running (e.g. \"staging\" or\n   *                        \"production\")\n   * @param validator may be used to validate configuration\n   * @throws IOException if the filter could not read its configuration source for any reason\n   * @throws ConfigurationValidationException if the configuration failed validation\n   */\n  void configure(String environmentName, Validator validator) throws IOException, ConfigurationValidationException;\n\n  /**\n   * Returns a collection of commands provided by this spam filter. Note that this method may be called before\n   * {@link #configure(String, Validator)}.\n   *\n   * @return a collection of commands provided by this spam filter\n   */\n  Collection<ConfiguredCommand<WhisperServerConfiguration>> getCommands();\n\n  /**\n   * Returns a message delivery listener controlled by the spam filter.\n   *\n   * @return a message delivery listener controlled by the spam filter\n   */\n  MessageDeliveryListener getMessageDeliveryListener();\n\n  /**\n   * Return a reported message listener controlled by the spam filter. Listeners will be registered with the\n   * {@link org.whispersystems.textsecuregcm.storage.ReportMessageManager}.\n   *\n   * @return a reported message listener controlled by the spam filter\n   */\n  ReportedMessageListener getReportedMessageListener();\n\n  /**\n   * Return a rate limit challenge listener. Listeners will be registered with the\n   * {@link org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager}\n   *\n   * @return a {@link RateLimitChallengeListener} controlled by the spam filter\n   */\n  RateLimitChallengeListener getRateLimitChallengeListener();\n\n  /**\n   * Return a spam checker that will be called on message sends via the\n   * {@link org.whispersystems.textsecuregcm.controllers.MessageController} to determine whether a specific message\n   * spend is spam.\n   *\n   * @return a {@link SpamChecker} controlled by the spam filter\n   */\n  SpamChecker getSpamChecker();\n\n  /**\n   * Return a checker that will be called to check registration attempts\n   *\n   * @return a {@link RegistrationFraudChecker} controlled by the spam filter\n   */\n  RegistrationFraudChecker getRegistrationFraudChecker();\n\n  /**\n   * Return a checker that will be called to determine what constraints should be applied\n   * when a user requests or solves a challenge (captchas, push challenges, etc).\n   *\n   * @return a {@link ChallengeConstraintChecker} controlled by the spam filter\n   */\n  ChallengeConstraintChecker getChallengeConstraintChecker();\n\n  /**\n   * Return a checker that will be called to determine if a user is allowed to use their\n   * registration recovery password to re-register\n   *\n   * @return a {@link RegistrationRecoveryChecker} controlled by the spam filter\n   */\n  RegistrationRecoveryChecker getRegistrationRecoveryChecker();\n\n  /**\n   * Return a function that will be used to lazily fetch the captcha client for a specified scheme. This is to avoid\n   * initialization issues with the spam filter if eagerly fetched.\n   *\n   * @return a {@link Function} that takes the scheme and returns a {@link CaptchaClient}. Returns null if no captcha\n   * client for the scheme exists\n   */\n  Function<String, CaptchaClient> getCaptchaClientSupplier();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AbstractDynamoDbStore.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\nimport static io.micrometer.core.instrument.Metrics.counter;\nimport static io.micrometer.core.instrument.Metrics.timer;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Consumer;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.BatchWriteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.BatchWriteItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport software.amazon.awssdk.services.dynamodb.model.WriteRequest;\nimport javax.annotation.Nonnull;\n\npublic abstract class AbstractDynamoDbStore {\n\n  private static final int MAX_ATTEMPTS_TO_SAVE_BATCH_WRITE = 25;  // This was arbitrarily chosen and may be entirely too high.\n\n  public static final int DYNAMO_DB_MAX_BATCH_SIZE = 25;  // This limit comes from Amazon Dynamo DB itself. It will reject batch writes larger than this.\n\n  public static final int RESULT_SET_CHUNK_SIZE = 100;\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  private final Timer batchWriteItemsFirstPass = timer(name(getClass(), \"batchWriteItems\"), \"firstAttempt\", \"true\");\n\n  private final Timer batchWriteItemsRetryPass = timer(name(getClass(), \"batchWriteItems\"), \"firstAttempt\", \"false\");\n\n  private final Counter batchWriteItemsUnprocessed = counter(name(getClass(), \"batchWriteItemsUnprocessed\"));\n\n  private final DynamoDbClient dynamoDbClient;\n\n\n  public AbstractDynamoDbStore(final DynamoDbClient dynamoDbClient) {\n    this.dynamoDbClient = dynamoDbClient;\n  }\n\n  protected DynamoDbClient db() {\n    return dynamoDbClient;\n  }\n\n  protected void executeTableWriteItemsUntilComplete(final Map<String, ? extends Collection<WriteRequest>> items) {\n    final AtomicReference<BatchWriteItemResponse> outcome = new AtomicReference<>();\n    writeAndStoreOutcome(items, batchWriteItemsFirstPass, outcome);\n    int attemptCount = 0;\n    while (!outcome.get().unprocessedItems().isEmpty() && attemptCount < MAX_ATTEMPTS_TO_SAVE_BATCH_WRITE) {\n      writeAndStoreOutcome(outcome.get().unprocessedItems(), batchWriteItemsRetryPass, outcome);\n      ++attemptCount;\n    }\n    if (!outcome.get().unprocessedItems().isEmpty()) {\n      final int totalItems = outcome.get().unprocessedItems().values().stream().mapToInt(List::size).sum();\n      logger.error(\n          \"Attempt count ({}) reached max ({}}) before applying all batch writes to dynamo. {} unprocessed items remain.\",\n          attemptCount, MAX_ATTEMPTS_TO_SAVE_BATCH_WRITE, totalItems);\n      batchWriteItemsUnprocessed.increment(totalItems);\n    }\n  }\n\n  @Nonnull\n  protected List<Map<String, AttributeValue>> scan(final ScanRequest scanRequest, final int max) {\n    return db().scanPaginator(scanRequest)\n        .items()\n        .stream()\n        .limit(max)\n        .toList();\n  }\n\n  private void writeAndStoreOutcome(\n      final Map<String, ? extends Collection<WriteRequest>> items,\n      final Timer timer,\n      final AtomicReference<BatchWriteItemResponse> outcome) {\n    timer.record(\n        () -> outcome.set(dynamoDbClient.batchWriteItem(BatchWriteItemRequest.builder().requestItems(items).build()))\n    );\n  }\n\n  static <T> void writeInBatches(final Iterable<T> items, final Consumer<List<T>> action) {\n    final List<T> batch = new ArrayList<>(DYNAMO_DB_MAX_BATCH_SIZE);\n\n    for (final T item : items) {\n      batch.add(item);\n\n      if (batch.size() == DYNAMO_DB_MAX_BATCH_SIZE) {\n        action.accept(batch);\n        batch.clear();\n      }\n    }\n    if (!batch.isEmpty()) {\n      action.accept(batch);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/Account.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\n\nimport com.fasterxml.jackson.annotation.JsonFilter;\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64UrlAdapter;\nimport org.whispersystems.textsecuregcm.util.IdentityKeyAdapter;\n\n@JsonFilter(\"Account\")\npublic class Account {\n\n  private static final Logger logger = LoggerFactory.getLogger(Account.class);\n\n  @JsonProperty\n  private UUID uuid;\n\n  @JsonProperty(\"pni\")\n  private UUID phoneNumberIdentifier;\n\n  @JsonProperty\n  private String number;\n\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n  @Nullable\n  private byte[] usernameHash;\n\n  @JsonProperty\n  @JsonSerialize(using = ByteArrayBase64UrlAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayBase64UrlAdapter.Deserializing.class)\n  @Nullable\n  private byte[] reservedUsernameHash;\n\n  @JsonProperty\n  @Nullable\n  private UUID usernameLinkHandle;\n\n  @JsonProperty(\"eu\")\n  @Nullable\n  private byte[] encryptedUsername;\n\n  @JsonProperty\n  private List<Device> devices = new ArrayList<>();\n\n  @JsonProperty\n  @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n  @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n  private IdentityKey identityKey;\n\n  @JsonProperty(\"pniIdentityKey\")\n  @JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n  @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n  private IdentityKey phoneNumberIdentityKey;\n\n  @JsonProperty(\"cpv\")\n  private String currentProfileVersion;\n\n  @JsonProperty\n  private List<AccountBadge> badges = new ArrayList<>();\n\n  @JsonProperty\n  private String registrationLock;\n\n  @JsonProperty\n  private String registrationLockSalt;\n\n  @JsonProperty(\"uak\")\n  private byte[] unidentifiedAccessKey;\n\n  @JsonProperty(\"uua\")\n  private boolean unrestrictedUnidentifiedAccess;\n\n  @JsonProperty(\"inCds\")\n  private boolean discoverableByPhoneNumber = true;\n\n  @JsonProperty(\"bcr\")\n  @Nullable\n  private byte[] messagesBackupCredentialRequest;\n\n  @JsonProperty(\"mbcr\")\n  @Nullable\n  private byte[] mediaBackupCredentialRequest;\n\n  @JsonProperty(\"bv\")\n  @Nullable\n  private BackupVoucher backupVoucher;\n\n  @JsonProperty\n  private int version;\n\n  @JsonProperty(\"holds\")\n  private List<UsernameHold> usernameHolds = Collections.emptyList();\n\n  @JsonIgnore\n  private boolean stale;\n\n  public record UsernameHold(@JsonProperty(\"uh\") byte[] usernameHash, @JsonProperty(\"e\") long expirationSecs) {}\n\n  public record BackupVoucher(@JsonProperty(\"rl\") long receiptLevel, @JsonProperty(\"e\") Instant expiration) {}\n\n  public UUID getIdentifier(final IdentityType identityType) {\n    return switch (identityType) {\n      case ACI -> getUuid();\n      case PNI -> getPhoneNumberIdentifier();\n    };\n  }\n\n  public UUID getUuid() {\n    // this is the one method that may be called on a stale account\n    return uuid;\n  }\n\n  public void setUuid(final UUID uuid) {\n    requireNotStale();\n\n    this.uuid = uuid;\n  }\n\n  public UUID getPhoneNumberIdentifier() {\n    requireNotStale();\n\n    return phoneNumberIdentifier;\n  }\n\n  /**\n   * Tests whether this account's account identifier or phone number identifier (depending on the given service\n   * identifier's identity type) matches the given service identifier.\n   *\n   * @param serviceIdentifier the identifier to test\n   * @return {@code true} if this account's identifier or phone number identifier matches\n   */\n  public boolean isIdentifiedBy(final ServiceIdentifier serviceIdentifier) {\n    return switch (serviceIdentifier.identityType()) {\n      case ACI -> serviceIdentifier.uuid().equals(uuid);\n      case PNI -> serviceIdentifier.uuid().equals(phoneNumberIdentifier);\n    };\n  }\n\n  public String getNumber() {\n    requireNotStale();\n\n    return number;\n  }\n\n  public void setNumber(final String number, final UUID phoneNumberIdentifier) {\n    requireNotStale();\n\n    this.number = number;\n    this.phoneNumberIdentifier = phoneNumberIdentifier;\n  }\n\n  public Optional<byte[]> getUsernameHash() {\n    requireNotStale();\n\n    return Optional.ofNullable(usernameHash);\n  }\n\n  public void setUsernameHash(final byte[] usernameHash) {\n    requireNotStale();\n\n    this.usernameHash = usernameHash;\n  }\n\n  public Optional<byte[]> getReservedUsernameHash() {\n    requireNotStale();\n\n    return Optional.ofNullable(reservedUsernameHash);\n  }\n\n  public void setReservedUsernameHash(final byte[] reservedUsernameHash) {\n    requireNotStale();\n\n    this.reservedUsernameHash = reservedUsernameHash;\n  }\n\n  @Nullable\n  public UUID getUsernameLinkHandle() {\n    requireNotStale();\n    return usernameLinkHandle;\n  }\n\n  public Optional<byte[]> getEncryptedUsername() {\n    requireNotStale();\n    return Optional.ofNullable(encryptedUsername);\n  }\n\n  public void setUsernameLinkDetails(@Nullable final UUID usernameLinkHandle, @Nullable final byte[] encryptedUsername) {\n    requireNotStale();\n    if ((usernameLinkHandle == null) ^ (encryptedUsername == null)) {\n      throw new IllegalArgumentException(\"Both or neither arguments must be null\");\n    }\n    if (usernameHash == null && encryptedUsername != null) {\n      throw new IllegalArgumentException(\"usernameHash field must be set to store username link\");\n    }\n    this.encryptedUsername = encryptedUsername;\n    this.usernameLinkHandle = usernameLinkHandle;\n  }\n\n  /*\n   * This method is intentionally left package-private so that it's only used\n   * when Account is read from DB\n   */\n  void setUsernameLinkHandle(@Nullable final UUID usernameLinkHandle) {\n    requireNotStale();\n    this.usernameLinkHandle = usernameLinkHandle;\n  }\n\n  public void addDevice(final Device device) {\n    requireNotStale();\n\n    removeDevice(device.getId());\n    this.devices.add(device);\n  }\n\n  public void removeDevice(final byte deviceId) {\n    requireNotStale();\n\n    this.devices.removeIf(device -> device.getId() == deviceId);\n  }\n\n  public List<Device> getDevices() {\n    requireNotStale();\n\n    return devices;\n  }\n\n  public Device getPrimaryDevice() {\n    requireNotStale();\n\n    return getDevice(Device.PRIMARY_ID)\n        .orElseThrow(() -> new IllegalStateException(\"All accounts must have a primary device\"));\n  }\n\n  public Optional<Device> getDevice(final byte deviceId) {\n    requireNotStale();\n\n    return devices.stream().filter(device -> device.getId() == deviceId).findFirst();\n  }\n\n  public boolean hasCapability(final DeviceCapability capability) {\n    requireNotStale();\n\n    return switch (capability.getAccountCapabilityMode()) {\n      case PRIMARY_DEVICE -> getPrimaryDevice().hasCapability(capability);\n      case ANY_DEVICE -> devices.stream().anyMatch(device -> device.hasCapability(capability));\n      case ALL_DEVICES -> devices.stream().allMatch(device -> device.hasCapability(capability));\n      case ALWAYS_CAPABLE -> true;\n    };\n  }\n\n  public byte getNextDeviceId() {\n    requireNotStale();\n\n    byte candidateId = Device.PRIMARY_ID + 1;\n\n    while (getDevice(candidateId).isPresent()) {\n      candidateId++;\n    }\n\n    if (candidateId <= Device.PRIMARY_ID) {\n      throw new RuntimeException(\"device ID overflow\");\n    }\n\n    return candidateId;\n  }\n\n  public void setIdentityKey(final IdentityKey identityKey) {\n    requireNotStale();\n\n    this.identityKey = identityKey;\n  }\n\n  public IdentityKey getIdentityKey(final IdentityType identityType) {\n    requireNotStale();\n\n    return switch (identityType) {\n      case ACI -> identityKey;\n      case PNI -> phoneNumberIdentityKey;\n    };\n  }\n\n  public void setPhoneNumberIdentityKey(final IdentityKey phoneNumberIdentityKey) {\n    this.phoneNumberIdentityKey = phoneNumberIdentityKey;\n  }\n\n  public long getLastSeen() {\n    requireNotStale();\n    return devices.stream()\n        .map(Device::getLastSeen)\n        .max(Long::compare)\n        .orElse(0L);\n  }\n\n  public Optional<String> getCurrentProfileVersion() {\n    requireNotStale();\n\n    return Optional.ofNullable(currentProfileVersion);\n  }\n\n  public void setCurrentProfileVersion(final String currentProfileVersion) {\n    requireNotStale();\n\n    this.currentProfileVersion = currentProfileVersion;\n  }\n\n  public List<AccountBadge> getBadges() {\n    requireNotStale();\n\n    return badges;\n  }\n\n  public void setBadges(final Clock clock, final List<AccountBadge> badges) {\n    requireNotStale();\n\n    this.badges = badges;\n\n    purgeStaleBadges(clock);\n  }\n\n  public void addBadge(final Clock clock, final AccountBadge badge) {\n    requireNotStale();\n    boolean added = false;\n    for (int i = 0; i < badges.size(); i++) {\n      final AccountBadge badgeInList = badges.get(i);\n      if (Objects.equals(badgeInList.id(), badge.id())) {\n        if (added) {\n          badges.remove(i);\n          i--;\n        } else {\n          badges.set(i, badgeInList.mergeWith(badge));\n          added = true;\n        }\n      }\n    }\n\n    if (!added) {\n      badges.add(badge);\n    }\n\n    purgeStaleBadges(clock);\n  }\n\n  public void makeBadgePrimaryIfExists(final Clock clock, final String badgeId) {\n    requireNotStale();\n\n    // early exit if it's already the first item in the list\n    if (!badges.isEmpty() && Objects.equals(badges.get(0).id(), badgeId)) {\n      purgeStaleBadges(clock);\n      return;\n    }\n\n    int indexOfBadge = -1;\n    for (int i = 1; i < badges.size(); i++) {\n      if (Objects.equals(badgeId, badges.get(i).id())) {\n        indexOfBadge = i;\n        break;\n      }\n    }\n\n    if (indexOfBadge != -1) {\n      badges.add(0, badges.remove(indexOfBadge));\n    }\n\n    purgeStaleBadges(clock);\n  }\n\n  public void removeBadge(final Clock clock, final String id) {\n    requireNotStale();\n\n    badges.removeIf(accountBadge -> Objects.equals(accountBadge.id(), id));\n    purgeStaleBadges(clock);\n  }\n\n  private void purgeStaleBadges(final Clock clock) {\n    final Instant now = clock.instant();\n    badges.removeIf(accountBadge -> now.isAfter(accountBadge.expiration()));\n  }\n\n  public void setRegistrationLockFromAttributes(final AccountAttributes attributes) {\n    if (StringUtils.isNotEmpty(attributes.getRegistrationLock())) {\n      final SaltedTokenHash credentials = SaltedTokenHash.generateFor(attributes.getRegistrationLock());\n      setRegistrationLock(credentials.hash(), credentials.salt());\n    } else {\n      setRegistrationLock(null, null);\n    }\n  }\n\n  public void setRegistrationLock(final String registrationLock, final String registrationLockSalt) {\n    requireNotStale();\n\n    this.registrationLock     = registrationLock;\n    this.registrationLockSalt = registrationLockSalt;\n  }\n\n  public StoredRegistrationLock getRegistrationLock() {\n    requireNotStale();\n\n    return new StoredRegistrationLock(Optional.ofNullable(registrationLock), Optional.ofNullable(registrationLockSalt), Instant.ofEpochMilli(getLastSeen()));\n  }\n\n  public Optional<byte[]> getUnidentifiedAccessKey() {\n    requireNotStale();\n\n    return Optional.ofNullable(unidentifiedAccessKey);\n  }\n\n  public void setUnidentifiedAccessKey(final byte[] unidentifiedAccessKey) {\n    requireNotStale();\n\n    this.unidentifiedAccessKey = unidentifiedAccessKey;\n  }\n\n  public boolean isUnrestrictedUnidentifiedAccess() {\n    requireNotStale();\n\n    return unrestrictedUnidentifiedAccess;\n  }\n\n  public void setUnrestrictedUnidentifiedAccess(final boolean unrestrictedUnidentifiedAccess) {\n    requireNotStale();\n\n    this.unrestrictedUnidentifiedAccess = unrestrictedUnidentifiedAccess;\n  }\n\n  public boolean isDiscoverableByPhoneNumber() {\n    requireNotStale();\n\n    return this.discoverableByPhoneNumber;\n  }\n\n  public void setDiscoverableByPhoneNumber(final boolean discoverableByPhoneNumber) {\n    requireNotStale();\n\n    this.discoverableByPhoneNumber = discoverableByPhoneNumber;\n  }\n\n  public int getVersion() {\n    requireNotStale();\n\n    return version;\n  }\n\n  public void setVersion(final int version) {\n    requireNotStale();\n\n    this.version = version;\n  }\n\n  public void setBackupCredentialRequests(final byte[] messagesBackupCredentialRequest,\n      final byte[] mediaBackupCredentialRequest) {\n\n    requireNotStale();\n\n    this.messagesBackupCredentialRequest = messagesBackupCredentialRequest;\n    this.mediaBackupCredentialRequest = mediaBackupCredentialRequest;\n  }\n\n  public Optional<byte[]> getBackupCredentialRequest(final BackupCredentialType credentialType) {\n    requireNotStale();\n\n    return Optional.ofNullable(switch (credentialType) {\n      case MESSAGES -> messagesBackupCredentialRequest;\n      case MEDIA -> mediaBackupCredentialRequest;\n    });\n  }\n\n  public @Nullable BackupVoucher getBackupVoucher() {\n    requireNotStale();\n\n    return backupVoucher;\n  }\n\n  public void setBackupVoucher(final @Nullable BackupVoucher backupVoucher) {\n    requireNotStale();\n\n    this.backupVoucher = backupVoucher;\n  }\n\n  /**\n   * Have all this account's devices been manually locked?\n   *\n   * @see Device#hasLockedCredentials\n   *\n   * @return true if all the account's devices were locked, false otherwise.\n   */\n  public boolean hasLockedCredentials() {\n    return devices.stream().allMatch(Device::hasLockedCredentials);\n  }\n\n  /**\n   * Lock account by invalidating authentication tokens.\n   *\n   * We only want to do this in cases where there is a potential conflict between the\n   * phone number holder and the registration lock holder. In that case, locking the\n   * account will ensure that either the registration lock holder proves ownership\n   * of the phone number, or after 7 days the phone number holder can register a new\n   * account.\n   */\n  public void lockAuthTokenHash() {\n    devices.forEach(Device::lockAuthTokenHash);\n  }\n\n  public List<UsernameHold> getUsernameHolds() {\n    return Collections.unmodifiableList(usernameHolds);\n  }\n\n  public void setUsernameHolds(final List<UsernameHold> usernameHolds) {\n    this.requireNotStale();\n    this.usernameHolds = usernameHolds;\n  }\n\n  public void markStale() {\n    stale = true;\n  }\n\n  private void requireNotStale() {\n    assert !stale;\n\n    //noinspection ConstantConditions\n    if (stale) {\n      logger.error(\"Accessor called on stale account\", new RuntimeException());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountAlreadyExistsException.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nclass AccountAlreadyExistsException extends Exception {\n  private final Account existingAccount;\n\n  public AccountAlreadyExistsException(final Account existingAccount) {\n    this.existingAccount = existingAccount;\n  }\n\n  public Account getExistingAccount() {\n    return existingAccount;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountBadge.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Instant;\nimport java.util.Objects;\n\npublic record AccountBadge(String id, Instant expiration, boolean visible) {\n\n  /**\n   * Returns a new AccountBadge that is a merging of the two originals. IDs must match for this operation to make sense.\n   * The expiration will be the later of the two. Visibility will be set if either of the passed in objects is visible.\n   */\n  public AccountBadge mergeWith(AccountBadge other) {\n    if (!Objects.equals(other.id, id)) {\n      throw new IllegalArgumentException(\"merging badges should only take place for same id\");\n    }\n\n    final Instant latestExpiration;\n    if (expiration == null || other.expiration == null) {\n      latestExpiration = null;\n    } else if (expiration.isAfter(other.expiration)) {\n      latestExpiration = expiration;\n    } else {\n      latestExpiration = other.expiration;\n    }\n\n    return new AccountBadge(\n        id,\n        latestExpiration,\n        visible || other.visible\n    );\n  }\n\n  public AccountBadge withVisibility(boolean visible) {\n    if (this.visible == visible) {\n      return this;\n    } else {\n      return new AccountBadge(\n          this.id,\n          this.expiration,\n          visible);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidator.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.security.MessageDigest;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n\nclass AccountChangeValidator {\n\n  private static final byte[] NO_HASH = new byte[32];\n\n  private final boolean allowNumberChange;\n  private final boolean allowUsernameHashChange;\n\n  static final AccountChangeValidator GENERAL_CHANGE_VALIDATOR = new AccountChangeValidator(false, false);\n  static final AccountChangeValidator NUMBER_CHANGE_VALIDATOR = new AccountChangeValidator(true, false);\n  static final AccountChangeValidator USERNAME_CHANGE_VALIDATOR = new AccountChangeValidator(false, true);\n\n  private static final Logger logger = LoggerFactory.getLogger(AccountChangeValidator.class);\n\n  AccountChangeValidator(final boolean allowNumberChange,\n      final boolean allowUsernameHashChange) {\n\n    this.allowNumberChange = allowNumberChange;\n    this.allowUsernameHashChange = allowUsernameHashChange;\n  }\n\n  public void validateChange(final Account originalAccount, final Account updatedAccount) {\n    if (!allowNumberChange) {\n      assert updatedAccount.getNumber().equals(originalAccount.getNumber());\n\n      if (!updatedAccount.getNumber().equals(originalAccount.getNumber())) {\n        logger.error(\"Account number changed via \\\"normal\\\" update; numbers must be changed via changeNumber method\",\n            new RuntimeException());\n      }\n\n      assert updatedAccount.getPhoneNumberIdentifier().equals(originalAccount.getPhoneNumberIdentifier());\n\n      if (!updatedAccount.getPhoneNumberIdentifier().equals(originalAccount.getPhoneNumberIdentifier())) {\n        logger.error(\n            \"Phone number identifier changed via \\\"normal\\\" update; PNIs must be changed via changeNumber method\",\n            new RuntimeException());\n      }\n    }\n\n    if (!allowUsernameHashChange) {\n      final byte[] updatedAccountUsernameHash = updatedAccount.getUsernameHash().orElse(NO_HASH);\n      final byte[] originalAccountUsernameHash = originalAccount.getUsernameHash().orElse(NO_HASH);\n\n      boolean usernameUnchanged = MessageDigest.isEqual(updatedAccountUsernameHash, originalAccountUsernameHash);\n\n      if (!usernameUnchanged) {\n        logger.error(\"Username hash changed via \\\"normal\\\" update; username hashes must be changed via reserveUsernameHash and confirmUsernameHash methods\",\n            new RuntimeException());\n      }\n      assert usernameUnchanged;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountLockManager.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport com.amazonaws.services.dynamodbv2.AcquireLockOptions;\nimport com.amazonaws.services.dynamodbv2.AmazonDynamoDBLockClient;\nimport com.amazonaws.services.dynamodbv2.AmazonDynamoDBLockClientOptions;\nimport com.amazonaws.services.dynamodbv2.LockItem;\nimport com.amazonaws.services.dynamodbv2.ReleaseLockOptions;\nimport com.google.common.annotations.VisibleForTesting;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.TimeUnit;\nimport org.whispersystems.textsecuregcm.util.ThrowingSupplier;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\n\npublic class AccountLockManager {\n\n  private final AmazonDynamoDBLockClient lockClient;\n\n  static final String KEY_ACCOUNT_PNI = \"P\";\n\n  public AccountLockManager(final DynamoDbClient lockDynamoDb, final String lockTableName) {\n    this(new AmazonDynamoDBLockClient(\n        AmazonDynamoDBLockClientOptions.builder(lockDynamoDb, lockTableName)\n            .withPartitionKeyName(KEY_ACCOUNT_PNI)\n            .withLeaseDuration(15L)\n            .withHeartbeatPeriod(2L)\n            .withTimeUnit(TimeUnit.SECONDS)\n            .withCreateHeartbeatBackgroundThread(true)\n            .build()));\n  }\n\n  @VisibleForTesting\n  AccountLockManager(final AmazonDynamoDBLockClient lockClient) {\n    this.lockClient = lockClient;\n  }\n\n  /**\n   * Acquires a distributed, pessimistic lock for the accounts identified by the given phone number identifiers. By\n   * design, the accounts need not actually exist in order to acquire a lock; this allows lock acquisition for\n   * operations that span account lifecycle changes (like deleting an account or changing a phone number). The given\n   * task runs once locks for all given identifiers have been acquired, and the locks are released as soon as the task\n   * completes by any means.\n   *\n   * @param phoneNumberIdentifiers  the phone number identifiers for which to acquire a distributed, pessimistic lock\n   * @param task                    the task to execute once locks have been acquired\n   * @param lockAcquisitionExecutor the executor on which to run blocking lock acquire/release tasks. this executor\n   *                                should not use virtual threads.\n   *\n   * @return the value returned by the given {@code task}\n   *\n   * @throws E if an exception is thrown by the given {@code task}\n   */\n  public <V, E extends Exception> V withLock(final Set<UUID> phoneNumberIdentifiers,\n      final ThrowingSupplier<V, E> task,\n      final Executor lockAcquisitionExecutor) throws E {\n\n    if (phoneNumberIdentifiers.isEmpty()) {\n      throw new IllegalArgumentException(\"List of PNIs to lock must not be empty\");\n    }\n\n    final List<LockItem> lockItems = new ArrayList<>(phoneNumberIdentifiers.size());\n\n    try {\n      // Offload the acquire/release tasks to the dedicated lock acquisition executor. The lock client performs blocking\n      // operations while holding locks which forces thread pinning when this method runs on a virtual thread.\n      // https://github.com/awslabs/amazon-dynamodb-lock-client/issues/97\n      CompletableFuture.runAsync(() -> {\n        for (final UUID pni : phoneNumberIdentifiers) {\n          try {\n            lockItems.add(lockClient.acquireLock(AcquireLockOptions.builder(pni.toString())\n                .withAcquireReleasedLocksConsistently(true)\n                .build()));\n          } catch (final InterruptedException e) {\n            throw new CompletionException(e);\n          }\n        }\n      }, lockAcquisitionExecutor).join();\n\n      return task.get();\n    } finally {\n      CompletableFuture.runAsync(() -> {\n        for (final LockItem lockItem : lockItems) {\n          lockClient.releaseLock(ReleaseLockOptions.builder(lockItem)\n              .withBestEffort(true)\n              .build());\n        }\n      }, lockAcquisitionExecutor).join();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport java.io.IOException;\n\npublic class AccountUtil {\n\n  static Account cloneAccountAsNotStale(final Account account) {\n    try {\n      return SystemMapper.jsonMapper().readValue(\n          SystemMapper.jsonMapper().writeValueAsBytes(account), Account.class);\n    } catch (final IOException e) {\n      // this should really, truly, never happen\n      throw new IllegalArgumentException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/Accounts.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static java.util.Objects.requireNonNull;\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectWriter;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collection;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.function.Predicate;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.util.AsyncTimerUtil;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Scheduler;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.CancellationReason;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.Delete;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.Put;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryResponse;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionConflictException;\nimport software.amazon.awssdk.services.dynamodb.model.Update;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemResponse;\nimport software.amazon.awssdk.services.dynamodb.paginators.ScanPublisher;\nimport software.amazon.awssdk.utils.CompletableFutureUtils;\n\n/**\n * \"Accounts\" DDB table's structure doesn't match 1:1 the {@link Account} class: most of the class fields are serialized\n * and stored in the {@link Accounts#ATTR_ACCOUNT_DATA} attribute, however there are certain fields that are stored only as DDB attributes\n * (e.g. if indexing or lookup by field is required), and there are also fields that stored in both places.\n * This class contains all the logic that decides whether or not a field of the {@link Account} class should be\n * added as an attribute, serialized as a part of {@link Accounts#ATTR_ACCOUNT_DATA}, or both. To skip serialization,\n * make sure attribute name is listed in {@link Accounts#ACCOUNT_FIELDS_TO_EXCLUDE_FROM_SERIALIZATION}. If serialization is skipped,\n * make sure the field is stored in a DDB attribute and then put back into the account object in {@link Accounts#fromItem(Map)}.\n */\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic class Accounts {\n\n  private static final Logger log = LoggerFactory.getLogger(Accounts.class);\n\n  private static final Duration USERNAME_RECLAIM_TTL = Duration.ofDays(3);\n\n  static final List<String> ACCOUNT_FIELDS_TO_EXCLUDE_FROM_SERIALIZATION = List.of(\"uuid\", \"usernameLinkHandle\");\n\n  @VisibleForTesting\n  static final ObjectWriter ACCOUNT_DDB_JSON_WRITER = SystemMapper.jsonMapper()\n      .writer(SystemMapper.excludingField(Account.class, ACCOUNT_FIELDS_TO_EXCLUDE_FROM_SERIALIZATION));\n\n  private static final Timer CREATE_TIMER = Metrics.timer(name(Accounts.class, \"create\"));\n  private static final Timer CHANGE_NUMBER_TIMER = Metrics.timer(name(Accounts.class, \"changeNumber\"));\n  private static final Timer SET_USERNAME_TIMER = Metrics.timer(name(Accounts.class, \"setUsername\"));\n  private static final Timer RESERVE_USERNAME_TIMER = Metrics.timer(name(Accounts.class, \"reserveUsername\"));\n  private static final Timer CLEAR_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, \"clearUsernameHash\"));\n  private static final Timer UPDATE_TIMER = Metrics.timer(name(Accounts.class, \"update\"));\n  private static final Timer UPDATE_TRANSACTIONALLY_TIMER = Metrics.timer(name(Accounts.class, \"updateTransactionally\"));\n  private static final Timer RECLAIM_TIMER = Metrics.timer(name(Accounts.class, \"reclaim\"));\n  private static final Timer GET_BY_NUMBER_TIMER = Metrics.timer(name(Accounts.class, \"getByNumber\"));\n  private static final Timer GET_BY_USERNAME_HASH_TIMER = Metrics.timer(name(Accounts.class, \"getByUsernameHash\"));\n  private static final Timer GET_BY_USERNAME_LINK_HANDLE_TIMER = Metrics.timer(name(Accounts.class, \"getByUsernameLinkHandle\"));\n  private static final Timer GET_BY_PNI_TIMER = Metrics.timer(name(Accounts.class, \"getByPni\"));\n  private static final Timer GET_BY_UUID_TIMER = Metrics.timer(name(Accounts.class, \"getByUuid\"));\n  private static final Timer DELETE_TIMER = Metrics.timer(name(Accounts.class, \"delete\"));\n  private static final String USERNAME_HOLD_ADDED_COUNTER_NAME = name(Accounts.class, \"usernameHoldAdded\");\n\n  private static final String CONDITIONAL_CHECK_FAILED = \"ConditionalCheckFailed\";\n\n  private static final String TRANSACTION_CONFLICT = \"TransactionConflict\";\n\n  // uuid, primary key\n  static final String KEY_ACCOUNT_UUID = \"U\";\n  // uuid, attribute on account table, primary key for PNI table\n  static final String ATTR_PNI_UUID = \"PNI\";\n  // uuid of the current username link or null\n  static final String ATTR_USERNAME_LINK_UUID = \"UL\";\n  // phone number\n  static final String ATTR_ACCOUNT_E164 = \"P\";\n  // account, serialized to JSON\n  static final String ATTR_ACCOUNT_DATA = \"D\";\n  // internal version for optimistic locking\n  static final String ATTR_VERSION = \"V\";\n  // canonically discoverable\n  static final String ATTR_CANONICALLY_DISCOVERABLE = \"C\";\n  // username hash; byte[] or null\n  static final String ATTR_USERNAME_HASH = \"N\";\n\n  // bytes, primary key\n  static final String KEY_LINK_DEVICE_TOKEN_HASH = \"H\";\n\n  // integer, seconds\n  static final String ATTR_LINK_DEVICE_TOKEN_TTL = \"E\";\n\n  // unidentified access key; byte[] or null\n  static final String ATTR_UAK = \"UAK\";\n\n  // For historical reasons, deleted-accounts PNI is stored as a string-format UUID rather than a\n  // compact byte array.\n  static final String DELETED_ACCOUNTS_KEY_ACCOUNT_PNI = \"P\";\n\n  static final String DELETED_ACCOUNTS_ATTR_ACCOUNT_UUID = \"U\";\n  static final String DELETED_ACCOUNTS_ATTR_EXPIRES = \"E\";\n  static final String DELETED_ACCOUNTS_UUID_TO_PNI_INDEX_NAME = \"u_to_p\";\n\n  static final String USERNAME_LINK_TO_UUID_INDEX = \"ul_to_u\";\n\n  static final Duration DELETED_ACCOUNTS_TIME_TO_LIVE = Duration.ofDays(30);\n\n  /**\n   * Maximum number of temporary username holds an account can have on recently used usernames\n   */\n  @VisibleForTesting\n  static final int MAX_USERNAME_HOLDS = 3;\n\n  /**\n   * How long an old username is held for an account after the account initially clears/switches the username\n   */\n  @VisibleForTesting\n  static final Duration USERNAME_HOLD_DURATION = Duration.ofDays(7);\n\n  private final Clock clock;\n\n  private final DynamoDbClient dynamoDbClient;\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n\n  private final String phoneNumberConstraintTableName;\n  private final String phoneNumberIdentifierConstraintTableName;\n  private final String usernamesConstraintTableName;\n  private final String deletedAccountsTableName;\n  private final String usedLinkDeviceTokenTableName;\n  private final String accountsTableName;\n\n  public Accounts(\n      final Clock clock,\n      final DynamoDbClient dynamoDbClient,\n      final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final String accountsTableName,\n      final String phoneNumberConstraintTableName,\n      final String phoneNumberIdentifierConstraintTableName,\n      final String usernamesConstraintTableName,\n      final String deletedAccountsTableName,\n      final String usedLinkDeviceTokenTableName) {\n\n    this.clock = clock;\n    this.dynamoDbClient = dynamoDbClient;\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.phoneNumberConstraintTableName = phoneNumberConstraintTableName;\n    this.phoneNumberIdentifierConstraintTableName = phoneNumberIdentifierConstraintTableName;\n    this.accountsTableName = accountsTableName;\n    this.usernamesConstraintTableName = usernamesConstraintTableName;\n    this.deletedAccountsTableName = deletedAccountsTableName;\n    this.usedLinkDeviceTokenTableName = usedLinkDeviceTokenTableName;\n  }\n\n  static class UsernameTable {\n    // usernameHash; bytes.\n    static final String KEY_USERNAME_HASH = Accounts.ATTR_USERNAME_HASH;\n    // uuid, bytes. The owner of the username or reservation\n    static final String ATTR_ACCOUNT_UUID = Accounts.KEY_ACCOUNT_UUID;\n    // confirmed; bool\n    static final String ATTR_CONFIRMED = \"F\";\n    // reclaimable; bool. Indicates that on confirmation the username link should be preserved\n    static final String ATTR_RECLAIMABLE = \"R\";\n    // time to live; number\n    static final String ATTR_TTL = \"TTL\";\n  }\n\n  boolean create(final Account account, final List<TransactWriteItem> additionalWriteItems)\n      throws AccountAlreadyExistsException {\n\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      final AttributeValue uuidAttr = AttributeValues.fromUUID(account.getUuid());\n      final AttributeValue numberAttr = AttributeValues.fromString(account.getNumber());\n      final AttributeValue pniUuidAttr = AttributeValues.fromUUID(account.getPhoneNumberIdentifier());\n\n      final TransactWriteItem phoneNumberConstraintPut = buildConstraintTablePutIfAbsent(\n          phoneNumberConstraintTableName, uuidAttr, ATTR_ACCOUNT_E164, numberAttr);\n\n      final TransactWriteItem phoneNumberIdentifierConstraintPut = buildConstraintTablePutIfAbsent(\n          phoneNumberIdentifierConstraintTableName, uuidAttr, ATTR_PNI_UUID, pniUuidAttr);\n\n      final TransactWriteItem accountPut = buildAccountPut(account, uuidAttr, numberAttr, pniUuidAttr);\n\n      // Clear any \"recently deleted account\" record for this number since, if it existed, we've used its old ACI for\n      // the newly-created account.\n      final TransactWriteItem deletedAccountDelete = buildRemoveDeletedAccount(account.getPhoneNumberIdentifier());\n\n      final Collection<TransactWriteItem> writeItems = new ArrayList<>(\n          List.of(phoneNumberConstraintPut, phoneNumberIdentifierConstraintPut, accountPut, deletedAccountDelete));\n\n      writeItems.addAll(additionalWriteItems);\n\n      final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()\n          .transactItems(writeItems)\n          .build();\n\n      try {\n        dynamoDbClient.transactWriteItems(request);\n      } catch (final TransactionCanceledException e) {\n\n        final CancellationReason accountCancellationReason = e.cancellationReasons().get(2);\n\n        if (conditionalCheckFailed(accountCancellationReason)) {\n          throw new IllegalArgumentException(\"account identifier present with different phone number\");\n        }\n\n        final CancellationReason phoneNumberConstraintCancellationReason = e.cancellationReasons().get(0);\n        final CancellationReason phoneNumberIdentifierConstraintCancellationReason = e.cancellationReasons().get(1);\n\n        if (conditionalCheckFailed(phoneNumberConstraintCancellationReason)\n            || conditionalCheckFailed(phoneNumberIdentifierConstraintCancellationReason)) {\n\n          // Both reasons should trip in tandem and either should give us the information we need. However, phone number\n          // canonicalization can cause multiple e164s to have the same PNI, so we make sure we're choosing a condition\n          // check that really failed.\n          final CancellationReason reason = conditionalCheckFailed(phoneNumberConstraintCancellationReason)\n              ? phoneNumberConstraintCancellationReason\n              : phoneNumberIdentifierConstraintCancellationReason;\n\n          final UUID existingAccountUuid =\n              UUIDUtil.fromByteBuffer(reason.item().get(KEY_ACCOUNT_UUID).b().asByteBuffer());\n\n          // This is unlikely, but it could be that the existing account was deleted in between the time the transaction\n          // happened and when we tried to read the full existing account. If that happens, we can just consider this a\n          // contested lock, and retrying is likely to succeed.\n          final Account existingAccount = getByAccountIdentifier(existingAccountUuid)\n              .orElseThrow(ContestedOptimisticLockException::new);\n\n          throw new AccountAlreadyExistsException(existingAccount);\n        }\n\n        if (TRANSACTION_CONFLICT.equals(accountCancellationReason.code())) {\n          // this should only happen if two clients manage to make concurrent create() calls\n          throw new ContestedOptimisticLockException();\n        }\n\n        // this shouldn't happen\n        throw new RuntimeException(\"could not create account: \" + extractCancellationReasonCodes(e));\n      }\n    } finally {\n      sample.stop(CREATE_TIMER);\n    }\n\n    return true;\n  }\n\n  /**\n   * Copies over any account attributes that should be preserved when a new account reclaims an account identifier.\n   *\n   * @param existingAccount the existing account in the accounts table\n   * @param accountToCreate a new account, with the same number and identifier as existingAccount\n   */\n  CompletionStage<Void> reclaimAccount(final Account existingAccount,\n      final Account accountToCreate,\n      final Collection<TransactWriteItem> additionalWriteItems) {\n\n    if (!existingAccount.getUuid().equals(accountToCreate.getUuid()) ||\n        !existingAccount.getPhoneNumberIdentifier().equals(accountToCreate.getPhoneNumberIdentifier())) {\n\n      log.error(\"Reclaimed accounts must match. Old account {}:{}:{}, New account {}:{}:{}\",\n          existingAccount.getUuid(), redactPhoneNumber(existingAccount.getNumber()), existingAccount.getPhoneNumberIdentifier(),\n          accountToCreate.getUuid(), redactPhoneNumber(accountToCreate.getNumber()), accountToCreate.getPhoneNumberIdentifier());\n      throw new IllegalArgumentException(\"reclaimed accounts must match\");\n    }\n\n    return AsyncTimerUtil.record(RECLAIM_TIMER, () -> {\n\n      accountToCreate.setVersion(existingAccount.getVersion());\n\n      // Carry over the old backup id commitment. If the new account claimer cannot does not have the secret used to\n      // generate their backup-id, this credential is useless, however if they can produce the same credential they\n      // won't be rate-limited for setting their backup-id.\n      accountToCreate.setBackupCredentialRequests(\n          existingAccount.getBackupCredentialRequest(BackupCredentialType.MESSAGES).orElse(null),\n          existingAccount.getBackupCredentialRequest(BackupCredentialType.MEDIA).orElse(null));\n\n      // Carry over the existing backup voucher to the new account\n      accountToCreate.setBackupVoucher(existingAccount.getBackupVoucher());\n\n      final List<TransactWriteItem> writeItems = new ArrayList<>();\n\n      // If we're reclaiming an account that already has a username, we'd like to give the re-registering client\n      // an opportunity to reclaim their original username and link. We do this by:\n      //   1. marking the usernameHash as reserved for the aci\n      //   2. saving the username link id, but not the encrypted username. The link will be broken until the client\n      //      reclaims their username\n      //\n      // If we partially reclaim the account but fail (for example, we update the account but the client goes away\n      // before creation is finished), we might be reclaiming the account we already reclaimed. In that case, we\n      // should copy over the reserved username and link verbatim\n      if (existingAccount.getReservedUsernameHash().isPresent() &&\n          existingAccount.getUsernameLinkHandle() != null &&\n          existingAccount.getUsernameHash().isEmpty() &&\n          existingAccount.getEncryptedUsername().isEmpty()) {\n        // reclaiming a partially reclaimed account\n        accountToCreate.setReservedUsernameHash(existingAccount.getReservedUsernameHash().get());\n        accountToCreate.setUsernameLinkHandle(existingAccount.getUsernameLinkHandle());\n      } else if (existingAccount.getUsernameHash().isPresent()) {\n        // reclaiming an account with a username\n        final byte[] usernameHash = existingAccount.getUsernameHash().get();\n        final long expirationTime = clock.instant().plus(USERNAME_RECLAIM_TTL).getEpochSecond();\n        accountToCreate.setReservedUsernameHash(usernameHash);\n        accountToCreate.setUsernameLinkHandle(existingAccount.getUsernameLinkHandle());\n\n        writeItems.add(TransactWriteItem.builder()\n            .put(Put.builder()\n                .tableName(usernamesConstraintTableName)\n                .item(Map.of(\n                    UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),\n                    UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(accountToCreate.getUuid()),\n                    UsernameTable.ATTR_TTL, AttributeValues.fromLong(expirationTime),\n                    UsernameTable.ATTR_CONFIRMED, AttributeValues.fromBool(false),\n                    UsernameTable.ATTR_RECLAIMABLE, AttributeValues.fromBool(true)))\n                .conditionExpression(\"attribute_not_exists(#username_hash) OR (#ttl < :now) OR #uuid = :uuid\")\n                .expressionAttributeNames(Map.of(\n                    \"#username_hash\", UsernameTable.KEY_USERNAME_HASH,\n                    \"#ttl\", UsernameTable.ATTR_TTL,\n                    \"#uuid\", UsernameTable.ATTR_ACCOUNT_UUID))\n                .expressionAttributeValues(Map.of(\n                    \":now\", AttributeValues.fromLong(clock.instant().getEpochSecond()),\n                    \":uuid\", AttributeValues.fromUUID(accountToCreate.getUuid())))\n                .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n                .build())\n            .build());\n      }\n      writeItems.add(UpdateAccountSpec.forAccount(accountsTableName, accountToCreate).transactItem());\n      writeItems.addAll(additionalWriteItems);\n\n      return dynamoDbAsyncClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(writeItems).build())\n          .thenApply(_ -> {\n            accountToCreate.setVersion(accountToCreate.getVersion() + 1);\n            return (Void) null;\n          })\n          .exceptionally(throwable -> {\n            final Throwable unwrapped = ExceptionUtils.unwrap(throwable);\n            if (unwrapped instanceof TransactionCanceledException te) {\n              if (te.cancellationReasons().stream().anyMatch(Accounts::conditionalCheckFailed)) {\n                throw new ContestedOptimisticLockException();\n              }\n            }\n            // rethrow\n            throw CompletableFutureUtils.errorAsCompletionException(throwable);\n          });\n    });\n  }\n\n  /**\n   * Changes the phone number for the given account. The given account's number should be its current, pre-change\n   * number. If this method succeeds, the account's number will be changed to the new number and its phone number\n   * identifier will be changed to the given phone number identifier. If the update fails for any reason, the account's\n   * number and PNI will be unchanged.\n   * <p/>\n   * This method expects that any accounts with conflicting numbers will have been removed by the time this method is\n   * called. This method may fail with an unspecified {@link RuntimeException} if another account with the same number\n   * exists in the data store.\n   *\n   * @param account the account for which to change the phone number\n   * @param number the new phone number\n   */\n  public void changeNumber(final Account account,\n      final String number,\n      final UUID phoneNumberIdentifier,\n      final Optional<UUID> maybeDisplacedAccountIdentifier,\n      final Collection<TransactWriteItem> additionalWriteItems) {\n\n    CHANGE_NUMBER_TIMER.record(() -> {\n      final String originalNumber = account.getNumber();\n      final UUID originalPni = account.getPhoneNumberIdentifier();\n\n      boolean succeeded = false;\n\n      account.setNumber(number, phoneNumberIdentifier);\n\n      int accountUpdateIndex = -1;\n      try {\n        final List<TransactWriteItem> writeItems = new ArrayList<>();\n        final AttributeValue uuidAttr = AttributeValues.fromUUID(account.getUuid());\n        final AttributeValue numberAttr = AttributeValues.fromString(number);\n        final AttributeValue pniAttr = AttributeValues.fromUUID(phoneNumberIdentifier);\n\n        if (!originalNumber.equals(number)) {\n          writeItems.add(buildDelete(phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, originalNumber));\n          writeItems.add(buildConstraintTablePut(phoneNumberConstraintTableName, uuidAttr, ATTR_ACCOUNT_E164, numberAttr));\n          writeItems.add(buildDelete(phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, originalPni));\n          writeItems.add(buildConstraintTablePut(phoneNumberIdentifierConstraintTableName, uuidAttr, ATTR_PNI_UUID, pniAttr));\n          writeItems.add(buildRemoveDeletedAccount(phoneNumberIdentifier));\n        }\n\n        maybeDisplacedAccountIdentifier.ifPresent(displacedAccountIdentifier ->\n            writeItems.add(buildPutDeletedAccount(displacedAccountIdentifier, originalPni)));\n\n        // The `catch (TransactionCanceledException) block needs to check whether the cancellation reason is the account\n        // update write item\n        accountUpdateIndex = writeItems.size();\n        writeItems.add(\n            TransactWriteItem.builder()\n                .update(Update.builder()\n                    .tableName(accountsTableName)\n                    .key(Map.of(KEY_ACCOUNT_UUID, uuidAttr))\n                    .updateExpression(\n                        \"SET #data = :data, #number = :number, #pni = :pni, #cds = :cds ADD #version :version_increment\")\n                    .conditionExpression(\n                        \"attribute_exists(#number) AND #version = :version\")\n                    .expressionAttributeNames(Map.of(\n                        \"#number\", ATTR_ACCOUNT_E164,\n                        \"#data\", ATTR_ACCOUNT_DATA,\n                        \"#cds\", ATTR_CANONICALLY_DISCOVERABLE,\n                        \"#pni\", ATTR_PNI_UUID,\n                        \"#version\", ATTR_VERSION))\n                    .expressionAttributeValues(Map.of(\n                        \":number\", numberAttr,\n                        \":data\", accountDataAttributeValue(account),\n                        \":cds\", AttributeValues.fromBool(account.isDiscoverableByPhoneNumber()),\n                        \":pni\", pniAttr,\n                        \":version\", AttributeValues.fromInt(account.getVersion()),\n                        \":version_increment\", AttributeValues.fromInt(1)))\n                    .build())\n                .build());\n\n        writeItems.addAll(additionalWriteItems);\n\n        final TransactWriteItemsRequest request = TransactWriteItemsRequest.builder()\n            .transactItems(writeItems)\n            .build();\n\n        dynamoDbClient.transactWriteItems(request);\n\n        account.setVersion(account.getVersion() + 1);\n        succeeded = true;\n      } catch (final TransactionCanceledException e) {\n        if (e.hasCancellationReasons()) {\n          if (CONDITIONAL_CHECK_FAILED.equals(e.cancellationReasons().get(accountUpdateIndex).code())) {\n            // the #version = :version condition failed, which indicates a concurrent update\n            throw new ContestedOptimisticLockException();\n          }\n        } else {\n          log.warn(\"Unexpected cancellation reasons: {}\", e.cancellationReasons());\n\n        }\n        throw e;\n      } finally {\n        if (!succeeded) {\n          account.setNumber(originalNumber, originalPni);\n        }\n      }\n    });\n  }\n\n  /// Reserve a username hash under the account UUID\n  ///\n  /// @throws ContestedOptimisticLockException if the account has been updated or there are concurrent updates to the\n  /// account or constraint records, and with an\n  /// @throws UsernameHashNotAvailableException if the username was taken by someone else\n  public void reserveUsernameHash(final Account account, final byte[] reservedUsernameHash, final Duration ttl)\n          throws ContestedOptimisticLockException, UsernameHashNotAvailableException {\n\n    final Timer.Sample sample = Timer.start();\n\n    // if there is an existing old reservation it will be cleaned up via ttl. Save it so we can restore it to the local\n    // account if the update fails though.\n    final Optional<byte[]> maybeOriginalReservation = account.getReservedUsernameHash();\n    account.setReservedUsernameHash(reservedUsernameHash);\n\n    // Normally when a username is reserved for the first time we reserve it for the provided TTL. But if the\n    // reservation is for a username that we already have a reservation for (for example, if it's reclaimable, or there\n    // is a hold) we might own that reservation for longer anyways, so we should preserve the original TTL in that case.\n    // What we'd really like to do is set expirationTime = max(oldExpirationTime, now + ttl), but dynamodb doesn't\n    // support that. Instead, we'll set expiration if it's greater than the existing expiration, otherwise retry\n    final long expirationTime = clock.instant().plus(ttl).getEpochSecond();\n    boolean succeeded = false;\n\n    try {\n      tryReserveUsernameHash(account, reservedUsernameHash, expirationTime);\n      succeeded = true;\n    } catch (final TtlConflictException e) {\n      // retry (once) with the returned expiration time\n      tryReserveUsernameHash(account, reservedUsernameHash, e.getExistingExpirationSeconds());\n      succeeded = true;\n    } finally {\n      sample.stop(RESERVE_USERNAME_TIMER);\n\n      if (succeeded) {\n        account.setVersion(account.getVersion() + 1);\n      } else {\n        account.setReservedUsernameHash(maybeOriginalReservation.orElse(null));\n      }\n    }\n  }\n\n  private static class TtlConflictException extends ContestedOptimisticLockException {\n    private final long existingExpirationSeconds;\n    TtlConflictException(final long existingExpirationSeconds) {\n      super();\n      this.existingExpirationSeconds = existingExpirationSeconds;\n    }\n\n    long getExistingExpirationSeconds() {\n      return existingExpirationSeconds;\n    }\n  }\n\n  /// Try to reserve the provided usernameHash\n  ///\n  /// @param updatedAccount        The account, already updated to reserve the provided usernameHash\n  /// @param reservedUsernameHash  The usernameHash to reserve\n  /// @param expirationTimeSeconds When the reservation should expire\n  ///\n  /// @throws ContestedOptimisticLockException  in the event of concurrent modifications to the account\n  /// @throws UsernameHashNotAvailableException if the username hash is already taken\n  /// @throws TtlConflictException              if the usernameHash was already reserved but with a longer TTL. The\n  ///                                           operation should be retried with the returned\n  ///                                           {@link TtlConflictException#getExistingExpirationSeconds()}\n  private void tryReserveUsernameHash(\n      final Account updatedAccount,\n      final byte[] reservedUsernameHash,\n      final long expirationTimeSeconds) throws ContestedOptimisticLockException, TtlConflictException, UsernameHashNotAvailableException {\n\n    // Use account UUID as a \"reservation token\" - by providing this, the client proves ownership of the hash\n    final UUID uuid = updatedAccount.getUuid();\n\n    final List<TransactWriteItem> writeItems = new ArrayList<>();\n\n    writeItems.add(TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(usernamesConstraintTableName)\n            .item(Map.of(\n                UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),\n                UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(reservedUsernameHash),\n                UsernameTable.ATTR_TTL, AttributeValues.fromLong(expirationTimeSeconds),\n                UsernameTable.ATTR_CONFIRMED, AttributeValues.fromBool(false),\n                UsernameTable.ATTR_RECLAIMABLE, AttributeValues.fromBool(false)))\n            // we can make a reservation if no reservation exists for the name, or that reservation is expired, or there\n            // is a reservation but it's ours and we haven't confirmed it yet and we're not accidentally reducing our\n            // reservation's TTL. Note that confirmed=false => a TTL exists\n            .conditionExpression(\"attribute_not_exists(#username_hash) OR #ttl < :now OR (#aci = :aci AND #confirmed = :false AND #ttl <= :expirationTime)\")\n            .expressionAttributeNames(Map.of(\n                \"#username_hash\", UsernameTable.KEY_USERNAME_HASH,\n                \"#ttl\", UsernameTable.ATTR_TTL,\n                \"#aci\", UsernameTable.ATTR_ACCOUNT_UUID,\n                \"#confirmed\", UsernameTable.ATTR_CONFIRMED))\n            .expressionAttributeValues(Map.of(\n                \":now\", AttributeValues.fromLong(clock.instant().getEpochSecond()),\n                \":aci\", AttributeValues.fromUUID(uuid),\n                \":false\", AttributeValues.fromBool(false),\n                \":expirationTime\", AttributeValues.fromLong(expirationTimeSeconds)))\n            .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n            .build())\n        .build());\n\n    writeItems.add(UpdateAccountSpec.forAccount(accountsTableName, updatedAccount).transactItem());\n\n    try {\n      dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(writeItems).build());\n    } catch (final TransactionCanceledException e) {\n      if (conditionalCheckFailed(e.cancellationReasons().get(0))) {\n        // The constraint table update failed the condition check. It could be because the username was taken,\n        // or because we need to retry with a longer TTL\n        final Map<String, AttributeValue> item = e.cancellationReasons().getFirst().item();\n        final UUID existingOwner = AttributeValues.getUUID(item, UsernameTable.ATTR_ACCOUNT_UUID, null);\n        final boolean confirmed = AttributeValues.getBool(item, UsernameTable.ATTR_CONFIRMED, false);\n        final long existingTtl = AttributeValues.getLong(item, UsernameTable.ATTR_TTL, 0L);\n        if (uuid.equals(existingOwner) && !confirmed && existingTtl > expirationTimeSeconds) {\n          // We failed because we provided a shorter TTL than the one that exists on the reservation. The caller\n          // can retry with updated expiration time.\n          throw new TtlConflictException(existingTtl);\n        }\n        throw new UsernameHashNotAvailableException();\n      } else if (conditionalCheckFailed(e.cancellationReasons().get(1)) ||\n          e.cancellationReasons().stream().anyMatch(Accounts::isTransactionConflict)) {\n        // The accounts table fails the conditional check or either table was concurrently updated, it's an\n        // optimistic locking failure and we should try again.\n        throw new ContestedOptimisticLockException();\n      } else {\n        throw e;\n      }\n    }\n  }\n\n  /**\n   * Add a held usernameHash to the account object.\n   * <p>\n   * An account may only have up to MAX_USERNAME_HOLDS held usernames. If adding this hold pushes the account over this\n   * limit, a usernameHash is returned that the caller must release their hold on.\n   * <p>\n   * This only tracks the holds associated with the account, ensuring that no other account can take a held username is\n   * done via the username constraint table, and should be done transactionally with writing the updated account.\n   *\n   * @param accountToUpdate The account to update (in-place)\n   * @param newHold         A username hash to add to the account's holds\n   * @param now             The current time\n   * @return If present, an old hold that the caller should remove from the username constraint table\n   */\n  private Optional<byte[]> addToHolds(final Account accountToUpdate, final byte[] newHold, final Instant now) {\n    List<Account.UsernameHold> holds = new ArrayList<>(accountToUpdate.getUsernameHolds());\n    final Account.UsernameHold holdToAdd = new Account.UsernameHold(newHold,\n        now.plus(USERNAME_HOLD_DURATION).getEpochSecond());\n\n    // Remove any holds that are\n    // - expired\n    // - match what we're trying to add (we'll re-add it at the end of the list to refresh the ttl)\n    // - match our current username\n    holds.removeIf(hold -> hold.expirationSecs() < now.getEpochSecond()\n        || Arrays.equals(newHold, hold.usernameHash())\n        || accountToUpdate.getUsernameHash().map(curr -> Arrays.equals(curr, hold.usernameHash())).orElse(false));\n\n    // add the new hold\n    holds.add(holdToAdd);\n\n    if (holds.size() <= MAX_USERNAME_HOLDS) {\n      accountToUpdate.setUsernameHolds(holds);\n      Metrics.counter(USERNAME_HOLD_ADDED_COUNTER_NAME, \"max\", String.valueOf(false)).increment();\n      return Optional.empty();\n    } else {\n      accountToUpdate.setUsernameHolds(holds.subList(1, holds.size()));\n      Metrics.counter(USERNAME_HOLD_ADDED_COUNTER_NAME, \"max\", String.valueOf(true)).increment();\n      // Newer holds are always added to the end of the holds list, so the first hold is always the oldest hold. Note\n      // that if a duplicate hold is added, we remove it from the list and re-add it at the end, this preserves hold\n      // ordering\n      return Optional.of(holds.getFirst().usernameHash());\n    }\n  }\n\n  /**\n   * Transaction item to update the usernameConstraintTable to \"hold\" a usernameHash for an account\n   *\n   * @param holder       The account with the hold.\n   * @param usernameHash The hash to reserve for the account\n   * @param now          The current time\n   * @return A transaction item that will update the usernameConstraintTable.\n   */\n  private TransactWriteItem holdUsernameTransactItem(final UUID holder, final byte[] usernameHash, final Instant now) {\n    return TransactWriteItem.builder().put(Put.builder()\n        .tableName(usernamesConstraintTableName)\n        .item(Map.of(\n            UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),\n            UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(holder),\n            UsernameTable.ATTR_CONFIRMED, AttributeValues.fromBool(false),\n            UsernameTable.ATTR_TTL,\n            AttributeValues.fromLong(now.plus(USERNAME_HOLD_DURATION).getEpochSecond())))\n        .build()).build();\n  }\n\n  /**\n   * Transaction item to release a hold on the usernameConstraintTable\n   *\n   * @param holder                The account with the hold.\n   * @param usernameHashToRelease The hash to release for the account\n   * @param now                   The current time\n   * @return A transaction item that will update the usernameConstraintTable. The transaction will fail with a condition\n   * exception if someone else has a reservation for usernameHashToRelease\n   */\n  private TransactWriteItem releaseHoldIfAllowedTransactItem(\n      final UUID holder, final byte[] usernameHashToRelease, final Instant now) {\n    return TransactWriteItem.builder().delete(Delete.builder()\n        .tableName(usernamesConstraintTableName)\n        .key(Map.of(UsernameTable.KEY_USERNAME_HASH, AttributeValues.b(usernameHashToRelease)))\n        // we can release the hold if we own it (and it's not our confirmed username) or if no one owns it\n        .conditionExpression(\"(#aci = :aci AND #confirmed = :false) OR #ttl < :now OR attribute_not_exists(#usernameHash)\")\n        .expressionAttributeNames(Map.of(\n            \"#usernameHash\", UsernameTable.KEY_USERNAME_HASH,\n            \"#aci\", UsernameTable.ATTR_ACCOUNT_UUID,\n            \"#confirmed\", UsernameTable.ATTR_CONFIRMED,\n            \"#ttl\", UsernameTable.ATTR_TTL))\n        .expressionAttributeValues(Map.of(\n            \":aci\", AttributeValues.b(holder),\n            \":now\", AttributeValues.n(now.getEpochSecond()),\n            \":false\", AttributeValues.fromBool(false)))\n        .build()).build();\n  }\n\n  /// Confirm (set) a previously reserved username hash\n  ///\n  /// @param account to update\n  /// @param usernameHash believed to be available\n  /// @param encryptedUsername the encrypted form of the previously reserved username; used for the username link\n  ///\n  /// @throws ContestedOptimisticLockException if the account has been updated or there are concurrent updates to the\n  /// account or constraint records\n  /// @throws UsernameHashNotAvailableException if the username was taken by someone else\n  public void confirmUsernameHash(final Account account, final byte[] usernameHash, @Nullable final byte[] encryptedUsername)\n      throws ContestedOptimisticLockException, UsernameHashNotAvailableException {\n\n    final Timer.Sample sample = Timer.start();\n    if (usernameHash == null) {\n      throw new IllegalArgumentException(\"Cannot confirm a null usernameHash\");\n    }\n\n    final UUID linkHandle = pickLinkHandle(account, usernameHash);\n\n    final Optional<byte[]> maybeOriginalUsernameHash = account.getUsernameHash();\n    final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account);\n    updatedAccount.setUsernameHash(usernameHash);\n    updatedAccount.setReservedUsernameHash(null);\n    updatedAccount.setUsernameLinkDetails(encryptedUsername == null ? null : linkHandle, encryptedUsername);\n\n    final Instant now = clock.instant();\n    final Optional<byte[]> holdToRemove =\n        maybeOriginalUsernameHash.flatMap(hold -> addToHolds(updatedAccount, hold, now));\n\n    final List<TransactWriteItem> writeItems = new ArrayList<>();\n\n    // 0: add the username hash to the constraint table, wiping out the ttl if we had already reserved the hash\n    writeItems.add(TransactWriteItem.builder().put(Put.builder()\n            .tableName(usernamesConstraintTableName)\n            .item(Map.of(\n                UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(updatedAccount.getUuid()),\n                UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),\n                UsernameTable.ATTR_CONFIRMED, AttributeValues.fromBool(true)))\n            // it's not in the constraint table OR it's expired OR it was reserved by us\n            .conditionExpression(\"attribute_not_exists(#username_hash) OR #ttl < :now OR (#aci = :aci AND #confirmed = :confirmed)\")\n            .expressionAttributeNames(Map.of(\n                \"#username_hash\", UsernameTable.KEY_USERNAME_HASH,\n                \"#ttl\", UsernameTable.ATTR_TTL,\n                \"#aci\", UsernameTable.ATTR_ACCOUNT_UUID,\n                \"#confirmed\", UsernameTable.ATTR_CONFIRMED))\n            .expressionAttributeValues(Map.of(\n                \":now\", AttributeValues.fromLong(clock.instant().getEpochSecond()),\n                \":aci\", AttributeValues.fromUUID(updatedAccount.getUuid()),\n                \":confirmed\", AttributeValues.fromBool(false)))\n            .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n            .build())\n        .build());\n\n    // 1: update the account object (conditioned on the version increment)\n    writeItems.add(UpdateAccountSpec.forAccount(accountsTableName, updatedAccount).transactItem());\n\n    // 2?: Add a temporary hold for the old username to stop others from claiming it\n    maybeOriginalUsernameHash.ifPresent(originalUsernameHash ->\n        writeItems.add(holdUsernameTransactItem(updatedAccount.getUuid(), originalUsernameHash, now)));\n\n    // 3?: Adding that hold may have caused our account to exceed our maximum holds. Release an old hold\n    holdToRemove.ifPresent(oldHold ->\n        writeItems.add(releaseHoldIfAllowedTransactItem(updatedAccount.getUuid(), oldHold, now)));\n\n    try {\n      dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(writeItems).build());\n\n      account.setUsernameHash(usernameHash);\n      account.setReservedUsernameHash(null);\n      account.setUsernameLinkDetails(updatedAccount.getUsernameLinkHandle(), updatedAccount.getEncryptedUsername().orElse(null));\n      account.setUsernameHolds(updatedAccount.getUsernameHolds());\n      account.setVersion(account.getVersion() + 1);\n    } catch (final TransactionCanceledException e) {\n      if (conditionalCheckFailed(e.cancellationReasons().get(0))) {\n        throw new UsernameHashNotAvailableException();\n      } else if (conditionalCheckFailed(e.cancellationReasons().get(1)) // Account version conflict\n          // When we looked at the holds on our account, we thought we still held the corresponding username\n          // reservation. But it turned out that someone else has taken the reservation since. This means that the\n          // TTL on the hold must have just expired, so if we retry we should see that our hold is expired, and we\n          // won't try to remove it again.\n          || (e.cancellationReasons().size() > 3 && conditionalCheckFailed(e.cancellationReasons().get(3)))\n          // concurrent update on any table\n          || e.cancellationReasons().stream().anyMatch(Accounts::isTransactionConflict)) {\n        throw new ContestedOptimisticLockException();\n      } else {\n        throw e;\n      }\n    } finally {\n      sample.stop(SET_USERNAME_TIMER);\n    }\n  }\n\n  private UUID pickLinkHandle(final Account account, final byte[] usernameHash) {\n    if (account.getUsernameLinkHandle() == null) {\n      // There's no old link handle, so we can just use a randomly generated link handle\n      return UUID.randomUUID();\n    }\n\n    // Otherwise, there's an existing link handle. If this is the result of an account being re-registered, we should\n    // preserve the link handle.\n    final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder()\n            .tableName(usernamesConstraintTableName)\n            .key(Map.of(UsernameTable.KEY_USERNAME_HASH, AttributeValues.b(usernameHash)))\n            .projectionExpression(UsernameTable.ATTR_RECLAIMABLE).build());\n\n    if (response.hasItem() && AttributeValues.getBool(response.item(), UsernameTable.ATTR_RECLAIMABLE, false)) {\n      // this username reservation indicates it's a username waiting to be \"reclaimed\"\n      return account.getUsernameLinkHandle();\n    }\n\n    // There was no existing username reservation, or this was a standard \"new\" username. Either way, we should\n    // generate a new link handle.\n    return UUID.randomUUID();\n  }\n\n  /// Clear the username hash and link from the given account\n  ///\n  /// @param account to update\n  ///\n  /// @throws ContestedOptimisticLockException if there are concurrent updates to the account or username constraint\n  /// records\n  public void clearUsernameHash(final Account account) throws ContestedOptimisticLockException {\n    if (account.getUsernameHash().isEmpty()) {\n      // no username to clear\n      return;\n    }\n\n    final byte[] usernameHash = account.getUsernameHash().get();\n    final Timer.Sample sample = Timer.start();\n\n    final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account);\n    updatedAccount.setUsernameHash(null);\n    updatedAccount.setUsernameLinkDetails(null, null);\n\n    final Instant now = clock.instant();\n    final Optional<byte[]> holdToRemove = addToHolds(updatedAccount, usernameHash, now);\n\n    final List<TransactWriteItem> items = new ArrayList<>();\n\n    // 0: remove the username from the account object, conditioned on account version\n    items.add(UpdateAccountSpec.forAccount(accountsTableName, updatedAccount).transactItem());\n\n    // 1: Un-confirm our username, adding a temporary hold for the old username to stop others from claiming it\n    items.add(holdUsernameTransactItem(updatedAccount.getUuid(), usernameHash, now));\n\n    // 2?: Adding that hold may have caused our account to exceed our maximum holds. Release an old hold\n    holdToRemove.ifPresent(oldHold -> items.add(releaseHoldIfAllowedTransactItem(updatedAccount.getUuid(), oldHold, now)));\n\n    try {\n      dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(items).build());\n\n      account.setUsernameHash(null);\n      account.setUsernameLinkDetails(null, null);\n      account.setVersion(account.getVersion() + 1);\n      account.setUsernameHolds(updatedAccount.getUsernameHolds());\n    } catch (final TransactionCanceledException e) {\n      if (conditionalCheckFailed(e.cancellationReasons().get(0)) // Account version conflict\n          // When we looked at the holds on our account, we thought we still held the corresponding username\n          // reservation. But it turned out that someone else has taken the reservation since. This means that the\n          // TTL on the hold must have just expired, so if we retry we should see that our hold is expired, and we\n          // won't try to remove it again.\n          || (e.cancellationReasons().size() > 2 && conditionalCheckFailed(e.cancellationReasons().get(2)))\n          // concurrent update on any table\n          || e.cancellationReasons().stream().anyMatch(Accounts::isTransactionConflict)) {\n        throw new ContestedOptimisticLockException();\n      } else {\n        throw e;\n      }\n    } finally {\n      sample.stop(CLEAR_USERNAME_HASH_TIMER);\n    }\n  }\n\n  /**\n   * A ddb update that can be used as part of a transaction or single-item update statement.\n   */\n  record UpdateAccountSpec(\n      String tableName,\n      Map<String, AttributeValue> key,\n      Map<String, String> attrNames,\n      Map<String, AttributeValue> attrValues,\n      String updateExpression,\n      String conditionExpression) {\n    UpdateItemRequest updateItemRequest() {\n      return UpdateItemRequest.builder()\n          .tableName(tableName)\n          .key(key)\n          .updateExpression(updateExpression)\n          .conditionExpression(conditionExpression)\n          .expressionAttributeNames(attrNames)\n          .expressionAttributeValues(attrValues)\n          .build();\n    }\n\n    TransactWriteItem transactItem() {\n      return TransactWriteItem.builder().update(Update.builder()\n          .tableName(tableName)\n          .key(key)\n          .updateExpression(updateExpression)\n          .conditionExpression(conditionExpression)\n          .expressionAttributeNames(attrNames)\n          .expressionAttributeValues(attrValues)\n          .build()).build();\n    }\n\n    static UpdateAccountSpec forAccount(\n        final String accountTableName,\n        final Account account) {\n      // username, e164, and pni cannot be modified through this method\n      final Map<String, String> attrNames = new HashMap<>(Map.of(\n          \"#number\", ATTR_ACCOUNT_E164,\n          \"#data\", ATTR_ACCOUNT_DATA,\n          \"#cds\", ATTR_CANONICALLY_DISCOVERABLE,\n          \"#version\", ATTR_VERSION));\n\n      final Map<String, AttributeValue> attrValues = new HashMap<>(Map.of(\n          \":data\", accountDataAttributeValue(account),\n          \":cds\", AttributeValues.fromBool(account.isDiscoverableByPhoneNumber()),\n          \":version\", AttributeValues.fromInt(account.getVersion()),\n          \":version_increment\", AttributeValues.fromInt(1)));\n\n      final StringBuilder updateExpressionBuilder = new StringBuilder(\"SET #data = :data, #cds = :cds\");\n      if (account.getUnidentifiedAccessKey().isPresent()) {\n        // if it's present in the account, also set the uak\n        attrNames.put(\"#uak\", ATTR_UAK);\n        attrValues.put(\":uak\", AttributeValues.fromByteArray(account.getUnidentifiedAccessKey().get()));\n        updateExpressionBuilder.append(\", #uak = :uak\");\n      }\n\n      if (account.getUsernameHash().isPresent()) {\n        // if it's present in the account, also set the username hash\n        attrNames.put(\"#usernameHash\", ATTR_USERNAME_HASH);\n        attrValues.put(\":usernameHash\", AttributeValues.fromByteArray(account.getUsernameHash().get()));\n        updateExpressionBuilder.append(\", #usernameHash = :usernameHash\");\n      }\n\n      // If the account has a username/handle pair, we should add it to the top level attributes.\n      // When we remove an encryptedUsername but preserve the link (re-registration), it's possible that the account\n      // has a usernameLinkHandle but not an encrypted username. In this case there should already be a top-level\n      // usernameLink attribute.\n      if (account.getEncryptedUsername().isPresent() && account.getUsernameLinkHandle() != null) {\n        attrNames.put(\"#ul\", ATTR_USERNAME_LINK_UUID);\n        attrValues.put(\":ul\", AttributeValues.fromUUID(account.getUsernameLinkHandle()));\n        updateExpressionBuilder.append(\", #ul = :ul\");\n      }\n\n      // Some operations may remove the usernameLink or the usernameHash (re-registration, clear username link, and\n      // clear username hash). Since these also have top-level ddb attributes, we need to make sure to remove those\n      // as well.\n      final List<String> removes = new ArrayList<>();\n      if (account.getUsernameLinkHandle() == null) {\n        attrNames.put(\"#ul\", ATTR_USERNAME_LINK_UUID);\n        removes.add(\"#ul\");\n      }\n      if (account.getUsernameHash().isEmpty()) {\n        attrNames.put(\"#username_hash\", ATTR_USERNAME_HASH);\n        removes.add(\"#username_hash\");\n      }\n      if (!removes.isEmpty()) {\n        updateExpressionBuilder.append(\" REMOVE %s\".formatted(String.join(\",\", removes)));\n      }\n      updateExpressionBuilder.append(\" ADD #version :version_increment\");\n\n      return new UpdateAccountSpec(\n          accountTableName,\n          Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())),\n          attrNames,\n          attrValues,\n          updateExpressionBuilder.toString(),\n          \"attribute_exists(#number) AND #version = :version\");\n    }\n  }\n\n  public void update(final Account account) throws ContestedOptimisticLockException {\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      final UpdateItemResponse response = dynamoDbClient.updateItem(UpdateAccountSpec\n          .forAccount(accountsTableName, account)\n          .updateItemRequest());\n\n      account.setVersion(AttributeValues.getInt(response.attributes(), \"V\", account.getVersion() + 1));\n    } catch (final TransactionConflictException _) {\n      throw new ContestedOptimisticLockException();\n    } catch (final ConditionalCheckFailedException e) {\n      // the exception doesn't give details about which condition failed,\n      // but we can infer it was an optimistic locking failure if the UUID is known\n      if (getByAccountIdentifier(account.getUuid()).isPresent()) {\n        throw new ContestedOptimisticLockException();\n      } else {\n        throw e;\n      }\n    } finally {\n      sample.stop(UPDATE_TIMER);\n    }\n  }\n\n  public void updateTransactionally(final Account account, final Collection<TransactWriteItem> additionalWriteItems)\n      throws ContestedOptimisticLockException {\n\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      final List<TransactWriteItem> writeItems = new ArrayList<>(additionalWriteItems.size() + 1);\n      writeItems.add(UpdateAccountSpec.forAccount(accountsTableName, account).transactItem());\n      writeItems.addAll(additionalWriteItems);\n\n      dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder()\n          .transactItems(writeItems)\n          .build());\n\n      account.setVersion(account.getVersion() + 1);\n    } catch (final TransactionCanceledException transactionCanceledException) {\n      if (CONDITIONAL_CHECK_FAILED.equals(transactionCanceledException.cancellationReasons().getFirst().code())) {\n        throw new ContestedOptimisticLockException();\n      }\n\n      if (transactionCanceledException.cancellationReasons()\n          .stream()\n          .anyMatch(reason -> TRANSACTION_CONFLICT.equals(reason.code()))) {\n\n        throw new ContestedOptimisticLockException();\n      }\n\n      throw transactionCanceledException;\n    } finally {\n      sample.stop(UPDATE_TRANSACTIONALLY_TIMER);\n    }\n  }\n\n  public TransactWriteItem buildTransactWriteItemForLinkDevice(final String linkDeviceToken, final Duration tokenTtl) {\n    final byte[] linkDeviceTokenHash;\n\n    try {\n      linkDeviceTokenHash = MessageDigest.getInstance(\"SHA-256\").digest(linkDeviceToken.getBytes(StandardCharsets.UTF_8));\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(\"Every implementation of the Java platform is required to support the SHA-256 MessageDigest algorithm\", e);\n    }\n\n    return TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(usedLinkDeviceTokenTableName)\n            .item(Map.of(\n                KEY_LINK_DEVICE_TOKEN_HASH, AttributeValue.fromB(SdkBytes.fromByteArray(linkDeviceTokenHash)),\n                ATTR_LINK_DEVICE_TOKEN_TTL, AttributeValue.fromN(String.valueOf(clock.instant().plus(tokenTtl).getEpochSecond()))\n            ))\n            .conditionExpression(\"attribute_not_exists(#linkDeviceTokenHash)\")\n            .expressionAttributeNames(Map.of(\"#linkDeviceTokenHash\", KEY_LINK_DEVICE_TOKEN_HASH))\n            .build())\n        .build();\n  }\n\n  @Nonnull\n  public Optional<Account> getByE164(final String number) {\n    return getByIndirectLookup(\n        GET_BY_NUMBER_TIMER, phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, AttributeValues.fromString(number));\n  }\n\n  @Nonnull\n  public CompletableFuture<Optional<Account>> getByE164Async(final String number) {\n    return getByIndirectLookupAsync(\n        GET_BY_NUMBER_TIMER, phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, AttributeValues.fromString(number));\n  }\n\n  @Nonnull\n  public Optional<Account> getByPhoneNumberIdentifier(final UUID phoneNumberIdentifier) {\n    return getByIndirectLookup(\n        GET_BY_PNI_TIMER, phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier));\n  }\n\n  @Nonnull\n  public CompletableFuture<Optional<Account>> getByPhoneNumberIdentifierAsync(final UUID phoneNumberIdentifier) {\n    return getByIndirectLookupAsync(GET_BY_PNI_TIMER, phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier));\n  }\n\n  @Nonnull\n  public CompletableFuture<Optional<Account>> getByUsernameHash(final byte[] usernameHash) {\n    return getByIndirectLookupAsync(GET_BY_USERNAME_HASH_TIMER,\n        usernamesConstraintTableName,\n        UsernameTable.KEY_USERNAME_HASH,\n        AttributeValues.fromByteArray(usernameHash),\n        item -> AttributeValues.getBool(item, UsernameTable.ATTR_CONFIRMED, false) // ignore items that are reservations (not confirmed)\n    );\n  }\n\n  @Nonnull\n  public CompletableFuture<Optional<Account>> getByUsernameLinkHandle(final UUID usernameLinkHandle) {\n    final Timer.Sample sample = Timer.start();\n\n    return itemByGsiKeyAsync(accountsTableName, USERNAME_LINK_TO_UUID_INDEX, ATTR_USERNAME_LINK_UUID, AttributeValues.fromUUID(usernameLinkHandle))\n        .thenApply(maybeItem -> maybeItem.map(Accounts::fromItem))\n        .whenComplete((_, _) -> sample.stop(GET_BY_USERNAME_LINK_HANDLE_TIMER));\n  }\n\n  @Nonnull\n  public Optional<Account> getByAccountIdentifier(final UUID uuid) {\n    return requireNonNull(GET_BY_UUID_TIMER.record(() ->\n        itemByKey(accountsTableName, KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid))\n            .map(Accounts::fromItem)));\n  }\n\n  private TransactWriteItem buildPutDeletedAccount(final UUID aci, final UUID pni) {\n    return TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(deletedAccountsTableName)\n            .item(Map.of(\n                DELETED_ACCOUNTS_KEY_ACCOUNT_PNI, AttributeValues.fromString(pni.toString()),\n                DELETED_ACCOUNTS_ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(aci),\n                DELETED_ACCOUNTS_ATTR_EXPIRES, AttributeValues.fromLong(clock.instant().plus(DELETED_ACCOUNTS_TIME_TO_LIVE).getEpochSecond())))\n            .build())\n        .build();\n  }\n\n  private TransactWriteItem buildRemoveDeletedAccount(final UUID pni) {\n    return TransactWriteItem.builder()\n        .delete(Delete.builder()\n            .tableName(deletedAccountsTableName)\n            .key(Map.of(DELETED_ACCOUNTS_KEY_ACCOUNT_PNI, AttributeValues.fromString(pni.toString())))\n            .build())\n        .build();\n  }\n\n  @Nonnull\n  public CompletableFuture<Optional<Account>> getByAccountIdentifierAsync(final UUID uuid) {\n    return AsyncTimerUtil.record(GET_BY_UUID_TIMER, () -> itemByKeyAsync(accountsTableName, KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid))\n        .thenApply(maybeItem -> maybeItem.map(Accounts::fromItem)))\n        .toCompletableFuture();\n  }\n\n  public Optional<UUID> findRecentlyDeletedAccountIdentifier(final UUID phoneNumberIdentifier) {\n    final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder()\n        .tableName(deletedAccountsTableName)\n        .consistentRead(true)\n        .key(Map.of(DELETED_ACCOUNTS_KEY_ACCOUNT_PNI, AttributeValues.fromString(phoneNumberIdentifier.toString())))\n        .build());\n\n    return Optional.ofNullable(AttributeValues.getUUID(response.item(), DELETED_ACCOUNTS_ATTR_ACCOUNT_UUID, null));\n  }\n\n  public Optional<UUID> findRecentlyDeletedPhoneNumberIdentifier(final UUID uuid) {\n    final QueryResponse response = dynamoDbClient.query(QueryRequest.builder()\n        .tableName(deletedAccountsTableName)\n        .indexName(DELETED_ACCOUNTS_UUID_TO_PNI_INDEX_NAME)\n        .keyConditionExpression(\"#uuid = :uuid\")\n        .projectionExpression(\"#pni\")\n        .expressionAttributeNames(Map.of(\"#uuid\", DELETED_ACCOUNTS_ATTR_ACCOUNT_UUID,\n            \"#pni\", DELETED_ACCOUNTS_KEY_ACCOUNT_PNI))\n        .expressionAttributeValues(Map.of(\":uuid\", AttributeValues.fromUUID(uuid))).build());\n\n    if (response.count() == 0) {\n      return Optional.empty();\n    }\n\n    return response.items().stream()\n        .map(item -> item.get(DELETED_ACCOUNTS_KEY_ACCOUNT_PNI).s())\n        .filter(e164OrPni -> !e164OrPni.startsWith(\"+\"))\n        .findFirst()\n        .map(UUID::fromString);\n  }\n\n  public void delete(final UUID uuid, final List<TransactWriteItem> additionalWriteItems) {\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      final Account account;\n      {\n        final Optional<Account> maybeAccount = getByAccountIdentifier(uuid);\n\n        if (maybeAccount.isEmpty()) {\n          return;\n        }\n\n        account = maybeAccount.get();\n      }\n\n      final List<TransactWriteItem> transactWriteItems = new ArrayList<>(List.of(\n          buildDelete(phoneNumberConstraintTableName, ATTR_ACCOUNT_E164, account.getNumber()),\n          buildDelete(accountsTableName, KEY_ACCOUNT_UUID, uuid),\n          buildDelete(phoneNumberIdentifierConstraintTableName, ATTR_PNI_UUID, account.getPhoneNumberIdentifier()),\n          buildPutDeletedAccount(uuid, account.getPhoneNumberIdentifier())\n      ));\n\n      account.getUsernameHash().ifPresent(usernameHash -> transactWriteItems.add(\n          buildDelete(usernamesConstraintTableName, UsernameTable.KEY_USERNAME_HASH, usernameHash)));\n\n      transactWriteItems.addAll(additionalWriteItems);\n\n      dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder()\n          .transactItems(transactWriteItems)\n          .build());\n    } finally {\n      sample.stop(DELETE_TIMER);\n    }\n  }\n\n  Flux<Account> getAll(final int segments, final Scheduler scheduler) {\n    if (segments < 1) {\n      throw new IllegalArgumentException(\"Total number of segments must be positive\");\n    }\n\n    return Flux.range(0, segments)\n        .parallel()\n        .runOn(scheduler)\n        .flatMap(segment -> {\n          final ScanPublisher scanPublisher = dynamoDbAsyncClient.scanPaginator(ScanRequest.builder()\n              .tableName(accountsTableName)\n              .consistentRead(true)\n              .segment(segment)\n              .totalSegments(segments)\n              .build());\n\n          return Flux.from(scanPublisher.items()).map(Accounts::fromItem);\n        })\n        .sequential();\n  }\n\n  Flux<UUID> getAllAccountIdentifiers(final int segments, final Scheduler scheduler) {\n    if (segments < 1) {\n      throw new IllegalArgumentException(\"Total number of segments must be positive\");\n    }\n\n    return Flux.range(0, segments)\n        .parallel()\n        .runOn(scheduler)\n        .flatMap(segment -> dynamoDbAsyncClient.scanPaginator(ScanRequest.builder()\n                .tableName(accountsTableName)\n                .consistentRead(false)\n                .segment(segment)\n                .totalSegments(segments)\n                .projectionExpression(KEY_ACCOUNT_UUID)\n                .build())\n            .items()\n            .map(item -> AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, null)))\n        .sequential();\n  }\n\n  @Nonnull\n  private Optional<Account> getByIndirectLookup(\n      final Timer timer,\n      final String tableName,\n      final String keyName,\n      final AttributeValue keyValue) {\n    return getByIndirectLookup(timer, tableName, keyName, keyValue, _ -> true);\n  }\n\n  @Nonnull\n  private CompletableFuture<Optional<Account>> getByIndirectLookupAsync(\n      final Timer timer,\n      final String tableName,\n      final String keyName,\n      final AttributeValue keyValue) {\n\n    return getByIndirectLookupAsync(timer, tableName, keyName, keyValue, _ -> true);\n  }\n\n  @Nonnull\n  private Optional<Account> getByIndirectLookup(\n      final Timer timer,\n      final String tableName,\n      final String keyName,\n      final AttributeValue keyValue,\n      final Predicate<? super Map<String, AttributeValue>> predicate) {\n\n    return requireNonNull(timer.record(() -> itemByKey(tableName, keyName, keyValue)\n        .filter(predicate)\n        .map(item -> item.get(KEY_ACCOUNT_UUID))\n        .flatMap(uuid -> itemByKey(accountsTableName, KEY_ACCOUNT_UUID, uuid))\n        .map(Accounts::fromItem)));\n  }\n\n  @Nonnull\n  private CompletableFuture<Optional<Account>> getByIndirectLookupAsync(\n      final Timer timer,\n      final String tableName,\n      final String keyName,\n      final AttributeValue keyValue,\n      final Predicate<? super Map<String, AttributeValue>> predicate) {\n\n    return AsyncTimerUtil.record(timer, () -> itemByKeyAsync(tableName, keyName, keyValue)\n        .thenCompose(maybeItem -> maybeItem\n            .filter(predicate)\n            .map(item -> item.get(KEY_ACCOUNT_UUID))\n            .map(uuid -> itemByKeyAsync(accountsTableName, KEY_ACCOUNT_UUID, uuid)\n                .thenApply(maybeAccountItem -> maybeAccountItem.map(Accounts::fromItem)))\n            .orElse(CompletableFuture.completedFuture(Optional.empty()))))\n        .toCompletableFuture();\n  }\n\n  @Nonnull\n  private Optional<Map<String, AttributeValue>> itemByKey(final String table, final String keyName, final AttributeValue keyValue) {\n    final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(keyName, keyValue))\n        .consistentRead(true)\n        .build());\n    return Optional.ofNullable(response.item()).filter(m -> !m.isEmpty());\n  }\n\n  @Nonnull\n  private CompletableFuture<Optional<Map<String, AttributeValue>>> itemByKeyAsync(final String table, final String keyName, final AttributeValue keyValue) {\n    return dynamoDbAsyncClient.getItem(GetItemRequest.builder()\n            .tableName(table)\n            .key(Map.of(keyName, keyValue))\n            .consistentRead(true)\n            .build())\n        .thenApply(response -> Optional.ofNullable(response.item()).filter(item -> !item.isEmpty()));\n  }\n\n  @Nonnull\n  private CompletableFuture<Optional<Map<String, AttributeValue>>> itemByGsiKeyAsync(final String table, final String indexName, final String keyName, final AttributeValue keyValue) {\n    return dynamoDbAsyncClient.query(QueryRequest.builder()\n        .tableName(table)\n        .indexName(indexName)\n        .keyConditionExpression(\"#gsiKey = :gsiValue\")\n        .projectionExpression(\"#uuid\")\n        .expressionAttributeNames(Map.of(\n            \"#gsiKey\", keyName,\n            \"#uuid\", KEY_ACCOUNT_UUID))\n        .expressionAttributeValues(Map.of(\n            \":gsiValue\", keyValue))\n        .build())\n        .thenCompose(response -> {\n          if (response.count() == 0) {\n            return CompletableFuture.completedFuture(Optional.empty());\n          }\n\n          if (response.count() > 1) {\n            return CompletableFuture.failedFuture(new IllegalStateException(\n                \"More than one row located for GSI [%s], key-value pair [%s, %s]\"\n                    .formatted(indexName, keyName, keyValue)));\n          }\n\n          final AttributeValue primaryKeyValue = response.items().getFirst().get(KEY_ACCOUNT_UUID);\n          return itemByKeyAsync(table, KEY_ACCOUNT_UUID, primaryKeyValue);\n        });\n  }\n\n  @Nonnull\n  private TransactWriteItem buildAccountPut(\n      final Account account,\n      final AttributeValue uuidAttr,\n      final AttributeValue numberAttr,\n      final AttributeValue pniUuidAttr) {\n\n    final Map<String, AttributeValue> item = new HashMap<>(Map.of(\n        KEY_ACCOUNT_UUID, uuidAttr,\n        ATTR_ACCOUNT_E164, numberAttr,\n        ATTR_PNI_UUID, pniUuidAttr,\n        ATTR_ACCOUNT_DATA, accountDataAttributeValue(account),\n        ATTR_VERSION, AttributeValues.fromInt(account.getVersion()),\n        ATTR_CANONICALLY_DISCOVERABLE, AttributeValues.fromBool(account.isDiscoverableByPhoneNumber())));\n\n    // Add the UAK if it's in the account\n    account.getUnidentifiedAccessKey()\n        .map(AttributeValues::fromByteArray)\n        .ifPresent(uak -> item.put(ATTR_UAK, uak));\n\n    return TransactWriteItem.builder()\n        .put(Put.builder()\n            .conditionExpression(\"attribute_not_exists(#pni) OR #pni = :pni\")\n            .expressionAttributeNames(Map.of(\"#pni\", ATTR_PNI_UUID))\n            .expressionAttributeValues(Map.of(\":pni\", pniUuidAttr))\n            .tableName(accountsTableName)\n            .item(item)\n            .build())\n        .build();\n  }\n\n  @Nonnull\n  private static TransactWriteItem buildConstraintTablePutIfAbsent(\n      final String tableName,\n      final AttributeValue uuidAttr,\n      final String keyName,\n      final AttributeValue keyValue) {\n    return TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(tableName)\n            .item(Map.of(\n                keyName, keyValue,\n                KEY_ACCOUNT_UUID, uuidAttr))\n            .conditionExpression(\n                \"attribute_not_exists(#key) OR #uuid = :uuid\")\n            .expressionAttributeNames(Map.of(\n                \"#key\", keyName,\n                \"#uuid\", KEY_ACCOUNT_UUID))\n            .expressionAttributeValues(Map.of(\n                \":uuid\", uuidAttr))\n            .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n            .build())\n        .build();\n  }\n\n  @Nonnull\n  private static TransactWriteItem buildConstraintTablePut(\n      final String tableName,\n      final AttributeValue uuidAttr,\n      final String keyName,\n      final AttributeValue keyValue) {\n    return TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(tableName)\n            .item(Map.of(\n                keyName, keyValue,\n                KEY_ACCOUNT_UUID, uuidAttr))\n            .conditionExpression(\n                \"attribute_not_exists(#key)\")\n            .expressionAttributeNames(Map.of(\n                \"#key\", keyName))\n            .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n            .build())\n        .build();\n  }\n\n  @Nonnull\n  private static TransactWriteItem buildDelete(final String tableName, final String keyName, final String keyValue) {\n    return buildDelete(tableName, keyName, AttributeValues.fromString(keyValue));\n  }\n\n  @Nonnull\n  private static TransactWriteItem buildDelete(final String tableName, final String keyName, final byte[] keyValue) {\n    return buildDelete(tableName, keyName, AttributeValues.fromByteArray(keyValue));\n  }\n\n  @Nonnull\n  private static TransactWriteItem buildDelete(final String tableName, final String keyName, final UUID keyValue) {\n    return buildDelete(tableName, keyName, AttributeValues.fromUUID(keyValue));\n  }\n\n  @Nonnull\n  private static TransactWriteItem buildDelete(final String tableName, final String keyName, final AttributeValue keyValue) {\n    return TransactWriteItem.builder()\n        .delete(Delete.builder()\n            .tableName(tableName)\n            .key(Map.of(keyName, keyValue))\n            .build())\n        .build();\n  }\n\n  CompletableFuture<Void> regenerateConstraints(final Account account) {\n    final List<CompletableFuture<?>> constraintFutures = new ArrayList<>();\n\n    constraintFutures.add(writeConstraint(phoneNumberConstraintTableName,\n        account.getIdentifier(IdentityType.ACI),\n        ATTR_ACCOUNT_E164,\n        AttributeValues.fromString(account.getNumber())));\n\n    constraintFutures.add(writeConstraint(phoneNumberIdentifierConstraintTableName,\n        account.getIdentifier(IdentityType.ACI),\n        ATTR_PNI_UUID,\n        AttributeValues.fromUUID(account.getPhoneNumberIdentifier())));\n\n    account.getUsernameHash().ifPresent(usernameHash ->\n        constraintFutures.add(writeUsernameConstraint(account.getIdentifier(IdentityType.ACI),\n            usernameHash,\n            Optional.empty())));\n\n    account.getUsernameHolds().forEach(usernameHold ->\n        constraintFutures.add(writeUsernameConstraint(account.getIdentifier(IdentityType.ACI),\n            usernameHold.usernameHash(),\n            Optional.of(Instant.ofEpochSecond(usernameHold.expirationSecs())))));\n\n    return CompletableFuture.allOf(constraintFutures.toArray(CompletableFuture[]::new));\n  }\n\n  private CompletableFuture<Void> writeConstraint(\n      final String tableName,\n      final UUID accountIdentifier,\n      final String keyName,\n      final AttributeValue keyValue) {\n\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .item(Map.of(\n                keyName, keyValue,\n                KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountIdentifier)))\n        .build())\n        .thenRun(Util.NOOP);\n  }\n\n  private CompletableFuture<Void> writeUsernameConstraint(\n      final UUID accountIdentifier,\n      final byte[] usernameHash,\n      final Optional<Instant> maybeExpiration) {\n\n    final Map<String, AttributeValue> item = new HashMap<>(Map.of(\n        UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash),\n        UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(accountIdentifier),\n        UsernameTable.ATTR_CONFIRMED, AttributeValues.fromBool(maybeExpiration.isEmpty())\n    ));\n\n    maybeExpiration.ifPresent(expiration ->\n        item.put(UsernameTable.ATTR_TTL, AttributeValues.fromLong(expiration.getEpochSecond())));\n\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(usernamesConstraintTableName)\n            .item(item)\n        .build())\n        .thenRun(Util.NOOP);\n  }\n\n  @Nonnull\n  private static String extractCancellationReasonCodes(final TransactionCanceledException exception) {\n    return exception.cancellationReasons().stream()\n        .map(CancellationReason::code)\n        .collect(Collectors.joining(\", \"));\n  }\n\n  @VisibleForTesting\n  @Nonnull\n  static Account fromItem(final Map<String, AttributeValue> item) {\n    if (!item.containsKey(ATTR_ACCOUNT_DATA)\n        || !item.containsKey(ATTR_ACCOUNT_E164)\n        || !item.containsKey(KEY_ACCOUNT_UUID)\n        || !item.containsKey(ATTR_CANONICALLY_DISCOVERABLE)) {\n      throw new RuntimeException(\"item missing values\");\n    }\n    try {\n      final Account account = SystemMapper.jsonMapper().readValue(item.get(ATTR_ACCOUNT_DATA).b().asByteArray(), Account.class);\n\n      final UUID accountIdentifier = UUIDUtil.fromByteBuffer(item.get(KEY_ACCOUNT_UUID).b().asByteBuffer());\n      final UUID phoneNumberIdentifierFromAttribute = AttributeValues.getUUID(item, ATTR_PNI_UUID, null);\n\n      if (account.getPhoneNumberIdentifier() == null || phoneNumberIdentifierFromAttribute == null ||\n          !Objects.equals(account.getPhoneNumberIdentifier(), phoneNumberIdentifierFromAttribute)) {\n\n        log.warn(\"Missing or mismatched PNIs for account {}. From JSON: {}; from attribute: {}\",\n            accountIdentifier, account.getPhoneNumberIdentifier(), phoneNumberIdentifierFromAttribute);\n      }\n\n      account.setNumber(item.get(ATTR_ACCOUNT_E164).s(), phoneNumberIdentifierFromAttribute);\n      account.setUuid(accountIdentifier);\n      account.setUsernameHash(AttributeValues.getByteArray(item, ATTR_USERNAME_HASH, null));\n      account.setUsernameLinkHandle(AttributeValues.getUUID(item, ATTR_USERNAME_LINK_UUID, null));\n      account.setVersion(Integer.parseInt(item.get(ATTR_VERSION).n()));\n\n      return account;\n\n    } catch (final IOException e) {\n      throw new RuntimeException(\"Could not read stored account data\", e);\n    }\n  }\n\n  private static AttributeValue accountDataAttributeValue(final Account account) {\n    try {\n      return AttributeValues.fromByteArray(ACCOUNT_DDB_JSON_WRITER.writeValueAsBytes(account));\n    } catch (JsonProcessingException e) {\n      throw new IllegalArgumentException(e);\n    }\n  }\n\n  private static boolean conditionalCheckFailed(final CancellationReason reason) {\n    return CONDITIONAL_CHECK_FAILED.equals(reason.code());\n  }\n\n  private static boolean isTransactionConflict(final CancellationReason reason) {\n    return TRANSACTION_CONFLICT.equals(reason.code());\n  }\n\n  private static String redactPhoneNumber(final String phoneNumber) {\n    final StringBuilder sb = new StringBuilder();\n    sb.append(\"+\");\n    sb.append(Util.getCountryCode(phoneNumber));\n    sb.append(\"???\");\n    sb.append(StringUtils.length(phoneNumber) < 3\n        ? \"\"\n        : phoneNumber.substring(phoneNumber.length() - 2));\n    return sb.toString();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/AccountsManager.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\n\nimport static java.util.Objects.requireNonNull;\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectWriter;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.base.Preconditions;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.SetArgs;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport io.lettuce.core.pubsub.RedisPubSubAdapter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.Key;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collection;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Queue;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.BiConsumer;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\nimport java.util.function.Supplier;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport org.apache.commons.lang3.StringUtils;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevices;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.DeviceInfo;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.RestoreAccountRequest;\nimport org.whispersystems.textsecuregcm.entities.TransferArchiveResult;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubConnection;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.RegistrationIdValidator;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.ThrowingConsumer;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Scheduler;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;\n\npublic class AccountsManager extends RedisPubSubAdapter<String, String> implements Managed {\n\n  private static final Timer createTimer = Metrics.timer(name(AccountsManager.class, \"create\"));\n  private static final Timer updateTimer = Metrics.timer(name(AccountsManager.class, \"update\"));\n  private static final Timer getByNumberTimer = Metrics.timer(name(AccountsManager.class, \"getByNumber\"));\n  private static final Timer getByUsernameHashTimer = Metrics.timer(name(AccountsManager.class, \"getByUsernameHash\"));\n  private static final Timer getByUsernameLinkHandleTimer = Metrics.timer(name(AccountsManager.class, \"getByUsernameLinkHandle\"));\n  private static final Timer getByUuidTimer = Metrics.timer(name(AccountsManager.class, \"getByUuid\"));\n  private static final Timer deleteTimer = Metrics.timer(name(AccountsManager.class, \"delete\"));\n\n  private static final Timer redisSetTimer = Metrics.timer(name(AccountsManager.class, \"redisSet\"));\n  private static final Timer redisPniGetTimer = Metrics.timer(name(AccountsManager.class, \"redisPniGet\"));\n  private static final Timer redisUuidGetTimer = Metrics.timer(name(AccountsManager.class, \"redisUuidGet\"));\n  private static final Timer redisDeleteTimer = Metrics.timer(name(AccountsManager.class, \"redisDelete\"));\n\n  private static final String CREATE_COUNTER_NAME = name(AccountsManager.class, \"createCounter\");\n  private static final String DELETE_COUNTER_NAME = name(AccountsManager.class, \"deleteCounter\");\n  private static final String COUNTRY_CODE_TAG_NAME = \"country\";\n  private static final String DELETION_REASON_TAG_NAME = \"reason\";\n  private static final String REGISTRATION_ID_BASED_TRANSFER_ARCHIVE_KEY_COUNTER_NAME =\n      name(AccountsManager.class, \"registrationIdRedisKeyCounter\");\n\n  private static final String RETRY_NAME = ResilienceUtil.name(AccountsManager.class);\n\n  private static final Duration SUBSCRIBE_RETRY_DELAY = Duration.ofSeconds(5);\n\n  private static final Logger logger = LoggerFactory.getLogger(AccountsManager.class);\n\n  private final Accounts accounts;\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers;\n  private final FaultTolerantRedisClusterClient cacheCluster;\n  private final FaultTolerantRedisClient pubSubRedisClient;\n  private final AccountLockManager accountLockManager;\n  private final KeysManager keysManager;\n  private final MessagesManager messagesManager;\n  private final ProfilesManager profilesManager;\n  private final SecureStorageClient secureStorageClient;\n  private final SecureValueRecoveryClient secureValueRecovery2Client;\n  private final DisconnectionRequestManager disconnectionRequestManager;\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n  private final Executor accountLockExecutor;\n  private final ScheduledExecutorService messagesPollExecutor;\n  private final ScheduledExecutorService retryExecutor;\n  private final Clock clock;\n\n  private final Key verificationTokenKey;\n\n  private final FaultTolerantPubSubConnection<String, String> pubSubConnection;\n\n  private final Map<String, CompletableFuture<Optional<DeviceInfo>>> waitForDeviceFuturesByTokenIdentifier =\n      new ConcurrentHashMap<>();\n\n  private final Map<DeviceIdentifier, CompletableFuture<Optional<TransferArchiveResult>>> waitForTransferArchiveFuturesByDeviceIdentifier =\n      new ConcurrentHashMap<>();\n\n  private final Map<String, CompletableFuture<Optional<RestoreAccountRequest>>> waitForRestoreAccountRequestFuturesByToken =\n      new ConcurrentHashMap<>();\n\n  private static final int SHA256_HASH_LENGTH = getSha256MessageDigest().getDigestLength();\n\n  private static final Duration RECENTLY_ADDED_DEVICE_TTL = Duration.ofHours(1);\n  private static final String LINKED_DEVICE_PREFIX = \"linked_device::\";\n  private static final String LINKED_DEVICE_KEYSPACE_PATTERN = \"__keyspace@0__:\" + LINKED_DEVICE_PREFIX + \"*\";\n\n  private static final Duration RECENTLY_ADDED_TRANSFER_ARCHIVE_TTL = Duration.ofHours(1);\n  private static final String TRANSFER_ARCHIVE_PREFIX = \"transfer_archive::\";\n  private static final String TRANSFER_ARCHIVE_KEYSPACE_PATTERN = \"__keyspace@0__:\" + TRANSFER_ARCHIVE_PREFIX + \"*\";\n  private static final String TRANSFER_ARCHIVE_REGISTRATION_ID_PATTERN = \"registrationId\";\n\n  private static final Duration RESTORE_ACCOUNT_REQUEST_TTL = Duration.ofHours(1);\n  private static final String RESTORE_ACCOUNT_REQUEST_PREFIX = \"restore_account::\";\n  private static final String RESTORE_ACCOUNT_REQUEST_KEYSPACE_PATTERN = \"__keyspace@0__:\" + RESTORE_ACCOUNT_REQUEST_PREFIX + \"*\";\n\n  private static final ObjectWriter ACCOUNT_REDIS_JSON_WRITER = SystemMapper.jsonMapper()\n      .writer(SystemMapper.excludingField(Account.class, List.of(\"uuid\")));\n\n  private static final Duration MESSAGE_POLL_INTERVAL = Duration.ofSeconds(1);\n\n  // An account that's used at least daily will get reset in the cache at least once per day when its \"last seen\"\n  // timestamp updates; expiring entries after two days will help clear out \"zombie\" cache entries that are read\n  // frequently (e.g. the account is in an active group and receives messages frequently), but aren't actively used by\n  // the owner.\n  private static final long CACHE_TTL_SECONDS = Duration.ofDays(2).toSeconds();\n\n  private static final Duration USERNAME_HASH_RESERVATION_TTL_MINUTES = Duration.ofMinutes(5);\n\n  private static final int MAX_UPDATE_ATTEMPTS = 10;\n\n  @VisibleForTesting\n  static final Duration LINK_DEVICE_TOKEN_EXPIRATION_DURATION = Duration.ofMinutes(10);\n\n  @VisibleForTesting\n  static final String LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM = \"HmacSHA256\";\n\n  public enum DeletionReason {\n    ADMIN_DELETED(\"admin\"),\n    EXPIRED      (\"expired\"),\n    USER_REQUEST (\"userRequest\");\n\n    private final String tagValue;\n\n    DeletionReason(final String tagValue) {\n      this.tagValue = tagValue;\n    }\n  }\n\n  private record DeviceIdentifier(UUID accountIdentifier, byte deviceId,\n                                  int registrationId) {\n  }\n\n  public AccountsManager(final Accounts accounts,\n      final PhoneNumberIdentifiers phoneNumberIdentifiers,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final FaultTolerantRedisClient pubSubRedisClient,\n      final AccountLockManager accountLockManager,\n      final KeysManager keysManager,\n      final MessagesManager messagesManager,\n      final ProfilesManager profilesManager,\n      final SecureStorageClient secureStorageClient,\n      final SecureValueRecoveryClient secureValueRecovery2Client,\n      final DisconnectionRequestManager disconnectionRequestManager,\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n      final Executor accountLockExecutor,\n      final ScheduledExecutorService messagesPollExecutor, final ScheduledExecutorService retryExecutor,\n      final Clock clock,\n      final byte[] linkDeviceSecret) {\n    this.accounts = accounts;\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n    this.cacheCluster = cacheCluster;\n    this.pubSubRedisClient = pubSubRedisClient;\n    this.accountLockManager = accountLockManager;\n    this.keysManager = keysManager;\n    this.messagesManager = messagesManager;\n    this.profilesManager = profilesManager;\n    this.secureStorageClient = secureStorageClient;\n    this.secureValueRecovery2Client = secureValueRecovery2Client;\n    this.disconnectionRequestManager = disconnectionRequestManager;\n    this.registrationRecoveryPasswordsManager = requireNonNull(registrationRecoveryPasswordsManager);\n    this.accountLockExecutor = accountLockExecutor;\n    this.messagesPollExecutor = messagesPollExecutor;\n    this.retryExecutor = retryExecutor;\n    this.clock = requireNonNull(clock);\n\n    this.verificationTokenKey = new SecretKeySpec(linkDeviceSecret, LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM);\n\n    // Fail fast: reject bad keys\n    try {\n      getInitializedMac(verificationTokenKey);\n    } catch (final InvalidKeyException e) {\n      throw new IllegalArgumentException(e);\n    }\n\n    this.pubSubConnection = pubSubRedisClient.createPubSubConnection();\n  }\n\n  @Override\n  public void start() {\n    pubSubConnection.usePubSubConnection(connection -> {\n      connection.addListener(this);\n\n      boolean subscribed = false;\n\n      // Loop indefinitely until we establish a subscription. We don't want to fail immediately if there's a temporary\n      // Redis connectivity issue, since that would derail the whole startup process and likely lead to unnecessary pod\n      // churn, which might make things worse. If we never establish a connection, readiness probes will eventually fail\n      // and terminate the pods.\n      do {\n        try {\n          connection.sync().psubscribe(LINKED_DEVICE_KEYSPACE_PATTERN, TRANSFER_ARCHIVE_KEYSPACE_PATTERN,\n              RESTORE_ACCOUNT_REQUEST_KEYSPACE_PATTERN);\n\n          subscribed = true;\n        } catch (final RedisCommandTimeoutException e) {\n          try {\n            Thread.sleep(SUBSCRIBE_RETRY_DELAY);\n          } catch (final InterruptedException ex) {\n            throw new RuntimeException(ex);\n          }\n        }\n      } while (!subscribed);\n    });\n  }\n\n  @Override\n  public void stop() {\n    pubSubConnection.usePubSubConnection(connection -> {\n      connection.sync().punsubscribe();\n      connection.removeListener(this);\n    });\n  }\n\n  public Account create(final String number,\n      final AccountAttributes accountAttributes,\n      final List<AccountBadge> accountBadges,\n      final IdentityKey aciIdentityKey,\n      final IdentityKey pniIdentityKey,\n      final DeviceSpec primaryDeviceSpec,\n      @Nullable final String userAgent) throws InterruptedException {\n\n    final UUID pni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join();\n\n    return createTimer.record(() -> {\n      try {\n        return accountLockManager.withLock(Set.of(pni),\n            () -> create(number, pni, accountAttributes, accountBadges, aciIdentityKey, pniIdentityKey, primaryDeviceSpec, userAgent), accountLockExecutor);\n      } catch (final RuntimeException e) {\n        logger.error(\"Unexpected exception while creating account\", e);\n        throw e;\n      }\n    });\n  }\n\n  private Account create(final String number,\n      final UUID pni,\n      final AccountAttributes accountAttributes,\n      final List<AccountBadge> accountBadges,\n      final IdentityKey aciIdentityKey,\n      final IdentityKey pniIdentityKey,\n      final DeviceSpec primaryDeviceSpec,\n      @Nullable final String userAgent) {\n\n    final Account account = new Account();\n    final Optional<UUID> maybeRecentlyDeletedAccountIdentifier =\n        accounts.findRecentlyDeletedAccountIdentifier(pni);\n\n    // Reuse the ACI from any recently-deleted account with this number to cover cases where somebody is\n    // re-registering.\n    account.setUuid(maybeRecentlyDeletedAccountIdentifier.orElseGet(UUID::randomUUID));\n    account.setNumber(number, pni);\n    account.setIdentityKey(aciIdentityKey);\n    account.setPhoneNumberIdentityKey(pniIdentityKey);\n    account.addDevice(primaryDeviceSpec.toDevice(Device.PRIMARY_ID, clock, aciIdentityKey));\n    account.setRegistrationLockFromAttributes(accountAttributes);\n    account.setUnidentifiedAccessKey(accountAttributes.getUnidentifiedAccessKey());\n    account.setUnrestrictedUnidentifiedAccess(accountAttributes.isUnrestrictedUnidentifiedAccess());\n    account.setDiscoverableByPhoneNumber(accountAttributes.isDiscoverableByPhoneNumber());\n    account.setBadges(clock, accountBadges);\n\n    String accountCreationType = maybeRecentlyDeletedAccountIdentifier.isPresent() ? \"recently-deleted\" : \"new\";\n\n    final String pushTokenType;\n\n    if (primaryDeviceSpec.apnRegistrationId().isPresent()) {\n      pushTokenType = \"apns\";\n    } else if (primaryDeviceSpec.gcmRegistrationId().isPresent()) {\n      pushTokenType = \"fcm\";\n    } else {\n      pushTokenType = \"none\";\n    }\n\n    String previousPushTokenType = null;\n\n    try {\n      accounts.create(account, keysManager.buildWriteItemsForNewDevice(account.getIdentifier(IdentityType.ACI),\n          account.getIdentifier(IdentityType.PNI),\n          Device.PRIMARY_ID,\n          primaryDeviceSpec.aciSignedPreKey(),\n          primaryDeviceSpec.pniSignedPreKey(),\n          primaryDeviceSpec.aciPqLastResortPreKey(),\n          primaryDeviceSpec.pniPqLastResortPreKey()));\n    } catch (final AccountAlreadyExistsException e) {\n      accountCreationType = \"re-registration\";\n\n      if (StringUtils.isNotBlank(e.getExistingAccount().getPrimaryDevice().getApnId())) {\n        previousPushTokenType = \"apns\";\n      } else if (StringUtils.isNotBlank(e.getExistingAccount().getPrimaryDevice().getGcmId())) {\n        previousPushTokenType = \"fcm\";\n      } else {\n        previousPushTokenType = \"none\";\n      }\n\n      final UUID aci = e.getExistingAccount().getIdentifier(IdentityType.ACI);\n      account.setUuid(aci);\n\n      final List<TransactWriteItem> additionalWriteItems = Stream.concat(\n              keysManager.buildWriteItemsForNewDevice(account.getIdentifier(IdentityType.ACI),\n                  account.getIdentifier(IdentityType.PNI),\n                  Device.PRIMARY_ID,\n                  primaryDeviceSpec.aciSignedPreKey(),\n                  primaryDeviceSpec.pniSignedPreKey(),\n                  primaryDeviceSpec.aciPqLastResortPreKey(),\n                  primaryDeviceSpec.pniPqLastResortPreKey()).stream(),\n              e.getExistingAccount().getDevices()\n                  .stream()\n                  .map(Device::getId)\n                  // No need to clear the keys for the primary device since we'll just overwrite them in the same\n                  // transaction anyhow\n                  .filter(existingDeviceId -> existingDeviceId != Device.PRIMARY_ID)\n                  .flatMap(existingDeviceId ->\n                      keysManager.buildWriteItemsForRemovedDevice(aci, pni, existingDeviceId).stream()))\n          .toList();\n\n      CompletableFuture.allOf(\n              keysManager.deleteSingleUsePreKeys(aci),\n              keysManager.deleteSingleUsePreKeys(pni),\n              messagesManager.clear(aci),\n              profilesManager.deleteAll(aci, false))\n          .thenCompose(ignored -> disconnectionRequestManager.requestDisconnection(e.getExistingAccount()))\n          .thenCompose(ignored -> accounts.reclaimAccount(e.getExistingAccount(), account, additionalWriteItems))\n          .thenCompose(ignored -> {\n            // We should have cleared all messages before overwriting the old account, but more may have arrived\n            // while we were working. Similarly, the old account holder could have added keys or profiles. We'll\n            // largely repeat the cleanup process after creating the account to make sure we really REALLY got\n            // everything.\n            //\n            // We exclude the primary device's repeated-use keys from deletion because new keys were provided as\n            // part of the account creation process, and we don't want to delete the keys that just got added.\n            return CompletableFuture.allOf(keysManager.deleteSingleUsePreKeys(aci),\n                keysManager.deleteSingleUsePreKeys(pni),\n                messagesManager.clear(aci),\n                profilesManager.deleteAll(aci, false));\n          })\n          .join();\n    }\n\n    redisSet(account);\n\n    final boolean rrpCreated = accountAttributes.recoveryPassword().map(registrationRecoveryPassword ->\n            registrationRecoveryPasswordsManager\n                .store(account.getIdentifier(IdentityType.PNI), registrationRecoveryPassword)\n                .join())\n        .orElse(false);\n\n\n    Tags tags = Tags.of(UserAgentTagUtil.getPlatformTag(userAgent),\n        Tag.of(\"type\", accountCreationType),\n        Tag.of(\"hasPushToken\", String.valueOf(\n            primaryDeviceSpec.apnRegistrationId().isPresent() || primaryDeviceSpec.gcmRegistrationId()\n                .isPresent())),\n        Tag.of(\"pushTokenType\", pushTokenType),\n        Tag.of(\"hasRecoveryPassword\", String.valueOf(accountAttributes.recoveryPassword().isPresent())));\n\n    if (StringUtils.isNotBlank(previousPushTokenType)) {\n      tags = tags.and(Tag.of(\"previousPushTokenType\", previousPushTokenType));\n    }\n    if (accountAttributes.recoveryPassword().isPresent()) {\n      tags = tags.and(Tag.of(\"recoveryPasswordOutcome\", rrpCreated ? \"created\" : \"updated\"));\n    }\n    Metrics.counter(CREATE_COUNTER_NAME, tags).increment();\n    return account;\n  }\n\n  public Pair<Account, Device> addDevice(final Account account, final DeviceSpec deviceSpec, final String linkDeviceToken)\n      throws LinkDeviceTokenAlreadyUsedException {\n\n    return accountLockManager.withLock(Set.of(account.getPhoneNumberIdentifier()),\n        () -> addDevice(account.getIdentifier(IdentityType.ACI), deviceSpec, linkDeviceToken, MAX_UPDATE_ATTEMPTS),\n        accountLockExecutor);\n  }\n\n  private Pair<Account, Device> addDevice(final UUID accountIdentifier, final DeviceSpec deviceSpec, final String linkDeviceToken, final int retries)\n      throws LinkDeviceTokenAlreadyUsedException {\n    final Account account = accounts.getByAccountIdentifier(accountIdentifier)\n        .orElseThrow(ContestedOptimisticLockException::new);\n\n    final byte nextDeviceId = account.getNextDeviceId();\n\n    CompletableFuture.allOf(\n            keysManager.deleteSingleUsePreKeys(account.getUuid(), nextDeviceId),\n            keysManager.deleteSingleUsePreKeys(account.getPhoneNumberIdentifier(), nextDeviceId),\n            messagesManager.clear(account.getUuid(), nextDeviceId))\n        .join();\n\n    account.addDevice(deviceSpec.toDevice(nextDeviceId, clock, account.getIdentityKey(IdentityType.ACI)));\n\n    final List<TransactWriteItem> additionalWriteItems = new ArrayList<>(keysManager.buildWriteItemsForNewDevice(\n        account.getIdentifier(IdentityType.ACI),\n        account.getIdentifier(IdentityType.PNI),\n        nextDeviceId,\n        deviceSpec.aciSignedPreKey(),\n        deviceSpec.pniSignedPreKey(),\n        deviceSpec.aciPqLastResortPreKey(),\n        deviceSpec.pniPqLastResortPreKey()));\n\n    additionalWriteItems.add(accounts.buildTransactWriteItemForLinkDevice(linkDeviceToken, LINK_DEVICE_TOKEN_EXPIRATION_DURATION));\n\n    try {\n      accounts.updateTransactionally(account, additionalWriteItems);\n      redisDelete(account);\n\n      final String key = getLinkedDeviceKey(getLinkDeviceTokenIdentifier(linkDeviceToken));\n      final String deviceInfoJson;\n\n      try {\n        deviceInfoJson = SystemMapper.jsonMapper().writeValueAsString(DeviceInfo.forDevice(account.getDevice(nextDeviceId).orElseThrow()));\n      } catch (final JsonProcessingException e) {\n        throw new UncheckedIOException(e);\n      }\n\n      ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n          .executeCompletionStage(retryExecutor, () -> pubSubRedisClient.withConnection(connection ->\n              connection.async().set(key, deviceInfoJson, SetArgs.Builder.ex(RECENTLY_ADDED_DEVICE_TTL))))\n          .whenComplete((_, pubSubThrowable) -> {\n            if (pubSubThrowable != null) {\n              logger.warn(\"Failed to record recently-created device\", pubSubThrowable);\n            }\n          });\n\n      return new Pair<>(account, account.getDevice(nextDeviceId).orElseThrow());\n    } catch (final ContestedOptimisticLockException e) {\n      if (retries > 0) {\n        return addDevice(accountIdentifier, deviceSpec, linkDeviceToken, retries - 1);\n      }\n\n      throw e;\n    } catch (final TransactionCanceledException transactionCanceledException) {\n      // We can be confident the transaction was canceled because the linked device token was already used if the\n      // \"check token\" transaction write item is the only one that failed. That SHOULD be the last one in the\n      // list.\n      final long cancelledTransactions = transactionCanceledException.cancellationReasons().stream()\n          .filter(cancellationReason -> !\"None\".equals(cancellationReason.code()))\n          .count();\n\n      final boolean tokenReuseConditionFailed =\n          \"ConditionalCheckFailed\".equals(transactionCanceledException.cancellationReasons().getLast().code());\n\n      if (cancelledTransactions == 1 && tokenReuseConditionFailed) {\n        throw new LinkDeviceTokenAlreadyUsedException();\n      }\n\n      throw transactionCanceledException;\n    }\n  }\n\n  private Mac getInitializedMac() {\n    try {\n      return getInitializedMac(verificationTokenKey);\n    } catch (final InvalidKeyException e) {\n      // We checked the key at construction time, so this can never happen\n      throw new AssertionError(\"Previously valid key now invalid\", e);\n    }\n  }\n\n  private static Mac getInitializedMac(final Key linkDeviceTokenKey) throws InvalidKeyException {\n    try {\n      final Mac mac = Mac.getInstance(LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM);\n      mac.init(linkDeviceTokenKey);\n\n      return mac;\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  public String generateLinkDeviceToken(final UUID aci) {\n    final String claims = aci + \".\" + clock.instant().toEpochMilli();\n    final byte[] signature = getInitializedMac().doFinal(claims.getBytes(StandardCharsets.UTF_8));\n\n    return claims + \":\" + Base64.getUrlEncoder().encodeToString(signature);\n  }\n\n  @VisibleForTesting\n  static String generateLinkDeviceToken(final UUID aci, final Key linkDeviceTokenKey, final Clock clock)\n      throws InvalidKeyException {\n\n    final String claims = aci + \".\" + clock.instant().toEpochMilli();\n    final byte[] signature = getInitializedMac(linkDeviceTokenKey).doFinal(claims.getBytes(StandardCharsets.UTF_8));\n\n    return claims + \":\" + Base64.getUrlEncoder().encodeToString(signature);\n  }\n\n  public static String getLinkDeviceTokenIdentifier(final String linkDeviceToken) {\n    return Base64.getUrlEncoder().withoutPadding().encodeToString(\n        getSha256MessageDigest().digest(linkDeviceToken.getBytes(StandardCharsets.UTF_8)));\n  }\n\n  /**\n   * Checks that a device-linking token is valid and returns the account identifier from the token if so, or empty if\n   * the token was invalid\n   *\n   * @param token the device-linking token to check\n   *\n   * @return the account identifier from a valid token or empty if the token was invalid\n   */\n  public Optional<UUID> checkDeviceLinkingToken(final String token) {\n    final String[] claimsAndSignature = token.split(\":\", 2);\n\n    if (claimsAndSignature.length != 2) {\n      return Optional.empty();\n    }\n\n    final byte[] expectedSignature = getInitializedMac().doFinal(claimsAndSignature[0].getBytes(StandardCharsets.UTF_8));\n    final byte[] providedSignature;\n\n    try {\n      providedSignature = Base64.getUrlDecoder().decode(claimsAndSignature[1]);\n    } catch (final IllegalArgumentException e) {\n      return Optional.empty();\n    }\n\n    if (!MessageDigest.isEqual(expectedSignature, providedSignature)) {\n      return Optional.empty();\n    }\n\n    final String[] aciAndTimestamp = claimsAndSignature[0].split(\"\\\\.\", 2);\n\n    if (aciAndTimestamp.length != 2) {\n      return Optional.empty();\n    }\n\n    final UUID aci;\n\n    try {\n      aci = UUID.fromString(aciAndTimestamp[0]);\n    } catch (final IllegalArgumentException e) {\n      return Optional.empty();\n    }\n\n    final Instant timestamp;\n\n    try {\n      timestamp = Instant.ofEpochMilli(Long.parseLong(aciAndTimestamp[1]));\n    } catch (final NumberFormatException e) {\n      return Optional.empty();\n    }\n\n    final Instant tokenExpiration = timestamp.plus(LINK_DEVICE_TOKEN_EXPIRATION_DURATION);\n\n    if (tokenExpiration.isBefore(clock.instant())) {\n      return Optional.empty();\n    }\n\n    return Optional.of(aci);\n  }\n\n  /**\n   * Unlink a device from the given account. The device will be immediately disconnected if it is connected to any chat\n   * frontend.\n   *\n   * @return the updated Account\n   */\n  public Account removeDevice(final Account account, final byte deviceId) {\n    if (deviceId == Device.PRIMARY_ID) {\n      throw new IllegalArgumentException(\"Cannot remove primary device\");\n    }\n\n    return accountLockManager.withLock(Set.of(account.getPhoneNumberIdentifier()),\n        () -> removeDevice(account.getIdentifier(IdentityType.ACI), deviceId, MAX_UPDATE_ATTEMPTS),\n        accountLockExecutor);\n  }\n\n  private Account removeDevice(final UUID accountIdentifier, final byte deviceId, final int retries) {\n    final Account account = accounts.getByAccountIdentifier(accountIdentifier)\n        .orElseThrow(ContestedOptimisticLockException::new);\n\n    CompletableFuture.allOf(\n            keysManager.deleteSingleUsePreKeys(account.getUuid(), deviceId),\n            messagesManager.clear(account.getUuid(), deviceId))\n        .join();\n\n    account.removeDevice(deviceId);\n\n    final List<TransactWriteItem> additionalWriteItems = new ArrayList<>(\n        keysManager.buildWriteItemsForRemovedDevice(\n            account.getIdentifier(IdentityType.ACI),\n            account.getIdentifier(IdentityType.PNI),\n            deviceId));\n\n    try {\n      accounts.updateTransactionally(account, additionalWriteItems);\n\n      redisDelete(account);\n\n      // Ensure any messages/single-use pre-keys that came in while we were working are also removed\n      CompletableFuture.allOf(\n              keysManager.deleteSingleUsePreKeys(account.getUuid(), deviceId),\n              messagesManager.clear(account.getUuid(), deviceId))\n          .join();\n\n      disconnectionRequestManager.requestDisconnection(accountIdentifier, List.of(deviceId));\n\n      return account;\n    } catch (final ContestedOptimisticLockException e) {\n      if (retries > 0) {\n        return removeDevice(accountIdentifier, deviceId, retries - 1);\n      }\n\n      throw e;\n    }\n  }\n\n  public Account changeNumber(final Account account,\n      final String targetNumber,\n      final IdentityKey pniIdentityKey,\n      final Map<Byte, ECSignedPreKey> pniSignedPreKeys,\n      final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,\n      final Map<Byte, Integer> pniRegistrationIds) throws InterruptedException, MismatchedDevicesException {\n\n    final UUID targetPhoneNumberIdentifier = phoneNumberIdentifiers.getPhoneNumberIdentifier(targetNumber).join();\n\n    try {\n      return accountLockManager.withLock(new HashSet<>(List.of(account.getPhoneNumberIdentifier(), targetPhoneNumberIdentifier)),\n          () -> changeNumber(account, targetNumber, targetPhoneNumberIdentifier, pniIdentityKey, pniSignedPreKeys, pniPqLastResortPreKeys, pniRegistrationIds), accountLockExecutor);\n    } catch (final RuntimeException e) {\n      logger.error(\"Unexpected exception when changing phone number\", e);\n      throw e;\n    }\n  }\n\n  private Account changeNumber(final Account account,\n      final String targetNumber,\n      final UUID targetPhoneNumberIdentifier,\n      final IdentityKey pniIdentityKey,\n      final Map<Byte, ECSignedPreKey> pniSignedPreKeys,\n      final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,\n      final Map<Byte, Integer> pniRegistrationIds) throws MismatchedDevicesException {\n\n    validateDevices(account, pniSignedPreKeys, pniPqLastResortPreKeys, pniRegistrationIds);\n\n    final UUID originalPhoneNumberIdentifier = account.getPhoneNumberIdentifier();\n\n    redisDelete(account);\n\n    // There are four possible states for accounts associated with the target phone number:\n    //\n    // 1. The authenticated account already has the given phone number. We don't want to delete the account, but do want\n    //    to update keys.\n    // 2. An account exists with the target PNI; the caller has proved ownership of the number, so delete the\n    //    account with the target PNI. This will leave a \"deleted account\" record for the deleted account mapping\n    //    the UUID of the deleted account to the target PNI. We'll then overwrite that so it points to the\n    //    original PNI to facilitate switching back and forth between numbers.\n    // 3. No account with the target PNI exists, but one has recently been deleted. In that case, add a \"deleted\n    //    account\" record that maps the ACI of the recently-deleted account to the now-abandoned original PNI\n    //    of the account changing its number (which facilitates ACI consistency in cases that a party is switching\n    //    back and forth between numbers).\n    // 4. No account with the target PNI exists at all, in which case no additional action is needed.\n    final Optional<UUID> recentlyDeletedAci = accounts.findRecentlyDeletedAccountIdentifier(targetPhoneNumberIdentifier);\n    final Optional<Account> maybeExistingAccount = getByE164(targetNumber);\n    final Optional<UUID> maybeDisplacedUuid;\n\n    if (maybeExistingAccount.isPresent()) {\n      if (maybeExistingAccount.get().getIdentifier(IdentityType.ACI).equals(account.getIdentifier(IdentityType.ACI))) {\n        maybeDisplacedUuid = Optional.empty();\n      } else {\n        delete(maybeExistingAccount.get());\n        maybeDisplacedUuid = maybeExistingAccount.map(Account::getUuid);\n      }\n    } else {\n      maybeDisplacedUuid = recentlyDeletedAci;\n    }\n\n    final UUID uuid = account.getUuid();\n\n    CompletableFuture.allOf(\n            keysManager.deleteSingleUsePreKeys(targetPhoneNumberIdentifier),\n            keysManager.deleteSingleUsePreKeys(originalPhoneNumberIdentifier))\n        .join();\n\n      final Collection<TransactWriteItem> keyWriteItems =\n          buildPniKeyWriteItems(targetPhoneNumberIdentifier, pniSignedPreKeys, pniPqLastResortPreKeys);\n\n    return updateWithRetries(\n        account,\n        a -> {\n          setPniKeys(a, pniIdentityKey, pniRegistrationIds);\n          return true;\n        },\n        a -> accounts.changeNumber(a, targetNumber, targetPhoneNumberIdentifier, maybeDisplacedUuid, keyWriteItems),\n        () -> accounts.getByAccountIdentifier(uuid).orElseThrow(),\n        AccountChangeValidator.NUMBER_CHANGE_VALIDATOR);\n  }\n\n  private Collection<TransactWriteItem> buildPniKeyWriteItems(\n      final UUID phoneNumberIdentifier,\n      final Map<Byte, ECSignedPreKey> pniSignedPreKeys,\n      final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys) {\n\n    final List<TransactWriteItem> keyWriteItems = new ArrayList<>();\n\n    pniSignedPreKeys.forEach((deviceId, signedPreKey) ->\n        keyWriteItems.add(keysManager.buildWriteItemForEcSignedPreKey(phoneNumberIdentifier, deviceId, signedPreKey)));\n\n    pniPqLastResortPreKeys.forEach((deviceId, lastResortKey) ->\n        keyWriteItems.add(keysManager.buildWriteItemForLastResortKey(phoneNumberIdentifier, deviceId, lastResortKey)));\n\n    return keyWriteItems;\n  }\n\n  private void setPniKeys(final Account account,\n      final IdentityKey pniIdentityKey,\n      final Map<Byte, Integer> pniRegistrationIds) {\n\n    account.getDevices()\n        .forEach(device -> device.setPhoneNumberIdentityRegistrationId(pniRegistrationIds.get(device.getId())));\n\n    account.setPhoneNumberIdentityKey(pniIdentityKey);\n  }\n\n  private void validateDevices(final Account account,\n      final Map<Byte, ECSignedPreKey> pniSignedPreKeys,\n      final Map<Byte, KEMSignedPreKey> pniPqLastResortPreKeys,\n      final Map<Byte, Integer> pniRegistrationIds) throws MismatchedDevicesException {\n\n    // Check that all including primary ID are in signed pre-keys\n    validateCompleteDeviceList(account, pniSignedPreKeys.keySet());\n\n    // Check that all including primary ID are in Pq pre-keys\n    validateCompleteDeviceList(account, pniPqLastResortPreKeys.keySet());\n\n    // Check that all devices are accounted for in the map of new PNI registration IDs\n    validateCompleteDeviceList(account, pniRegistrationIds.keySet());\n  }\n\n  @VisibleForTesting\n  static void validateCompleteDeviceList(final Account account, final Set<Byte> deviceIds) throws MismatchedDevicesException {\n    final Set<Byte> accountDeviceIds = account.getDevices().stream()\n        .map(Device::getId)\n        .collect(Collectors.toSet());\n\n    final Set<Byte> missingDeviceIds = new HashSet<>(accountDeviceIds);\n    missingDeviceIds.removeAll(deviceIds);\n\n    final Set<Byte> extraDeviceIds = new HashSet<>(deviceIds);\n    extraDeviceIds.removeAll(accountDeviceIds);\n\n    if (!missingDeviceIds.isEmpty() || !extraDeviceIds.isEmpty()) {\n      throw new MismatchedDevicesException(new MismatchedDevices(missingDeviceIds, extraDeviceIds, Set.of()));\n    }\n  }\n\n  public record UsernameReservation(Account account, byte[] reservedUsernameHash){}\n\n  /// Reserve a username hash so that no other accounts may take it.\n  ///\n  /// The reserved hash can later be set with [#confirmReservedUsernameHash(Account, byte\\[\\], byte\\[\\])]. The\n  /// reservation will eventually expire, after which point confirmReservedUsernameHash may fail if another account has\n  /// taken the username hash.\n  ///\n  /// @param account the account to update\n  /// @param requestedUsernameHashes the list of username hashes to attempt to reserve\n  ///\n  /// @return the reserved username hash\n  ///\n  /// @throws UsernameHashNotAvailableException if none of the given username hashes are available\n  public UsernameReservation reserveUsernameHash(final Account account, final List<byte[]> requestedUsernameHashes)\n      throws UsernameHashNotAvailableException {\n    if (account.getUsernameHash().filter(\n            oldHash -> requestedUsernameHashes.stream().anyMatch(hash -> Arrays.equals(oldHash, hash)))\n        .isPresent()) {\n\n      // if we are trying to reserve our already-confirmed username hash, we don't need to do\n      // anything, and can give the client a success response (they may try to confirm it again,\n      // but that's a no-op other than rotaing their username link which they may need to do\n      // anyway). note this is *not* the case for reserving our already-reserved username hash,\n      // which should extend the reservation's TTL.\n      return new UsernameReservation(account, account.getUsernameHash().get());\n    }\n\n    final AtomicReference<byte[]> reservedUsernameHash = new AtomicReference<>();\n\n    redisDelete(account);\n\n    final Account updatedAccount = updateWithRetries(\n        account,\n        _ -> true,\n        a -> reservedUsernameHash.set(\n            checkAndReserveNextUsernameHash(a, new ArrayDeque<>(requestedUsernameHashes))),\n        () -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),\n        AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);\n\n    redisDelete(updatedAccount);\n\n    return new UsernameReservation(updatedAccount, reservedUsernameHash.get());\n  }\n\n  private byte[] checkAndReserveNextUsernameHash(final Account account, final Queue<byte[]> requestedUsernameHashes)\n      throws UsernameHashNotAvailableException {\n\n    final byte[] usernameHash = requestedUsernameHashes.remove();\n\n    try {\n      accounts.reserveUsernameHash(account, usernameHash, USERNAME_HASH_RESERVATION_TTL_MINUTES);\n      return usernameHash;\n    } catch (final UsernameHashNotAvailableException e) {\n      if (!requestedUsernameHashes.isEmpty()) {\n        return checkAndReserveNextUsernameHash(account, requestedUsernameHashes);\n      }\n\n      throw e;\n    }\n  }\n\n  /// Set a username hash previously reserved with {@link #reserveUsernameHash(Account, List)}\n  ///\n  /// @param account the account to update\n  /// @param reservedUsernameHash the previously reserved username hash\n  /// @param encryptedUsername the encrypted form of the previously reserved username for the username link\n  ///\n  /// @return the updated account with the username hash field set\n  ///\n  /// @throws UsernameHashNotAvailableException if the reserved username hash has been taken (because the reservation\n  /// expired)\n  /// @throws UsernameReservationNotFoundException if `reservedUsernameHash` was not reserved for the account\n  public Account confirmReservedUsernameHash(final Account account, final byte[] reservedUsernameHash, @Nullable final byte[] encryptedUsername)\n      throws UsernameReservationNotFoundException, UsernameHashNotAvailableException {\n\n    if (account.getUsernameHash().map(currentUsernameHash -> Arrays.equals(currentUsernameHash, reservedUsernameHash)).orElse(false)) {\n      // the client likely already succeeded and is retrying\n      return account;\n    }\n\n    if (!account.getReservedUsernameHash().map(oldHash -> Arrays.equals(oldHash, reservedUsernameHash)).orElse(false)) {\n      // no such reservation existed, either there was no previous call to reserveUsername\n      // or the reservation changed\n      throw new UsernameReservationNotFoundException();\n    }\n\n    redisDelete(account);\n\n    final Account updatedAccount = updateWithRetries(account,\n        _ -> true,\n        a -> accounts.confirmUsernameHash(a, reservedUsernameHash, encryptedUsername),\n        () -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),\n        AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);\n\n    redisDelete(updatedAccount);\n\n    return updatedAccount;\n  }\n\n  public Account clearUsernameHash(final Account account) {\n    redisDelete(account);\n\n    final Account updatedAccount = updateWithRetries(account,\n        _ -> true,\n        accounts::clearUsernameHash,\n        () -> accounts.getByAccountIdentifier(account.getUuid()).orElseThrow(),\n        AccountChangeValidator.USERNAME_CHANGE_VALIDATOR);\n\n    redisDelete(updatedAccount);\n\n    return updatedAccount;\n  }\n\n  public Account update(Account account, Consumer<Account> updater) {\n    return update(account, a -> {\n      updater.accept(a);\n      // assume that all updaters passed to the public method actually modify the account\n      return true;\n    });\n  }\n\n  /**\n   * Specialized version of {@link #updateDevice(Account, byte, Consumer)} that minimizes potentially contentious and\n   * redundant updates of {@code device.lastSeen}\n   */\n  public Account updateDeviceLastSeen(Account account, Device device, final long lastSeen) {\n    return update(account, a -> {\n\n      final Optional<Device> maybeDevice = a.getDevice(device.getId());\n\n      return maybeDevice.map(d -> {\n        if (d.getLastSeen() >= lastSeen) {\n          return false;\n        }\n\n        d.setLastSeen(lastSeen);\n\n        return true;\n\n      }).orElse(false);\n    });\n  }\n\n  public Account updateDeviceAuthentication(final Account account, final Device device, final SaltedTokenHash credentials) {\n    Preconditions.checkArgument(credentials.getVersion() == SaltedTokenHash.CURRENT_VERSION);\n    return updateDevice(account, device.getId(), device1 -> device1.setAuthTokenHash(credentials));\n  }\n\n  /**\n   * @param account account to update\n   * @param updater must return {@code true} if the account was actually updated\n   */\n  private Account update(Account account, Function<Account, Boolean> updater) {\n\n    return updateTimer.record(() -> {\n\n      redisDelete(account);\n\n      final UUID uuid = account.getUuid();\n\n      final Account updatedAccount = updateWithRetries(account,\n          updater,\n          accounts::update,\n          () -> accounts.getByAccountIdentifier(uuid).orElseThrow(),\n          AccountChangeValidator.GENERAL_CHANGE_VALIDATOR);\n\n      redisSet(updatedAccount);\n\n      return updatedAccount;\n    });\n  }\n\n  private <E extends Exception> Account updateWithRetries(Account account,\n      final Function<Account, Boolean> updater,\n      final ThrowingConsumer<Account, E> persister,\n      final Supplier<Account> retriever,\n      final AccountChangeValidator changeValidator) throws E {\n\n    Account originalAccount = AccountUtil.cloneAccountAsNotStale(account);\n\n    if (!updater.apply(account)) {\n      return account;\n    }\n\n    final int maxTries = 10;\n    int tries = 0;\n\n    while (tries < maxTries) {\n\n      try {\n        persister.accept(account);\n\n        final Account updatedAccount = AccountUtil.cloneAccountAsNotStale(account);\n        account.markStale();\n\n        changeValidator.validateChange(originalAccount, updatedAccount);\n\n        return updatedAccount;\n      } catch (final ContestedOptimisticLockException e) {\n        tries++;\n\n        account = retriever.get();\n        originalAccount = AccountUtil.cloneAccountAsNotStale(account);\n\n        if (!updater.apply(account)) {\n          return account;\n        }\n      }\n    }\n\n    throw new OptimisticLockRetryLimitExceededException();\n  }\n\n  public Account updateDevice(Account account, byte deviceId, Consumer<Device> deviceUpdater) {\n    return update(account, a -> {\n      a.getDevice(deviceId).ifPresent(deviceUpdater);\n      // assume that all updaters passed to the public method actually modify the device\n      return true;\n    });\n  }\n\n  public Optional<Account> getByE164(final String number) {\n    return getByNumberTimer.record(() -> accounts.getByE164(number));\n  }\n\n  public Optional<Account> getByPhoneNumberIdentifier(final UUID pni) {\n    return checkRedisThenAccounts(\n        getByNumberTimer,\n        () -> redisGetBySecondaryKey(getAccountMapKey(pni.toString()), redisPniGetTimer),\n        () -> accounts.getByPhoneNumberIdentifier(pni)\n    );\n  }\n\n  public CompletableFuture<Optional<Account>> getByPhoneNumberIdentifierAsync(final UUID pni) {\n    return checkRedisThenAccountsAsync(\n        getByNumberTimer,\n        () -> redisGetBySecondaryKeyAsync(getAccountMapKey(pni.toString()), redisPniGetTimer),\n        () -> accounts.getByPhoneNumberIdentifierAsync(pni)\n    );\n  }\n\n  public CompletableFuture<Optional<Account>> getByUsernameLinkHandle(final UUID usernameLinkHandle) {\n    final Timer.Sample sample = Timer.start();\n    return accounts.getByUsernameLinkHandle(usernameLinkHandle)\n        .whenComplete((ignoredResult, ignoredThrowable) -> sample.stop(getByUsernameLinkHandleTimer));\n  }\n\n  public CompletableFuture<Optional<Account>> getByUsernameHash(final byte[] usernameHash) {\n    final Timer.Sample sample = Timer.start();\n    return accounts.getByUsernameHash(usernameHash)\n        .whenComplete((ignoredResult, ignoredThrowable) -> sample.stop(getByUsernameHashTimer));\n  }\n\n  public Optional<Account> getByServiceIdentifier(final ServiceIdentifier serviceIdentifier) {\n    return switch (serviceIdentifier.identityType()) {\n      case ACI -> getByAccountIdentifier(serviceIdentifier.uuid());\n      case PNI -> getByPhoneNumberIdentifier(serviceIdentifier.uuid());\n    };\n  }\n\n  public CompletableFuture<Optional<Account>> getByServiceIdentifierAsync(final ServiceIdentifier serviceIdentifier) {\n    return switch (serviceIdentifier.identityType()) {\n      case ACI -> getByAccountIdentifierAsync(serviceIdentifier.uuid());\n      case PNI -> getByPhoneNumberIdentifierAsync(serviceIdentifier.uuid());\n    };\n  }\n\n  public Optional<Account> getByAccountIdentifier(final UUID uuid) {\n    return checkRedisThenAccounts(\n        getByUuidTimer,\n        () -> redisGetByAccountIdentifier(uuid),\n        () -> accounts.getByAccountIdentifier(uuid)\n    );\n  }\n\n  public CompletableFuture<Optional<Account>> getByAccountIdentifierAsync(final UUID uuid) {\n    return checkRedisThenAccountsAsync(\n        getByUuidTimer,\n        () -> redisGetByAccountIdentifierAsync(uuid),\n        () -> accounts.getByAccountIdentifierAsync(uuid)\n    );\n  }\n\n  public UUID getPhoneNumberIdentifier(String e164) {\n    return phoneNumberIdentifiers.getPhoneNumberIdentifier(e164).join();\n  }\n\n  public Optional<UUID> findRecentlyDeletedAccountIdentifier(final UUID phoneNumberIdentifier) {\n    return accounts.findRecentlyDeletedAccountIdentifier(phoneNumberIdentifier);\n  }\n\n  public Optional<UUID> findRecentlyDeletedPhoneNumberIdentifier(final UUID accountIdentifier) {\n    return accounts.findRecentlyDeletedPhoneNumberIdentifier(accountIdentifier);\n  }\n\n  public Flux<Account> streamAllFromDynamo(final int segments, final Scheduler scheduler) {\n    return accounts.getAll(segments, scheduler);\n  }\n\n  public void delete(final Account account, final DeletionReason deletionReason) {\n    final Timer.Sample sample = Timer.start();\n\n    try {\n      accountLockManager.withLock(Set.of(account.getPhoneNumberIdentifier()), () -> {\n        delete(account);\n        return null;\n      }, accountLockExecutor);\n\n      Metrics.counter(DELETE_COUNTER_NAME,\n              COUNTRY_CODE_TAG_NAME, Util.getCountryCode(account.getNumber()),\n              DELETION_REASON_TAG_NAME, deletionReason.tagValue)\n          .increment();\n    } catch (final RuntimeException e) {\n      logger.warn(\"Failed to delete account\", e);\n      throw e;\n    } finally {\n      sample.stop(deleteTimer);\n    }\n  }\n\n  private void delete(final Account account) {\n    final List<TransactWriteItem> additionalWriteItems = account.getDevices().stream()\n        .flatMap(device -> keysManager.buildWriteItemsForRemovedDevice(\n                account.getIdentifier(IdentityType.ACI),\n                account.getIdentifier(IdentityType.PNI),\n                device.getId())\n            .stream())\n        .toList();\n\n    CompletableFuture.allOf(\n            secureStorageClient.deleteStoredData(account.getUuid()),\n            secureValueRecovery2Client.removeData(account.getUuid()),\n            keysManager.deleteSingleUsePreKeys(account.getUuid()),\n            keysManager.deleteSingleUsePreKeys(account.getPhoneNumberIdentifier()),\n            messagesManager.clear(account.getUuid()),\n            profilesManager.deleteAll(account.getUuid(), true),\n            registrationRecoveryPasswordsManager.remove(account.getIdentifier(IdentityType.PNI)))\n        .join();\n\n    accounts.delete(account.getUuid(), additionalWriteItems);\n    redisDelete(account);\n\n    disconnectionRequestManager.requestDisconnection(account);\n  }\n\n  private String getAccountMapKey(String key) {\n    return \"AccountMap::\" + key;\n  }\n\n  private String getAccountEntityKey(UUID uuid) {\n    return \"Account3::\" + uuid.toString();\n  }\n\n  private void redisSet(Account account) {\n    redisSetTimer.record(() -> {\n      try {\n        final String accountJson = writeRedisAccountJson(account);\n\n        cacheCluster.useCluster(connection -> {\n          final RedisAdvancedClusterCommands<String, String> commands = connection.sync();\n\n          commands.setex(getAccountMapKey(account.getPhoneNumberIdentifier().toString()), CACHE_TTL_SECONDS,\n              account.getUuid().toString());\n          commands.setex(getAccountEntityKey(account.getUuid()), CACHE_TTL_SECONDS, accountJson);\n        });\n      } catch (JsonProcessingException e) {\n        throw new IllegalStateException(e);\n      }\n    });\n  }\n\n  private CompletableFuture<Void> redisSetAsync(final Account account) {\n    final String accountJson;\n\n    try {\n      accountJson = writeRedisAccountJson(account);\n    } catch (final JsonProcessingException e) {\n      throw new UncheckedIOException(e);\n    }\n\n    return cacheCluster.withCluster(connection -> CompletableFuture.allOf(\n        connection.async().setex(\n                getAccountMapKey(account.getPhoneNumberIdentifier().toString()), CACHE_TTL_SECONDS,\n                account.getUuid().toString())\n            .toCompletableFuture(),\n        connection.async().setex(getAccountEntityKey(account.getUuid()), CACHE_TTL_SECONDS, accountJson)\n            .toCompletableFuture()));\n  }\n\n  private Optional<Account> checkRedisThenAccounts(\n      final Timer overallTimer,\n      final Supplier<Optional<Account>> resolveFromRedis,\n      final Supplier<Optional<Account>> resolveFromAccounts) {\n    return overallTimer.record(() -> {\n      Optional<Account> account = resolveFromRedis.get();\n      if (account.isEmpty()) {\n        account = resolveFromAccounts.get();\n        try {\n          account.ifPresent(this::redisSet);\n        } catch (RedisException e) {\n          logger.warn(\"Failed to cache retrieved account\", e);\n        }\n      }\n      return account;\n    });\n  }\n\n  private CompletableFuture<Optional<Account>> checkRedisThenAccountsAsync(\n      final Timer overallTimer,\n      final Supplier<CompletableFuture<Optional<Account>>> resolveFromRedis,\n      final Supplier<CompletableFuture<Optional<Account>>> resolveFromAccounts) {\n\n    final Timer.Sample sample = Timer.start();\n\n    return resolveFromRedis.get()\n        .thenCompose(maybeAccountFromRedis -> maybeAccountFromRedis\n            .map(_ -> CompletableFuture.completedFuture(maybeAccountFromRedis))\n            .orElseGet(() -> resolveFromAccounts.get()\n                .thenCompose(maybeAccountFromAccounts -> maybeAccountFromAccounts\n                    .map(account -> redisSetAsync(account)\n                        .exceptionally(ExceptionUtils.exceptionallyHandler(RedisException.class, e -> {\n                          logger.warn(\"Failed to cache retrieved account\", e);\n                          return null;\n                        }))\n                        .thenApply(ignored -> maybeAccountFromAccounts))\n                    .orElseGet(() -> CompletableFuture.completedFuture(maybeAccountFromAccounts)))))\n        .whenComplete((_, _) -> sample.stop(overallTimer));\n  }\n\n  private Optional<Account> redisGetBySecondaryKey(final String secondaryKey, final Timer timer) {\n    return timer.record(() -> {\n      try {\n      return Optional.ofNullable(cacheCluster.withCluster(connection -> connection.sync().get(secondaryKey)))\n          .map(UUID::fromString)\n          .flatMap(this::getByAccountIdentifier);\n    } catch (IllegalArgumentException e) {\n      logger.warn(\"Deserialization error\", e);\n      return Optional.empty();\n    } catch (RedisException e) {\n      logger.warn(\"Failed fetching account from cache by secondary key\", e);\n      return Optional.empty();\n    }\n    });\n  }\n\n  private CompletableFuture<Optional<Account>> redisGetBySecondaryKeyAsync(final String secondaryKey, final Timer timer) {\n    final Timer.Sample sample = Timer.start();\n\n    return cacheCluster.withCluster(connection -> connection.async().get(secondaryKey))\n        .thenCompose(nullableUuid -> {\n          if (nullableUuid != null) {\n            return getByAccountIdentifierAsync(UUID.fromString(nullableUuid));\n          } else {\n            return CompletableFuture.completedFuture(Optional.empty());\n          }\n        })\n        .exceptionally(throwable -> {\n          logger.warn(\"Failed to retrieve account from Redis\", throwable);\n          return Optional.empty();\n        })\n        .whenComplete((_, _) -> sample.stop(timer))\n        .toCompletableFuture();\n  }\n\n  private Optional<Account> redisGetByAccountIdentifier(UUID uuid) {\n    return redisUuidGetTimer.record(() -> {\n      try {\n        final String json = cacheCluster.withCluster(connection -> connection.sync().get(getAccountEntityKey(uuid)));\n\n        return parseAccountJson(json, uuid);\n      } catch (final RedisException e) {\n        logger.warn(\"Failed to retrieve account from cache\", e);\n        return Optional.empty();\n      }\n    });\n  }\n\n  private CompletableFuture<Optional<Account>> redisGetByAccountIdentifierAsync(final UUID uuid) {\n    return cacheCluster.withCluster(connection -> connection.async().get(getAccountEntityKey(uuid)))\n        .thenApply(accountJson -> parseAccountJson(accountJson, uuid))\n        .exceptionally(throwable -> {\n          logger.warn(\"Failed to retrieve account from Redis\", throwable);\n          return Optional.empty();\n        })\n        .toCompletableFuture();\n  }\n\n  @VisibleForTesting\n  static Optional<Account> parseAccountJson(@Nullable final String accountJson, final UUID uuid) {\n    try {\n      if (StringUtils.isNotBlank(accountJson)) {\n        Account account = SystemMapper.jsonMapper().readValue(accountJson, Account.class);\n        account.setUuid(uuid);\n\n        if (account.getPhoneNumberIdentifier() == null) {\n          logger.warn(\"Account {} loaded from Redis is missing a PNI\", uuid);\n        }\n\n        return Optional.of(account);\n      }\n\n      return Optional.empty();\n    } catch (final IOException e) {\n      logger.warn(\"Deserialization error\", e);\n      return Optional.empty();\n    }\n  }\n\n  @VisibleForTesting\n  static String writeRedisAccountJson(final Account account) throws JsonProcessingException {\n    return ACCOUNT_REDIS_JSON_WRITER.writeValueAsString(account);\n  }\n\n  private void redisDelete(final Account account) {\n    ResilienceUtil.getGeneralRedisRetry(RETRY_NAME).executeRunnable(() ->\n        redisDeleteTimer.record(() ->\n            cacheCluster.useCluster(connection ->\n                connection.sync().del(getAccountMapKey(account.getPhoneNumberIdentifier().toString()),\n                    getAccountEntityKey(account.getUuid())))));\n  }\n\n  private CompletableFuture<Void> redisDeleteAsync(final Account account) {\n    final Timer.Sample sample = Timer.start();\n\n    final String[] keysToDelete = new String[]{\n        getAccountMapKey(account.getPhoneNumberIdentifier().toString()),\n        getAccountEntityKey(account.getUuid())\n    };\n\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME).executeCompletionStage(retryExecutor,\n            () -> cacheCluster.withCluster(connection -> connection.async().del(keysToDelete))\n                .thenRun(Util.NOOP))\n        .toCompletableFuture()\n        .whenComplete((_, _) -> sample.stop(redisDeleteTimer));\n  }\n\n  public CompletableFuture<Optional<DeviceInfo>> waitForNewLinkedDevice(\n      final UUID accountIdentifier,\n      final Device linkingDevice,\n      final String linkDeviceTokenIdentifier,\n      final Duration timeout) {\n    if (!linkingDevice.isPrimary()) {\n      throw new IllegalArgumentException(\"Only primary devices can link devices\");\n    }\n\n    // Unbeknownst to callers but beknownst to us, the \"link device token identifier\" is the base64/url-encoded SHA256\n    // hash of a device-linking token. Before we use the string anywhere, make sure it's the right \"shape\" for a hash.\n    if (Base64.getUrlDecoder().decode(linkDeviceTokenIdentifier).length != SHA256_HASH_LENGTH) {\n      return CompletableFuture.failedFuture(new IllegalArgumentException(\"Invalid token identifier\"));\n    }\n\n    final Instant deadline = clock.instant().plus(timeout);\n    final CompletableFuture<Optional<DeviceInfo>> deviceAdded = waitForPubSubKey(waitForDeviceFuturesByTokenIdentifier,\n        linkDeviceTokenIdentifier, getLinkedDeviceKey(linkDeviceTokenIdentifier), timeout, this::handleDeviceAdded);\n\n    return deviceAdded.thenCompose(maybeDeviceInfo -> maybeDeviceInfo.map(deviceInfo -> {\n          // The device finished linking, we now want to make sure the primary client has fetched messages that could\n          // have come in before the linked device's mailbox was set up. This avoids a race where the linked device\n          // misses out on messages that were sent before its mailbox was set up but received by the primary *after*\n          // creating its backup for the linked device.\n\n          // We know the device finished linking at the current time, so waiting for all messages\n          // before now is sufficient.\n          return waitForPreLinkMessagesToBeFetched(accountIdentifier, linkingDevice, deviceInfo, clock.instant(), deadline);\n        })\n        .orElseGet(() -> CompletableFuture.completedFuture(maybeDeviceInfo)));\n  }\n\n  /**\n   * Wait until there are no pending messages for the authenticatedDevice that have a timestamp lower than the provided\n   * messageEpoch.\n   *\n   * @param aci              The account identifier of the device doing the linking\n   * @param linkingDevice    The device doing the linking\n   * @param linkedDeviceInfo Information about the newly linked device\n   * @param messageEpoch     A time at which the device was linked\n   * @param deadline         The time at which the method will stop waiting\n   * @return A future that completes when there are no pending messages for the linking device with a timestamp earlier\n   * the provided messageEpoch, or after the deadline is reached. If the deadline was exceeded, the future will be empty.\n   */\n  private CompletableFuture<Optional<DeviceInfo>> waitForPreLinkMessagesToBeFetched(\n      final UUID aci,\n      final Device linkingDevice,\n      final DeviceInfo linkedDeviceInfo,\n      final Instant messageEpoch,\n      final Instant deadline) {\n    return messagesManager.getEarliestUndeliveredTimestampForDevice(aci, linkingDevice)\n        .thenCompose(maybeEarliestTimestamp -> {\n\n          final boolean clientHasOldMessages = maybeEarliestTimestamp\n              .map(earliestTimestamp -> earliestTimestamp.isBefore(messageEpoch))\n              .orElse(false);\n\n          if (!clientHasOldMessages) {\n            // The client has fetched all messages before the messageEpoch\n            return CompletableFuture.completedFuture(Optional.of(linkedDeviceInfo));\n          }\n\n          final Instant now = clock.instant();\n          if (now.plus(MESSAGE_POLL_INTERVAL).isAfter(deadline)) {\n            // Not enough time to try again before the deadline\n            return CompletableFuture.completedFuture(Optional.empty());\n          }\n\n          // Schedule a retry\n          return CompletableFuture.supplyAsync(\n                  () -> waitForPreLinkMessagesToBeFetched(aci, linkingDevice, linkedDeviceInfo, messageEpoch, deadline),\n                  r -> messagesPollExecutor.schedule(r, MESSAGE_POLL_INTERVAL.toMillis(), TimeUnit.MILLISECONDS))\n              .thenCompose(Function.identity());\n        });\n  }\n\n\n  private void handleDeviceAdded(final CompletableFuture<Optional<DeviceInfo>> future, final String deviceInfoJson) {\n    try {\n      future.complete(Optional.of(SystemMapper.jsonMapper().readValue(deviceInfoJson, DeviceInfo.class)));\n    } catch (final JsonProcessingException e) {\n      logger.error(\"Could not parse device json\", e);\n      future.completeExceptionally(e);\n    }\n  }\n\n  private static String getLinkedDeviceKey(final String linkDeviceTokenIdentifier) {\n    return LINKED_DEVICE_PREFIX + linkDeviceTokenIdentifier;\n  }\n\n  public CompletableFuture<Optional<TransferArchiveResult>> waitForTransferArchive(final Account account, final Device device, final Duration timeout) {\n    final DeviceIdentifier deviceIdentifier = new DeviceIdentifier(account.getIdentifier(IdentityType.ACI), device.getId(), device.getRegistrationId(IdentityType.ACI));\n    final String registrationIdTransferArchiveKey = getRegistrationIdTransferArchiveKey(account.getIdentifier(IdentityType.ACI), device.getId(), device.getRegistrationId(IdentityType.ACI));\n\n    return waitForPubSubKey(waitForTransferArchiveFuturesByDeviceIdentifier,\n        deviceIdentifier,\n        registrationIdTransferArchiveKey,\n        timeout,\n        this::handleTransferArchiveAdded);\n  }\n\n  public CompletableFuture<Void> recordTransferArchiveUpload(final Account account,\n      final byte destinationDeviceId,\n      final int registrationId,\n      final TransferArchiveResult transferArchiveResult) {\n    try {\n      final String transferArchiveJson = SystemMapper.jsonMapper().writeValueAsString(transferArchiveResult);\n\n      final String key = getRegistrationIdTransferArchiveKey(account.getIdentifier(IdentityType.ACI), destinationDeviceId, registrationId);\n\n      return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n          .executeCompletionStage(retryExecutor, () -> pubSubRedisClient.withConnection(connection -> connection.async()\n                  .set(key, transferArchiveJson, SetArgs.Builder.ex(RECENTLY_ADDED_TRANSFER_ARCHIVE_TTL)))\n              .toCompletableFuture())\n          .thenRun(Util.NOOP)\n          .toCompletableFuture();\n    } catch (final JsonProcessingException e) {\n      // This should never happen for well-defined objects we control\n      throw new UncheckedIOException(e);\n    }\n  }\n\n  private void handleTransferArchiveAdded(final CompletableFuture<Optional<TransferArchiveResult>> future, final String transferArchiveJson) {\n    try {\n      future.complete(Optional.of(SystemMapper.jsonMapper().readValue(transferArchiveJson, TransferArchiveResult.class)));\n    } catch (final JsonProcessingException e) {\n      logger.error(\"Could not parse transfer archive json\", e);\n      future.completeExceptionally(e);\n    }\n  }\n\n  private static String getRegistrationIdTransferArchiveKey(final UUID accountIdentifier,\n      final byte destinationDeviceId,\n      final int registrationId) {\n    Metrics.counter(REGISTRATION_ID_BASED_TRANSFER_ARCHIVE_KEY_COUNTER_NAME).increment();\n\n    return TRANSFER_ARCHIVE_PREFIX + accountIdentifier.toString() +\n        \":\" + destinationDeviceId +\n        \":\" + TRANSFER_ARCHIVE_REGISTRATION_ID_PATTERN +\n        \":\" + registrationId;\n  }\n\n  public CompletableFuture<Optional<RestoreAccountRequest>> waitForRestoreAccountRequest(final String token, final Duration timeout) {\n    return waitForPubSubKey(waitForRestoreAccountRequestFuturesByToken,\n        token,\n        getRestoreAccountRequestKey(token),\n        timeout,\n        this::handleRestoreAccountRequest);\n  }\n\n  public CompletableFuture<Void> recordRestoreAccountRequest(final String token, final RestoreAccountRequest restoreAccountRequest) {\n    final String key = getRestoreAccountRequestKey(token);\n\n    final String requestJson;\n\n    try {\n      requestJson = SystemMapper.jsonMapper().writeValueAsString(restoreAccountRequest);\n    } catch (final JsonProcessingException e) {\n      throw new UncheckedIOException(e);\n    }\n\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> pubSubRedisClient.withConnection(connection ->\n                connection.async().set(key, requestJson, SetArgs.Builder.ex(RESTORE_ACCOUNT_REQUEST_TTL)))\n            .toCompletableFuture())\n        .thenRun(Util.NOOP)\n        .toCompletableFuture();\n  }\n\n  private void handleRestoreAccountRequest(final CompletableFuture<Optional<RestoreAccountRequest>> future, final String transferRequestJson) {\n    try {\n      future.complete(Optional.of(SystemMapper.jsonMapper().readValue(transferRequestJson, RestoreAccountRequest.class)));\n    } catch (final JsonProcessingException e) {\n      logger.error(\"Could not parse device transfer request JSON\", e);\n      future.completeExceptionally(e);\n    }\n  }\n\n  private static String getRestoreAccountRequestKey(final String token) {\n    return RESTORE_ACCOUNT_REQUEST_PREFIX + token;\n  }\n\n  private <K, T> CompletableFuture<Optional<T>> waitForPubSubKey(final Map<K, CompletableFuture<Optional<T>>> futureMap,\n      final K mapKey,\n      final String redisKey,\n      final Duration timeout,\n      final BiConsumer<CompletableFuture<Optional<T>>, String> handler) {\n\n    final CompletableFuture<Optional<T>> future = new CompletableFuture<>();\n\n    future.completeOnTimeout(Optional.empty(), TimeUnit.MILLISECONDS.convert(timeout), TimeUnit.MILLISECONDS)\n        .whenComplete((_, _) -> futureMap.remove(mapKey, future));\n\n    {\n      final CompletableFuture<Optional<T>> displacedFuture = futureMap.put(mapKey, future);\n\n      if (displacedFuture != null) {\n        displacedFuture.complete(Optional.empty());\n      }\n    }\n\n    // The Redis key we're waiting for may have been added before the caller issued a request to watch for it; check to\n    // see if it's already there\n    pubSubRedisClient.withConnection(connection -> connection.async().get(redisKey))\n        .thenAccept(response -> {\n          if (StringUtils.isNotBlank(response)) {\n            handler.accept(future, response);\n          }\n        });\n\n    return future;\n  }\n\n  @Override\n  public void message(final String pattern, final String channel, final String message) {\n    if (LINKED_DEVICE_KEYSPACE_PATTERN.equals(pattern) && \"set\".equalsIgnoreCase(message)) {\n      // The `- 1` here compensates for the '*' in the pattern\n      final String tokenIdentifier = channel.substring(LINKED_DEVICE_KEYSPACE_PATTERN.length() - 1);\n\n      Optional.ofNullable(waitForDeviceFuturesByTokenIdentifier.remove(tokenIdentifier))\n          .ifPresent(future -> pubSubRedisClient.withConnection(connection -> connection.async().get(getLinkedDeviceKey(tokenIdentifier)))\n              .whenComplete((deviceInfoJson, throwable) -> {\n                if (throwable != null) {\n                  future.completeExceptionally(throwable);\n                } else {\n                  handleDeviceAdded(future, deviceInfoJson);\n                }\n              }));\n    } else if (TRANSFER_ARCHIVE_KEYSPACE_PATTERN.equals(pattern) && \"set\".equalsIgnoreCase(message)) {\n      // The `- 1` here compensates for the '*' in the pattern\n      final String[] deviceIdentifierComponents =\n          channel.substring(TRANSFER_ARCHIVE_KEYSPACE_PATTERN.length() - 1).split(\":\", 4);\n\n      if (deviceIdentifierComponents.length != 4) {\n        logger.error(\"Could not parse device identifier; unexpected component count: {}\",\n            deviceIdentifierComponents.length);\n        return;\n      }\n\n      final DeviceIdentifier deviceIdentifier;\n      final String transferArchiveKey;\n      try {\n        final UUID accountIdentifier = UUID.fromString(deviceIdentifierComponents[0]);\n        final byte deviceId = Byte.parseByte(deviceIdentifierComponents[1]);\n\n        final String registrationIdPattern = deviceIdentifierComponents[2];\n        if (!registrationIdPattern.equals(TRANSFER_ARCHIVE_REGISTRATION_ID_PATTERN)) {\n          throw new IllegalArgumentException(\"Could not parse Redis key with pattern \" + registrationIdPattern);\n        }\n        final int registrationId = Integer.parseInt(deviceIdentifierComponents[3]);\n        if (!RegistrationIdValidator.validRegistrationId(registrationId)) {\n          throw new IllegalArgumentException(\"Invalid registration ID: \" + registrationId);\n        }\n        deviceIdentifier = new DeviceIdentifier(accountIdentifier, deviceId, registrationId);\n        transferArchiveKey = getRegistrationIdTransferArchiveKey(accountIdentifier, deviceId, registrationId);\n\n        Optional.ofNullable(waitForTransferArchiveFuturesByDeviceIdentifier.remove(deviceIdentifier))\n            .ifPresent(future -> pubSubRedisClient.withConnection(connection -> connection.async().get(transferArchiveKey))\n                .whenComplete((transferArchiveJson, throwable) -> {\n                  if (throwable != null) {\n                    future.completeExceptionally(throwable);\n                  } else {\n                    handleTransferArchiveAdded(future, transferArchiveJson);\n                  }\n                }));\n      } catch (final IllegalArgumentException e) {\n        logger.error(\"Could not parse device identifier\", e);\n      }\n    } else if (RESTORE_ACCOUNT_REQUEST_KEYSPACE_PATTERN.equalsIgnoreCase(pattern) && \"set\".equalsIgnoreCase(message)) {\n      // The `- 1` here compensates for the '*' in the pattern\n      final String token = channel.substring(RESTORE_ACCOUNT_REQUEST_KEYSPACE_PATTERN.length() - 1);\n\n      Optional.ofNullable(waitForRestoreAccountRequestFuturesByToken.remove(token))\n          .ifPresent(future -> pubSubRedisClient.withConnection(connection -> connection.async().get(\n                  getRestoreAccountRequestKey(token)))\n              .whenComplete((requestJson, throwable) -> {\n                if (throwable != null) {\n                  future.completeExceptionally(throwable);\n                } else {\n                  handleRestoreAccountRequest(future, requestJson);\n                }\n              }));\n    }\n  }\n\n  private static MessageDigest getSha256MessageDigest() {\n    try {\n      return MessageDigest.getInstance(\"SHA-256\");\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(\"Every implementation of the Java platform is required to support the SHA-256 MessageDigest algorithm\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManager.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Clock;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessage;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\n\npublic class ChangeNumberManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(ChangeNumberManager.class);\n  private final MessageSender messageSender;\n  private final AccountsManager accountsManager;\n  private final Clock clock;\n\n  public ChangeNumberManager(\n      final MessageSender messageSender,\n      final AccountsManager accountsManager,\n      final Clock clock) {\n\n    this.messageSender = messageSender;\n    this.accountsManager = accountsManager;\n    this.clock = clock;\n  }\n\n  public Account changeNumber(final Account account,\n      final String number,\n      final IdentityKey pniIdentityKey,\n      final Map<Byte, ECSignedPreKey> deviceSignedPreKeys,\n      final Map<Byte, KEMSignedPreKey> devicePqLastResortPreKeys,\n      final List<IncomingMessage> deviceMessages,\n      final Map<Byte, Integer> pniRegistrationIds,\n      final String senderUserAgent)\n      throws InterruptedException, MismatchedDevicesException, MessageTooLargeException {\n\n    final long serverTimestamp = clock.millis();\n    final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(account.getIdentifier(IdentityType.ACI));\n\n    // Note that these for-validation envelopes do NOT have the \"updated PNI\" field set, and we'll need to populate that\n    // after actually changing the account's number.\n    final Map<Byte, Envelope> messagesByDeviceId = deviceMessages.stream()\n        .collect(Collectors.toMap(IncomingMessage::destinationDeviceId, message -> message.toEnvelope(serviceIdentifier,\n            serviceIdentifier,\n            Device.PRIMARY_ID,\n            serverTimestamp,\n            false,\n            false,\n            true,\n            null,\n            clock)));\n\n    final Map<Byte, Integer> registrationIdsByDeviceId = deviceMessages.stream()\n        .collect(Collectors.toMap(IncomingMessage::destinationDeviceId, IncomingMessage::destinationRegistrationId));\n\n    // Make sure we can plausibly deliver the messages to other devices on the account before making any changes to the\n    // account itself\n    if (!messagesByDeviceId.isEmpty()) {\n      MessageSender.validateIndividualMessageBundle(account,\n          serviceIdentifier,\n          messagesByDeviceId,\n          registrationIdsByDeviceId,\n          Optional.of(Device.PRIMARY_ID),\n          senderUserAgent);\n    }\n\n    final Account updatedAccount = accountsManager.changeNumber(\n        account, number, pniIdentityKey, deviceSignedPreKeys, devicePqLastResortPreKeys, pniRegistrationIds);\n\n    try {\n      // Now that we've actually updated the account, populate the \"updated PNI\" field on all envelopes\n      final String updatedPniString = updatedAccount.getIdentifier(IdentityType.PNI).toString();\n\n      messagesByDeviceId.replaceAll((deviceId, envelope) ->\n          envelope.toBuilder().setUpdatedPni(updatedPniString).build());\n\n      messageSender.sendMessages(updatedAccount,\n          serviceIdentifier,\n          messagesByDeviceId,\n          registrationIdsByDeviceId,\n          Optional.of(Device.PRIMARY_ID),\n          senderUserAgent);\n    } catch (final RuntimeException e) {\n      logger.warn(\"Changed number but could not send all device messages for {}\", account.getIdentifier(IdentityType.ACI), e);\n      throw e;\n    }\n\n    return updatedAccount;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ChunkProcessingFailedException.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class ChunkProcessingFailedException extends Exception {\n\n  public ChunkProcessingFailedException(String message) {\n    super(message);\n  }\n\n  public ChunkProcessingFailedException(Exception cause) {\n    super(cause);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientRelease.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.vdurmont.semver4j.Semver;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport java.time.Instant;\n\npublic record ClientRelease(ClientPlatform platform, Semver version, Instant release, Instant expiration) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientReleaseManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.vdurmont.semver4j.Semver;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.Map;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport io.dropwizard.lifecycle.Managed;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport javax.annotation.Nullable;\n\npublic class ClientReleaseManager implements Managed {\n\n  private final ClientReleases clientReleases;\n  private final ScheduledExecutorService scheduledExecutorService;\n  private final Duration refreshInterval;\n  private final Clock clock;\n\n  @Nullable\n  private ScheduledFuture<?> refreshClientReleasesFuture;\n\n  private volatile Map<ClientPlatform, Map<Semver, ClientRelease>> clientReleasesByPlatform = Collections.emptyMap();\n\n  private static final Logger logger = LoggerFactory.getLogger(ClientReleaseManager.class);\n\n  public ClientReleaseManager(final ClientReleases clientReleases,\n      final ScheduledExecutorService scheduledExecutorService,\n      final Duration refreshInterval,\n      final Clock clock) {\n\n    this.clientReleases = clientReleases;\n    this.scheduledExecutorService = scheduledExecutorService;\n    this.refreshInterval = refreshInterval;\n    this.clock = clock;\n  }\n\n  public boolean isVersionActive(final ClientPlatform platform, final Semver version) {\n    final Map<Semver, ClientRelease> releasesByVersion = clientReleasesByPlatform.get(platform);\n\n    return releasesByVersion != null &&\n        releasesByVersion.containsKey(version) &&\n        releasesByVersion.get(version).expiration().isAfter(clock.instant());\n  }\n\n  @Override\n  public void start() throws Exception {\n    refreshClientVersions();\n\n    refreshClientReleasesFuture =\n        scheduledExecutorService.scheduleWithFixedDelay(this::refreshClientVersions,\n            refreshInterval.toMillis(),\n            refreshInterval.toMillis(),\n            TimeUnit.MILLISECONDS);\n  }\n\n  @Override\n  public void stop() throws Exception {\n    if (refreshClientReleasesFuture != null) {\n      refreshClientReleasesFuture.cancel(true);\n    }\n  }\n\n  void refreshClientVersions() {\n    try {\n      clientReleasesByPlatform = clientReleases.getClientReleases();\n\n      logger.debug(\"Loaded client releases; android: {}, desktop: {}, ios: {}\",\n          clientReleasesByPlatform.getOrDefault(ClientPlatform.ANDROID, Collections.emptyMap()).size(),\n          clientReleasesByPlatform.getOrDefault(ClientPlatform.DESKTOP, Collections.emptyMap()).size(),\n          clientReleasesByPlatform.getOrDefault(ClientPlatform.IOS, Collections.emptyMap()).size());\n    } catch (final Exception e) {\n      logger.warn(\"Failed to refresh client releases\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ClientReleases.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.vdurmont.semver4j.Semver;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport reactor.core.publisher.Flux;\nimport reactor.util.function.Tuple2;\nimport reactor.util.function.Tuples;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport javax.annotation.Nullable;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.Map;\n\npublic class ClientReleases {\n\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n\n  public static final String ATTR_PLATFORM = \"P\";\n  public static final String ATTR_VERSION = \"V\";\n  public static final String ATTR_RELEASE_TIMESTAMP = \"T\";\n  public static final String ATTR_EXPIRATION = \"E\";\n\n  private static final Logger logger = LoggerFactory.getLogger(ClientReleases.class);\n\n  public ClientReleases(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n  }\n\n  public Map<ClientPlatform, Map<Semver, ClientRelease>> getClientReleases() {\n    return Collections.unmodifiableMap(\n        Flux.from(dynamoDbAsyncClient.scanPaginator(ScanRequest.builder()\n                    .tableName(tableName)\n                    .build())\n                .items())\n            .mapNotNull(ClientReleases::releaseFromItem)\n            .groupBy(ClientRelease::platform)\n            .flatMap(groupedFlux -> groupedFlux.collectMap(ClientRelease::version)\n                .map(releasesByVersion -> Tuples.of(groupedFlux.key(), releasesByVersion)))\n            .collectMap(Tuple2::getT1, Tuple2::getT2)\n            .blockOptional()\n            .orElseGet(Collections::emptyMap));\n  }\n\n  @Nullable\n  static ClientRelease releaseFromItem(final Map<String, AttributeValue> item) {\n    try {\n      final ClientPlatform platform = ClientPlatform.valueOf(item.get(ATTR_PLATFORM).s());\n      final Semver version = new Semver(item.get(ATTR_VERSION).s());\n      final Instant release = Instant.ofEpochSecond(Long.parseLong(item.get(ATTR_RELEASE_TIMESTAMP).n()));\n      final Instant expiration = Instant.ofEpochSecond(Long.parseLong(item.get(ATTR_EXPIRATION).n()));\n\n      return new ClientRelease(platform, version, release, expiration);\n    } catch (final Exception e) {\n      logger.warn(\"Failed to parse client release item\", e);\n      return null;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ConflictingMessageConsumerException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.whispersystems.textsecuregcm.util.NoStackTraceException;\n\n/// Indicates that more than one consumer is trying to read a specific message queue at the same time.\npublic class ConflictingMessageConsumerException extends NoStackTraceException {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ContestedOptimisticLockException.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.whispersystems.textsecuregcm.util.NoStackTraceRuntimeException;\n\npublic class ContestedOptimisticLockException extends NoStackTraceRuntimeException {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/Device.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonSetter;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport javax.annotation.Nullable;\nimport com.google.common.annotations.VisibleForTesting;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.DeviceCapabilityAdapter;\nimport org.whispersystems.textsecuregcm.util.DeviceNameByteArrayAdapter;\n\npublic class Device {\n\n  public static final byte PRIMARY_ID = 1;\n  public static final byte MAXIMUM_DEVICE_ID = Byte.MAX_VALUE;\n  public static final int MAX_REGISTRATION_ID = 0x3FFF;\n  public static final List<Byte> ALL_POSSIBLE_DEVICE_IDS = IntStream.range(Device.PRIMARY_ID, MAXIMUM_DEVICE_ID).boxed()\n      .map(Integer::byteValue).collect(Collectors.toList());\n\n  private static final long ALLOWED_LINKED_IDLE_MILLIS = Duration.ofDays(45).toMillis();\n  private static final long ALLOWED_PRIMARY_IDLE_MILLIS = Duration.ofDays(180).toMillis();\n\n  @JsonDeserialize(using = DeviceIdDeserializer.class)\n  @JsonProperty\n  private byte id;\n\n  @JsonProperty\n  @JsonSerialize(using = DeviceNameByteArrayAdapter.Serializer.class)\n  @JsonDeserialize(using = DeviceNameByteArrayAdapter.Deserializer.class)\n  private byte[] name;\n\n  @JsonProperty(\"createdAt\")\n  @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n  @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n  private byte[] createdAtCiphertext;\n\n  @JsonProperty\n  private String  authToken;\n\n  @JsonProperty\n  private String  salt;\n\n  @JsonProperty\n  private String  gcmId;\n\n  @JsonProperty\n  private String  apnId;\n\n  @JsonProperty\n  private long pushTimestamp;\n\n  @JsonProperty\n  private boolean fetchesMessages;\n\n  @JsonProperty\n  private int registrationId;\n\n  @JsonProperty(\"pniRegistrationId\")\n  private int phoneNumberIdentityRegistrationId;\n\n  @JsonProperty\n  private long lastSeen;\n\n  @JsonProperty\n  private long created;\n\n  @JsonProperty\n  private String userAgent;\n\n  @JsonProperty\n  @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class)\n  @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class)\n  private Set<DeviceCapability> capabilities = Collections.emptySet();\n\n  public String getApnId() {\n    return apnId;\n  }\n\n  public void setApnId(String apnId) {\n    this.apnId = apnId;\n\n    if (apnId != null) {\n      this.pushTimestamp = System.currentTimeMillis();\n    }\n  }\n\n  public void setLastSeen(long lastSeen) {\n    this.lastSeen = lastSeen;\n  }\n\n  public long getLastSeen() {\n    return lastSeen;\n  }\n\n  public void setCreated(long created) {\n    this.created = created;\n  }\n\n  public long getCreated() {\n    return this.created;\n  }\n\n  public void setCreatedAtCiphertext(byte[] createdAtCiphertext) {\n    this.createdAtCiphertext = createdAtCiphertext;\n  }\n\n  public byte[] getCreatedAtCiphertext() {\n    return this.createdAtCiphertext;\n  }\n\n  public String getGcmId() {\n    return gcmId;\n  }\n\n  public void setGcmId(String gcmId) {\n    this.gcmId = gcmId;\n\n    if (gcmId != null) {\n      this.pushTimestamp = System.currentTimeMillis();\n    }\n  }\n\n  public byte getId() {\n    return id;\n  }\n\n  public void setId(byte id) {\n    this.id = id;\n  }\n\n  public byte[] getName() {\n    return name;\n  }\n\n  public void setName(byte[] name) {\n    this.name = name;\n  }\n\n  public void setAuthTokenHash(SaltedTokenHash credentials) {\n    this.authToken = credentials.hash();\n    this.salt      = credentials.salt();\n  }\n\n  /**\n   * Has this device been manually locked?\n   *\n   * We lock a device by prepending \"!\" to its token.\n   * This character cannot normally appear in valid tokens.\n   *\n   * @return true if the credential was locked, false otherwise.\n   */\n  public boolean hasLockedCredentials() {\n    SaltedTokenHash auth = getAuthTokenHash();\n    return auth.hash().startsWith(\"!\");\n  }\n\n  /**\n   * Lock device by invalidating authentication tokens.\n   *\n   * This should only be used from Account::lockAuthenticationCredentials.\n   *\n   * See that method for more information.\n   */\n  public void lockAuthTokenHash() {\n    SaltedTokenHash oldAuth = getAuthTokenHash();\n    String token = \"!\" + oldAuth.hash();\n    String salt = oldAuth.salt();\n    setAuthTokenHash(new SaltedTokenHash(token, salt));\n  }\n\n  public SaltedTokenHash getAuthTokenHash() {\n    return new SaltedTokenHash(authToken, salt);\n  }\n\n  @VisibleForTesting\n  public Set<DeviceCapability> getCapabilities() {\n    return capabilities;\n  }\n\n  @JsonSetter\n  public void setCapabilities(@Nullable final Set<DeviceCapability> capabilities) {\n    this.capabilities = (capabilities == null || capabilities.isEmpty())\n        ? Collections.emptySet()\n        : EnumSet.copyOf(capabilities);\n  }\n\n  public boolean hasCapability(final DeviceCapability capability) {\n    return capabilities.contains(capability);\n  }\n\n  public boolean isExpired() {\n    return isPrimary()\n        ? lastSeen < (System.currentTimeMillis() - ALLOWED_PRIMARY_IDLE_MILLIS)\n        : lastSeen < (System.currentTimeMillis() - ALLOWED_LINKED_IDLE_MILLIS);\n  }\n\n  public boolean getFetchesMessages() {\n    return fetchesMessages;\n  }\n\n  public void setFetchesMessages(boolean fetchesMessages) {\n    this.fetchesMessages = fetchesMessages;\n  }\n\n  public boolean isPrimary() {\n    return getId() == PRIMARY_ID;\n  }\n\n  public int getRegistrationId(final IdentityType identityType) {\n    return switch (identityType) {\n      case ACI -> registrationId;\n      case PNI -> phoneNumberIdentityRegistrationId;\n    };\n  }\n\n  public void setRegistrationId(int registrationId) {\n    this.registrationId = registrationId;\n  }\n\n  public void setPhoneNumberIdentityRegistrationId(final int phoneNumberIdentityRegistrationId) {\n    this.phoneNumberIdentityRegistrationId = phoneNumberIdentityRegistrationId;\n  }\n\n  public long getPushTimestamp() {\n    return pushTimestamp;\n  }\n\n  public void setUserAgent(String userAgent) {\n    this.userAgent = userAgent;\n  }\n\n  public String getUserAgent() {\n    return this.userAgent;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceCapability.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.Arrays;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic enum DeviceCapability {\n  STORAGE(\"storage\", AccountCapabilityMode.ANY_DEVICE, false, false, false),\n  TRANSFER(\"transfer\", AccountCapabilityMode.PRIMARY_DEVICE, false, false, false),\n  ATTACHMENT_BACKFILL(\"attachmentBackfill\", AccountCapabilityMode.PRIMARY_DEVICE, false, true, false),\n  SPARSE_POST_QUANTUM_RATCHET(\"spqr\", AccountCapabilityMode.ALL_DEVICES, true, true, true);\n\n  public enum AccountCapabilityMode {\n    /**\n     * The account will have the capability iff the primary device has the capability\n     */\n    PRIMARY_DEVICE,\n    /**\n     * The account will have the capability iff any device on the account has the capability\n     */\n    ANY_DEVICE,\n    /**\n     * The account will have the capability iff all devices on the account have the capability\n     */\n    ALL_DEVICES,\n    /**\n     * The account always has this capability, regardless of the constituent devices' capabilities.\n     * This supports retiring capabilities where older clients still need the field provided.\n     */\n    ALWAYS_CAPABLE,\n  }\n\n  public static final Set<DeviceCapability> CAPABILITIES_REQUIRED_FOR_REGISTRATION =\n      Arrays.stream(DeviceCapability.values())\n          .filter(DeviceCapability::requireForRegistration)\n          .collect(Collectors.toSet());\n\n  private final String name;\n  private final AccountCapabilityMode accountCapabilityMode;\n  private final boolean preventDowngrade;\n  private final boolean includeInProfile;\n  private final boolean requireForRegistration;\n\n  /**\n   * Create a DeviceCapability\n   *\n   * @param name                   The name of the device capability that clients will see\n   * @param accountCapabilityMode  How to combine the constituent device's capabilities in the account to an overall\n   *                               account capability\n   * @param preventDowngrade       If true, don't let linked devices join that don't have a device capability if the\n   *                               overall account has the capability. Most of the time this should only be used in\n   *                               conjunction with AccountCapabilityMode.ALL_DEVICES.\n   * @param includeInProfile       Whether to return this capability on the account's profile. If false, the capability\n   *                               is only visible to the server.\n   * @param requireForRegistration If true, prevent account creation if the account's initial device does not have this\n   *                               capability\n   */\n  DeviceCapability(final String name,\n      final AccountCapabilityMode accountCapabilityMode,\n      final boolean preventDowngrade,\n      final boolean includeInProfile,\n      final boolean requireForRegistration) {\n\n    this.name = name;\n    this.accountCapabilityMode = accountCapabilityMode;\n    this.preventDowngrade = preventDowngrade;\n    this.includeInProfile = includeInProfile;\n    this.requireForRegistration = requireForRegistration;\n  }\n\n  public String getName() {\n    return name;\n  }\n\n  public AccountCapabilityMode getAccountCapabilityMode() {\n    return accountCapabilityMode;\n  }\n\n  public boolean preventDowngrade() {\n    return preventDowngrade;\n  }\n\n  public boolean includeInProfile() {\n    return includeInProfile;\n  }\n\n  public boolean requireForRegistration() {\n    return requireForRegistration;\n  }\n\n  public static Optional<DeviceCapability> forName(final String name) {\n    for (final DeviceCapability capability : DeviceCapability.values()) {\n      if (capability.getName().equals(name)) {\n        return Optional.of(capability);\n      }\n    }\n    return Optional.empty();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceIdDeserializer.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport java.io.IOException;\n\n/**\n * The built-in {@link com.fasterxml.jackson.databind.deser.std.NumberDeserializers.ByteDeserializer} will return\n * negative values&mdash;both verbatim and by coercing 128&hellip;255. We prefer this invalid data to fail fast, so this\n * is a simpler and stricter deserializer.\n */\npublic class DeviceIdDeserializer extends JsonDeserializer<Byte> {\n\n  @Override\n  public Byte deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {\n\n    byte value = p.getByteValue();\n\n    if (value < Device.PRIMARY_ID) {\n      throw new DeviceIdDeserializationException();\n    }\n\n    return value;\n  }\n\n  static class DeviceIdDeserializationException extends IOException {\n\n    DeviceIdDeserializationException() {\n      super(\"Invalid Device ID\");\n    }\n\n  }\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceKEMPreKeyPages.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\n\n/**\n * The prekey pages stored for a particular device\n *\n * @param identifier           The account identifier or phone number identifier that the keys belong to\n * @param deviceId             The device identifier\n * @param currentPage          If present, the active stored page prekeys are being distributed from\n * @param pageIdToLastModified The last modified time for all the device's stored pages, keyed by the pageId\n */\npublic record DeviceKEMPreKeyPages(\n    UUID identifier, byte deviceId,\n    Optional<UUID> currentPage,\n    Map<UUID, Instant> pageIdToLastModified) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/DeviceSpec.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Clock;\nimport java.util.Arrays;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.entities.ApnRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.GcmRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.util.EncryptDeviceCreationTimestampUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\n\npublic record DeviceSpec(\n    byte[] deviceNameCiphertext,\n    String password,\n    String signalAgent,\n    Set<DeviceCapability> capabilities,\n    int aciRegistrationId,\n    int pniRegistrationId,\n    boolean fetchesMessages,\n    Optional<ApnRegistrationId> apnRegistrationId,\n    Optional<GcmRegistrationId> gcmRegistrationId,\n    ECSignedPreKey aciSignedPreKey,\n    ECSignedPreKey pniSignedPreKey,\n    KEMSignedPreKey aciPqLastResortPreKey,\n    KEMSignedPreKey pniPqLastResortPreKey) {\n  \n  public Device toDevice(final byte deviceId, final Clock clock, final IdentityKey aciIdentityKey) {\n    final long created = clock.millis();\n\n    final Device device = new Device();\n    device.setId(deviceId);\n    device.setAuthTokenHash(SaltedTokenHash.generateFor(password()));\n    device.setFetchesMessages(fetchesMessages());\n    device.setRegistrationId(aciRegistrationId());\n    device.setPhoneNumberIdentityRegistrationId(pniRegistrationId());\n    device.setName(deviceNameCiphertext());\n    device.setCapabilities(capabilities());\n    device.setCreated(created);\n    device.setCreatedAtCiphertext(\n        EncryptDeviceCreationTimestampUtil.encrypt(created, aciIdentityKey, deviceId, aciRegistrationId()));\n    device.setLastSeen(Util.todayInMillis());\n    device.setUserAgent(signalAgent());\n\n    apnRegistrationId().ifPresent(apnRegistrationId -> device.setApnId(apnRegistrationId.apnRegistrationId()));\n    gcmRegistrationId().ifPresent(gcmRegistrationId -> device.setGcmId(gcmRegistrationId.gcmRegistrationId()));\n\n    return device;\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n\n    final DeviceSpec that = (DeviceSpec) o;\n\n    return aciRegistrationId == that.aciRegistrationId\n        && pniRegistrationId == that.pniRegistrationId\n        && fetchesMessages == that.fetchesMessages\n        && Arrays.equals(deviceNameCiphertext, that.deviceNameCiphertext)\n        && Objects.equals(password, that.password)\n        && Objects.equals(signalAgent, that.signalAgent)\n        && Objects.equals(capabilities, that.capabilities)\n        && Objects.equals(apnRegistrationId, that.apnRegistrationId)\n        && Objects.equals(gcmRegistrationId, that.gcmRegistrationId)\n        && Objects.equals(aciSignedPreKey, that.aciSignedPreKey)\n        && Objects.equals(pniSignedPreKey, that.pniSignedPreKey)\n        && Objects.equals(aciPqLastResortPreKey, that.aciPqLastResortPreKey)\n        && Objects.equals(pniPqLastResortPreKey, that.pniPqLastResortPreKey);\n  }\n\n  @Override\n  public int hashCode() {\n    int result = Objects.hash(password, signalAgent, capabilities, aciRegistrationId, pniRegistrationId,\n        fetchesMessages, apnRegistrationId, gcmRegistrationId, aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey,\n        pniPqLastResortPreKey);\n    result = 31 * result + Arrays.hashCode(deviceNameCiphertext);\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport jakarta.validation.ConstraintViolation;\nimport jakarta.validation.Validation;\nimport jakarta.validation.Validator;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.atomic.AtomicReference;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class DynamicConfigurationManager<T> {\n\n  private final S3ObjectMonitor configMonitor;\n  private final Class<T> configurationClass;\n\n  // Set on initial config fetch\n  private final AtomicReference<T> configuration = new AtomicReference<>();\n  private final CountDownLatch initialized = new CountDownLatch(1);\n\n  private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();\n\n  private static final String ERROR_COUNTER_NAME = name(DynamicConfigurationManager.class, \"error\");\n  private static final String ERROR_TYPE_TAG_NAME = \"type\";\n  private static final String CONFIG_CLASS_TAG_NAME = \"configClass\";\n\n  private static final Logger logger = LoggerFactory.getLogger(DynamicConfigurationManager.class);\n\n  public DynamicConfigurationManager(final S3ObjectMonitor configMonitor, final Class<T> configurationClass) {\n    this.configMonitor = configMonitor;\n    this.configurationClass = configurationClass;\n  }\n\n  public T getConfiguration() {\n    try {\n      initialized.await();\n    } catch (InterruptedException e) {\n      logger.warn(\"Interrupted while waiting for initial configuration\", e);\n      throw new RuntimeException(e);\n    }\n    return configuration.get();\n  }\n\n  public synchronized void start() {\n    if (initialized.getCount() == 0) {\n      return;\n    }\n\n    this.configMonitor.start(this::receiveConfiguration);\n\n    // Starting an S3ObjectMonitor immediately does a blocking retrieve of the data, but it might\n    // fail to parse, in which case we wait for an update (which will happen on another thread) to\n    // give us a valid configuration before marking ourselves ready\n    while (configuration.get() == null) {\n      logger.warn(\"Failed to retrieve or parse initial dynamic configuration\");\n      try {\n        this.wait();\n      } catch (InterruptedException e) {}\n    }\n    initialized.countDown();\n  }\n\n  private synchronized void receiveConfiguration(InputStream configDataStream) {\n    final String configData;\n    try {\n      configData = new String(configDataStream.readAllBytes());\n    } catch (IOException e) {\n      Metrics.counter(ERROR_COUNTER_NAME, ERROR_TYPE_TAG_NAME, \"fetch\").increment();\n      return;\n    }\n\n    logger.info(\"Received new dynamic configuration of length {}\", configData.length());\n    parseConfiguration(configData, configurationClass).ifPresent(configuration::set);\n    this.notify();\n  }\n\n  @VisibleForTesting\n  public static <T> Optional<T> parseConfiguration(final String configurationYaml, final Class<T> configurationClass) {\n    final T configuration;\n    try {\n      configuration = SystemMapper.yamlMapper().readValue(configurationYaml, configurationClass);\n    } catch (final IOException e) {\n      logger.warn(\"Failed to parse dynamic configuration\", e);\n      Metrics.counter(ERROR_COUNTER_NAME,\n          ERROR_TYPE_TAG_NAME, \"parse\",\n          CONFIG_CLASS_TAG_NAME, configurationClass.getName()).increment();\n      return Optional.empty();\n    }\n\n    final Set<ConstraintViolation<T>> violations = VALIDATOR.validate(configuration);\n\n    if (!violations.isEmpty()) {\n      logger.warn(\"Failed to validate configuration: {}\", violations);\n      Metrics.counter(ERROR_COUNTER_NAME,\n          ERROR_TYPE_TAG_NAME, \"validate\",\n          CONFIG_CLASS_TAG_NAME, configurationClass.getName()).increment();\n      return Optional.empty();\n    }\n\n    return Optional.of(configuration);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/DynamoDbRecoveryManager.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.concurrent.CompletableFuture;\n\n/**\n * The DynamoDB recovery manager regenerates data for secondary tables in a disaster recovery scenario. In a disaster\n * recovery scenario, there is no guarantee that table backups will be consistent, and so we need to derive or update\n * some tables from a \"core\" data source to ensure consistency.\n */\npublic class DynamoDbRecoveryManager {\n\n  private final Accounts accounts;\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers;\n\n  public DynamoDbRecoveryManager(final Accounts accounts, final PhoneNumberIdentifiers phoneNumberIdentifiers) {\n    this.accounts = accounts;\n    this.phoneNumberIdentifiers = phoneNumberIdentifiers;\n  }\n\n  /**\n   * Regenerates secondary data (i.e. uniqueness constraints) for a given account.\n   *\n   * @param account the account for which to regenerate secondary data\n   *\n   * @return a future that completes when secondary for the given account has been regenerated\n   */\n  public CompletableFuture<Void> regenerateData(final Account account) {\n    return CompletableFuture.allOf(\n        accounts.regenerateConstraints(account),\n        phoneNumberIdentifiers.regeneratePhoneNumberIdentifierMappings(account));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/EnvelopeUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.UUID;\nimport com.google.common.annotations.VisibleForTesting;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.grpc.ServiceIdentifierUtil;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\n/**\n * Provides utility methods for \"compressing\" and \"expanding\" envelopes. Historically UUID-like fields in envelopes have\n * been represented as strings (e.g. \"c15f1dfb-ae2c-43a8-9bb9-baba97ac416c\"), but <em>could</em> be represented as more\n * compact byte arrays instead. Existing clients generally expect string representations (though that should change in\n * the near future), but we can use the more compressed forms at rest for more efficient storage and transfer.\n */\npublic class EnvelopeUtil {\n\n  @VisibleForTesting\n  static final String INCLUDE_BINARY_SERVICE_ID_EXPERIMENT_NAME = \"envelopeIncludeBinaryServiceIdentifier\";\n\n  /**\n   * Converts all \"compressible\" UUID-like fields in the given envelope to more compact binary representations.\n   *\n   * @param envelope the envelope to compress\n   *\n   * @return an envelope with string-based UUID-like fields compressed to binary representations\n   */\n  public static MessageProtos.Envelope compress(final MessageProtos.Envelope envelope) {\n    final MessageProtos.Envelope.Builder builder = envelope.toBuilder();\n    \n    if (builder.hasSourceServiceId()) {\n      final ServiceIdentifier sourceServiceId = ServiceIdentifier.valueOf(builder.getSourceServiceId());\n      \n      builder.setSourceServiceIdBinary(ServiceIdentifierUtil.toCompactByteString(sourceServiceId));\n      builder.clearSourceServiceId();\n    }\n\n    if (builder.hasDestinationServiceId()) {\n      final ServiceIdentifier destinationServiceId = ServiceIdentifier.valueOf(builder.getDestinationServiceId());\n\n      builder.setDestinationServiceIdBinary(ServiceIdentifierUtil.toCompactByteString(destinationServiceId));\n      builder.clearDestinationServiceId();\n    }\n\n    if (builder.hasServerGuid()) {\n      final UUID serverGuid = UUID.fromString(builder.getServerGuid());\n\n      builder.setServerGuidBinary(UUIDUtil.toByteString(serverGuid));\n      builder.clearServerGuid();\n    }\n\n    if (builder.hasUpdatedPni()) {\n      final UUID updatedPni = UUID.fromString(builder.getUpdatedPni());\n\n      builder.setUpdatedPniBinary(UUIDUtil.toByteString(updatedPni));\n      builder.clearUpdatedPni();\n    }\n\n    return builder.build();\n  }\n\n  /**\n   * \"Expands\" all binary representations of UUID-like fields to string representations to meet current client\n   * expectations.\n   *\n   * @param envelope the envelope to expand\n   *\n   * @return an envelope with binary representations of UUID-like fields expanded to string representations\n   */\n  public static MessageProtos.Envelope expand(final MessageProtos.Envelope envelope,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) {\n\n    final boolean includeBinaryServiceIdentifiers;\n\n    if (envelope.hasDestinationServiceIdBinary() || envelope.hasDestinationServiceId()) {\n      final ServiceIdentifier destinationIdentifier = envelope.hasDestinationServiceIdBinary()\n          ? ServiceIdentifier.fromBytes(envelope.getDestinationServiceIdBinary().toByteArray())\n          : ServiceIdentifier.valueOf(envelope.getDestinationServiceId());\n\n      includeBinaryServiceIdentifiers =\n          experimentEnrollmentManager.isEnrolled(destinationIdentifier.uuid(), INCLUDE_BINARY_SERVICE_ID_EXPERIMENT_NAME);\n    } else {\n      includeBinaryServiceIdentifiers = false;\n    }\n\n    final MessageProtos.Envelope.Builder builder = envelope.toBuilder();\n\n    if (builder.hasSourceServiceIdBinary()) {\n      final ServiceIdentifier sourceServiceId =\n          ServiceIdentifierUtil.fromByteString(builder.getSourceServiceIdBinary());\n\n      builder.setSourceServiceId(sourceServiceId.toServiceIdentifierString());\n\n      if (!includeBinaryServiceIdentifiers) {\n        builder.clearSourceServiceIdBinary();\n      }\n    }\n\n    if (builder.hasDestinationServiceIdBinary()) {\n      final ServiceIdentifier destinationServiceId =\n          ServiceIdentifierUtil.fromByteString(builder.getDestinationServiceIdBinary());\n\n      builder.setDestinationServiceId(destinationServiceId.toServiceIdentifierString());\n\n      if (!includeBinaryServiceIdentifiers) {\n        builder.clearDestinationServiceIdBinary();\n      }\n    }\n\n    if (builder.hasServerGuidBinary()) {\n      final UUID serverGuid = UUIDUtil.fromByteString(builder.getServerGuidBinary());\n      \n      builder.setServerGuid(serverGuid.toString());\n\n      if (!includeBinaryServiceIdentifiers) {\n        builder.clearServerGuidBinary();\n      }\n    }\n\n    if (builder.hasUpdatedPniBinary()) {\n      final UUID updatedPni = UUIDUtil.fromByteString(builder.getUpdatedPniBinary());\n\n      // Note that expanded envelopes include BOTH forms of the `updatedPni` field\n      builder.setUpdatedPni(updatedPni.toString());\n    }\n\n    return builder.build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManager.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.b;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.n;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.s;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.base.Throwables;\nimport jakarta.ws.rs.ClientErrorException;\nimport jakarta.ws.rs.core.Response.Status;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.EnumMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.function.Consumer;\nimport javax.annotation.Nonnull;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\npublic class IssuedReceiptsManager {\n\n  public static final String KEY_PROCESSOR_ITEM_ID = \"A\";  // S  (HashKey)\n  public static final String KEY_EXPIRATION = \"E\";  // N\n  public static final String KEY_ISSUED_RECEIPT_TAG_SET = \"T\"; // BS\n\n  private final String table;\n  private final Duration expiration;\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final byte[] receiptTagGenerator;\n  private final EnumMap<PaymentProvider, Integer> maxIssuedReceiptsPerPaymentId;\n\n  public IssuedReceiptsManager(\n      @Nonnull String table,\n      @Nonnull Duration expiration,\n      @Nonnull DynamoDbAsyncClient dynamoDbAsyncClient,\n      @Nonnull byte[] receiptTagGenerator,\n      @Nonnull EnumMap<PaymentProvider, Integer> maxIssuedReceiptsPerPaymentId) {\n    this.table = Objects.requireNonNull(table);\n    this.expiration = Objects.requireNonNull(expiration);\n    this.dynamoDbAsyncClient = Objects.requireNonNull(dynamoDbAsyncClient);\n    this.receiptTagGenerator = Objects.requireNonNull(receiptTagGenerator);\n    this.maxIssuedReceiptsPerPaymentId = Objects.requireNonNull(maxIssuedReceiptsPerPaymentId);\n  }\n\n  /**\n   * Returns a future that completes normally if either this processor item was never issued a receipt credential\n   * previously OR if it was issued a receipt credential previously for the exact same receipt credential request\n   * enabling clients to retry in case they missed the original response.\n   * <p>\n   * If this item has already been used to issue another receipt, throws a 409 conflict web application exception.\n   * <p>\n   * For {@link PaymentProvider#STRIPE}, item is expected to refer to an invoice line item (subscriptions) or a\n   * payment intent (one-time).\n   */\n  public CompletableFuture<Void> recordIssuance(\n      String processorItemId,\n      PaymentProvider processor,\n      ReceiptCredentialRequest request,\n      Instant now) {\n\n    final AttributeValue key = dynamoDbKey(processor, processorItemId);\n    final byte[] tag = generateIssuedReceiptTag(request);\n    UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_PROCESSOR_ITEM_ID, key))\n        .conditionExpression(\"attribute_not_exists(#key) OR contains(#tags, :tag) OR size(#tags) < :maxTags\")\n        .returnValues(ReturnValue.NONE)\n        .updateExpression(\"SET #exp = if_not_exists(#exp, :exp) ADD #tags :singletonTag\")\n        .expressionAttributeNames(Map.of(\n            \"#key\", KEY_PROCESSOR_ITEM_ID,\n            \"#tags\", KEY_ISSUED_RECEIPT_TAG_SET,\n            \"#exp\", KEY_EXPIRATION))\n        .expressionAttributeValues(Map.of(\n            \":tag\", b(tag),\n            \":singletonTag\", AttributeValue.fromBs(List.of(SdkBytes.fromByteArray(tag))),\n            \":exp\", n(now.plus(expiration).getEpochSecond()),\n            \":maxTags\", n(maxIssuedReceiptsPerPaymentId.get(processor))))\n        .build();\n    return dynamoDbAsyncClient.updateItem(updateItemRequest).handle((updateItemResponse, throwable) -> {\n      if (throwable != null) {\n        Throwable rootCause = Throwables.getRootCause(throwable);\n        if (rootCause instanceof ConditionalCheckFailedException) {\n          throw new ClientErrorException(Status.CONFLICT, rootCause);\n        }\n        Throwables.throwIfUnchecked(throwable);\n        throw new CompletionException(throwable);\n      }\n      return null;\n    });\n  }\n\n  /**\n   * Clear the recorded issuances for a particular item\n   *\n   * @param processorItemId The itemId within the processor to clear\n   * @param processor The processor\n   * @return a future that yields true if the item was deleted, false if the item already did not exist\n   */\n  public CompletableFuture<Boolean> clearIssuance(String processorItemId, PaymentProvider processor) {\n    final AttributeValue key = dynamoDbKey(processor, processorItemId);\n    final DeleteItemRequest deleteItemRequest = DeleteItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_PROCESSOR_ITEM_ID, key))\n        .returnValues(ReturnValue.ALL_OLD)\n        .build();\n    return dynamoDbAsyncClient.deleteItem(deleteItemRequest)\n        .thenApply(item -> item.hasAttributes() && !item.attributes().isEmpty());\n  }\n\n  @VisibleForTesting\n  static AttributeValue dynamoDbKey(final PaymentProvider processor, String processorItemId) {\n    if (processor == PaymentProvider.STRIPE) {\n      // As the first processor, Stripe’s IDs were not prefixed. Its item IDs have documented prefixes (`il_`, `pi_`)\n      // that will not collide with `SubscriptionProcessor` names\n      return s(processorItemId);\n    } else {\n      return s(processor.name() + \"_\" + processorItemId);\n    }\n  }\n\n\n  @VisibleForTesting\n  byte[] generateIssuedReceiptTag(ReceiptCredentialRequest request) {\n    return generateHmac(\"issuedReceiptTag\", mac -> mac.update(request.serialize()));\n  }\n\n  private byte[] generateHmac(String type, Consumer<Mac> byteConsumer) {\n    try {\n      Mac mac = Mac.getInstance(\"HmacSHA256\");\n      mac.init(new SecretKeySpec(receiptTagGenerator, \"HmacSHA256\"));\n      mac.update(type.getBytes(StandardCharsets.UTF_8));\n      byteConsumer.accept(mac);\n      return mac.doFinal();\n    } catch (NoSuchAlgorithmException | InvalidKeyException e) {\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/KEMPreKeyPage.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.nio.ByteBuffer;\nimport java.util.List;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\n\nclass KEMPreKeyPage {\n\n  static final byte FORMAT = 1;\n\n  // Serialized pages start with a 4 byte magic constant, followed by 3 bytes of 0s and then the format byte\n  static final int HEADER_MAGIC = 0xC21C6DB8;\n  static final int HEADER_SIZE = 8;\n  // Serialize bigendian to produce the serialized page header\n  private static final long HEADER = ((long) HEADER_MAGIC) << 32L | (long) FORMAT;\n\n  // The length of libsignal's serialized KEM public key, which is a single-byte version followed by the public key\n  private static final int SERIALIZED_PUBKEY_LENGTH = 1569;\n  private static final int SERIALIZED_SIGNATURE_LENGTH = 64;\n  private static final int KEY_ID_LENGTH = Long.BYTES;\n\n  // The internal prefix byte libsignal uses to indicate a key is of type KEMKeyType.KYBER_1024. Currently, this\n  // is the only type of key allowed to be written to a prekey page\n  private static final byte KEM_KEY_TYPE_KYBER_1024 = 0x08;\n\n  @VisibleForTesting\n  static final int SERIALIZED_PREKEY_LENGTH = KEY_ID_LENGTH + SERIALIZED_PUBKEY_LENGTH + SERIALIZED_SIGNATURE_LENGTH;\n\n  private KEMPreKeyPage() {}\n\n  /**\n   * Serialize the list of preKeys into a single buffer\n   *\n   * @param format the format to serialize as. Currently, the only valid format is {@link KEMPreKeyPage#FORMAT}\n   * @param preKeys the preKeys to serialize\n   * @return The serialized buffer and a format to store alongside the buffer\n   */\n  static ByteBuffer serialize(final byte format, final List<KEMSignedPreKey> preKeys) {\n    if (format != FORMAT) {\n      throw new IllegalArgumentException(\"Unknown format: \" + format + \", must be \" + FORMAT);\n    }\n\n    if (preKeys.isEmpty()) {\n      throw new IllegalArgumentException(\"PreKeys cannot be empty\");\n    }\n    final ByteBuffer buffer = ByteBuffer.allocate(HEADER_SIZE + SERIALIZED_PREKEY_LENGTH * preKeys.size());\n    buffer.putLong(HEADER);\n    for (KEMSignedPreKey preKey : preKeys) {\n\n      buffer.putLong(preKey.keyId());\n\n      final byte[] publicKeyBytes = preKey.serializedPublicKey();\n      if (publicKeyBytes[0] != KEM_KEY_TYPE_KYBER_1024) {\n        // 0x08 is libsignal's current KEM key format. If some future version of libsignal supports additional KEM\n        // keys, we'll have to roll out read support before rolling out write support. Otherwise, we may write keys\n        // to storage that are not readable by other chat instances.\n        throw new IllegalArgumentException(\"Format 1 only supports \" + KEM_KEY_TYPE_KYBER_1024 + \" public keys\");\n      }\n      if (publicKeyBytes.length != SERIALIZED_PUBKEY_LENGTH) {\n        throw new IllegalArgumentException(\"Unexpected public key length \" + publicKeyBytes.length);\n      }\n      buffer.put(publicKeyBytes);\n\n      if (preKey.signature().length != SERIALIZED_SIGNATURE_LENGTH) {\n        throw new IllegalArgumentException(\"prekey signature length must be \" + SERIALIZED_SIGNATURE_LENGTH);\n      }\n      buffer.put(preKey.signature());\n    }\n    buffer.flip();\n    return buffer;\n  }\n\n  /**\n   * Deserialize a single {@link KEMSignedPreKey}\n   *\n   * @param format The format of the page this buffer is from\n   * @param buffer The key to deserialize. The position of the buffer should be the start of the key, and the limit of\n   *               the buffer should be the end of the key. After a successful deserialization the position of the\n   *               buffer will be the limit\n   * @return The deserialized key\n   * @throws InvalidKeyException\n   */\n  static KEMSignedPreKey deserializeKey(int format, ByteBuffer buffer) throws InvalidKeyException {\n    if (format != FORMAT) {\n      throw new IllegalArgumentException(\"Unknown prekey page format \" + format);\n    }\n    if (buffer.remaining() != SERIALIZED_PREKEY_LENGTH) {\n      throw new IllegalArgumentException(\"PreKeys must be length \" + SERIALIZED_PREKEY_LENGTH);\n    }\n    final long keyId = buffer.getLong();\n\n    final byte[] publicKeyBytes = new byte[SERIALIZED_PUBKEY_LENGTH];\n    buffer.get(publicKeyBytes);\n    final KEMPublicKey kemPublicKey = new KEMPublicKey(publicKeyBytes);\n\n    final byte[] signature = new byte[SERIALIZED_SIGNATURE_LENGTH];\n    buffer.get(signature);\n    return new KEMSignedPreKey(keyId, kemPublicKey, signature);\n  }\n\n  /**\n   * The location of a specific key within a serialized page\n   */\n  record KeyLocation(int start, int length) {\n\n    int getStartInclusive() {\n      return start;\n    }\n\n    int getEndInclusive() {\n      return start + length - 1;\n    }\n  }\n\n  /**\n   * Get the location of the key at the provided index within a page\n   *\n   * @param format The format of the page\n   * @param index  The index of the key to retrieve\n   * @return An {@link KeyLocation} indicating where within the page the key is\n   */\n  static KeyLocation keyLocation(final int format, final int index) {\n    if (format != FORMAT) {\n      throw new IllegalArgumentException(\"unknown format \" + format);\n    }\n    final int startOffset = HEADER_SIZE + (index * SERIALIZED_PREKEY_LENGTH);\n    return new KeyLocation(startOffset, SERIALIZED_PREKEY_LENGTH);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/KeyIdUtil.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class KeyIdUtil {\n  public static final long MAX_KEY_ID = (1L << 32) - 1;\n  public static final long MIN_KEY_ID = 0;\n  private KeyIdUtil(){}\n\n  public static boolean keyIdValid(final long keyId) {\n    return keyId <= MAX_KEY_ID && keyId >= MIN_KEY_ID;\n  }\n\n  /// Convert a long keyId (a 32-bit unsigned int) into an int representation.\n  ///\n  /// The inverse of [Integer#toUnsignedLong].\n  ///\n  /// @param keyId A key ID which must be in the range [0, 2^32)\n  /// @throws IllegalArgumentException If `keyId` is not within the range\n  /// @return A 32-bit unsigned integer where the top bit is stored in the sign bit\n  public static int toUnsignedInt(final long keyId) {\n    if (!keyIdValid(keyId)) {\n      throw new IllegalArgumentException(\"Invalid keyId \" + keyId);\n    }\n    return (int) keyId;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/KeysManager.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.util.Futures;\nimport org.whispersystems.textsecuregcm.util.Optionals;\nimport reactor.core.publisher.Flux;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\n\npublic class KeysManager {\n  // KeysController for backwards compatibility\n  private static final String GET_KEYS_COUNTER_NAME = MetricsUtil.name(KeysManager.class, \"getKeys\");\n\n  private final SingleUseECPreKeyStore ecPreKeys;\n  private final PagedSingleUseKEMPreKeyStore pagedPqPreKeys;\n  private final RepeatedUseECSignedPreKeyStore ecSignedPreKeys;\n  private final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys;\n\n  private static final String  TAKE_PQ_NAME = MetricsUtil.name(KeysManager.class, \"takePq\");\n\n  public KeysManager(\n      final SingleUseECPreKeyStore ecPreKeys,\n      final PagedSingleUseKEMPreKeyStore pagedPqPreKeys,\n      final RepeatedUseECSignedPreKeyStore ecSignedPreKeys,\n      final RepeatedUseKEMSignedPreKeyStore pqLastResortKeys) {\n    this.ecPreKeys = ecPreKeys;\n    this.pagedPqPreKeys = pagedPqPreKeys;\n    this.ecSignedPreKeys = ecSignedPreKeys;\n    this.pqLastResortKeys = pqLastResortKeys;\n  }\n\n  public TransactWriteItem buildWriteItemForEcSignedPreKey(final UUID identifier,\n      final byte deviceId,\n      final ECSignedPreKey ecSignedPreKey) {\n\n    return ecSignedPreKeys.buildTransactWriteItemForInsertion(identifier, deviceId, ecSignedPreKey);\n  }\n\n  public TransactWriteItem buildWriteItemForLastResortKey(final UUID identifier,\n      final byte deviceId,\n      final KEMSignedPreKey lastResortSignedPreKey) {\n\n    return pqLastResortKeys.buildTransactWriteItemForInsertion(identifier, deviceId, lastResortSignedPreKey);\n  }\n\n  public List<TransactWriteItem> buildWriteItemsForNewDevice(final UUID accountIdentifier,\n      final UUID phoneNumberIdentifier,\n      final byte deviceId,\n      final ECSignedPreKey aciSignedPreKey,\n      final ECSignedPreKey pniSignedPreKey,\n      final KEMSignedPreKey aciPqLastResortPreKey,\n      final KEMSignedPreKey pniLastResortPreKey) {\n\n    return List.of(\n        ecSignedPreKeys.buildTransactWriteItemForInsertion(accountIdentifier, deviceId, aciSignedPreKey),\n        ecSignedPreKeys.buildTransactWriteItemForInsertion(phoneNumberIdentifier, deviceId, pniSignedPreKey),\n        pqLastResortKeys.buildTransactWriteItemForInsertion(accountIdentifier, deviceId, aciPqLastResortPreKey),\n        pqLastResortKeys.buildTransactWriteItemForInsertion(phoneNumberIdentifier, deviceId, pniLastResortPreKey)\n    );\n  }\n\n  public List<TransactWriteItem> buildWriteItemsForRemovedDevice(final UUID accountIdentifier,\n      final UUID phoneNumberIdentifier,\n      final byte deviceId) {\n\n    return List.of(\n        ecSignedPreKeys.buildTransactWriteItemForDeletion(accountIdentifier, deviceId),\n        ecSignedPreKeys.buildTransactWriteItemForDeletion(phoneNumberIdentifier, deviceId),\n        pqLastResortKeys.buildTransactWriteItemForDeletion(accountIdentifier, deviceId),\n        pqLastResortKeys.buildTransactWriteItemForDeletion(phoneNumberIdentifier, deviceId)\n    );\n  }\n\n  public CompletableFuture<Void> storeEcSignedPreKeys(final UUID identifier, final byte deviceId,\n      final ECSignedPreKey ecSignedPreKey) {\n    return ecSignedPreKeys.store(identifier, deviceId, ecSignedPreKey);\n  }\n\n  public CompletableFuture<Void> storePqLastResort(final UUID identifier, final byte deviceId,\n      final KEMSignedPreKey lastResortKey) {\n    return pqLastResortKeys.store(identifier, deviceId, lastResortKey);\n  }\n\n  public CompletableFuture<Void> storeEcOneTimePreKeys(final UUID identifier, final byte deviceId,\n      final List<ECPreKey> preKeys) {\n    return ecPreKeys.store(identifier, deviceId, preKeys);\n  }\n\n  public CompletableFuture<Void> storeKemOneTimePreKeys(final UUID identifier, final byte deviceId,\n      final List<KEMSignedPreKey> preKeys) {\n    return pagedPqPreKeys.store(identifier, deviceId, preKeys);\n\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Optional<ECPreKey>> takeEC(final UUID identifier, final byte deviceId) {\n    return ecPreKeys.take(identifier, deviceId);\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Optional<KEMSignedPreKey>> takePQ(final UUID identifier, final byte deviceId) {\n    return tagTakePQ(pagedPqPreKeys.take(identifier, deviceId), PQSource.PAGE)\n        .thenCompose(maybeSingleUsePreKey -> maybeSingleUsePreKey\n            .map(_ -> CompletableFuture.completedFuture(maybeSingleUsePreKey))\n            .orElseGet(() -> tagTakePQ(pqLastResortKeys.find(identifier, deviceId), PQSource.LAST_RESORT)));\n  }\n\n  private enum PQSource {\n    PAGE,\n    LAST_RESORT\n  }\n  private CompletableFuture<Optional<KEMSignedPreKey>> tagTakePQ(CompletableFuture<Optional<KEMSignedPreKey>> prekey, final PQSource source) {\n    return prekey.thenApply(maybeSingleUsePreKey -> {\n      final Optional<String> maybeSourceTag = maybeSingleUsePreKey\n          // If we found a PK, use this source tag\n          .map(ignore -> source.name())\n          // If we didn't and this is our last resort, we didn't find a PK\n          .or(() -> source == PQSource.LAST_RESORT ? Optional.of(\"absent\") : Optional.empty());\n      maybeSourceTag.ifPresent(sourceTag -> {\n        Metrics.counter(TAKE_PQ_NAME, \"source\", sourceTag).increment();\n      });\n      return maybeSingleUsePreKey;\n    });\n  }\n\n  public CompletableFuture<Optional<KEMSignedPreKey>> getLastResort(final UUID identifier, final byte deviceId) {\n    return pqLastResortKeys.find(identifier, deviceId);\n  }\n\n  public CompletableFuture<Optional<ECSignedPreKey>> getEcSignedPreKey(final UUID identifier, final byte deviceId) {\n    return ecSignedPreKeys.find(identifier, deviceId);\n  }\n\n  public CompletableFuture<Integer> getEcCount(final UUID identifier, final byte deviceId) {\n    return ecPreKeys.getCount(identifier, deviceId);\n  }\n\n  public CompletableFuture<Integer> getPqCount(final UUID identifier, final byte deviceId) {\n    return pagedPqPreKeys.getCount(identifier, deviceId);\n  }\n\n  public CompletableFuture<Void> deleteSingleUsePreKeys(final UUID identifier) {\n    return CompletableFuture.allOf(\n        ecPreKeys.delete(identifier),\n        pagedPqPreKeys.delete(identifier)\n    );\n  }\n\n  public CompletableFuture<Void> deleteSingleUsePreKeys(final UUID accountUuid, final byte deviceId) {\n    return CompletableFuture.allOf(\n        ecPreKeys.delete(accountUuid, deviceId),\n        pagedPqPreKeys.delete(accountUuid, deviceId)\n    );\n  }\n\n  /**\n   * List all the current remotely stored prekey pages across all devices. Pages that are no longer in use can be\n   * removed with {@link #pruneDeadPage}\n   *\n   * @param lookupConcurrency the number of concurrent lookup operations to perform when populating list results\n   * @return All stored prekey pages\n   */\n  public Flux<DeviceKEMPreKeyPages> listStoredKEMPreKeyPages(int lookupConcurrency) {\n    return pagedPqPreKeys.listStoredPages(lookupConcurrency);\n  }\n\n  /**\n   * Remove a prekey page that is no longer in use. A page should only be removed if it is not the active page and\n   * it has no chance of being updated to be.\n   *\n   * @param identifier The owner of the dead page\n   * @param deviceId The device of the dead page\n   * @param pageId The dead page to remove from storage\n   * @return A future that completes when the page has been removed\n   */\n  public CompletableFuture<Void> pruneDeadPage(final UUID identifier, final byte deviceId, final UUID pageId) {\n    return pagedPqPreKeys.deleteBundleFromS3(identifier, deviceId, pageId);\n  }\n\n  public record DevicePreKeys(\n      ECSignedPreKey ecSignedPreKey,\n      Optional<ECPreKey> ecPreKey,\n      KEMSignedPreKey kemSignedPreKey) {}\n\n  public CompletableFuture<Optional<DevicePreKeys>> takeDevicePreKeys(\n      final byte deviceId,\n      final ServiceIdentifier serviceIdentifier,\n      final @Nullable String userAgent) {\n    final UUID uuid = serviceIdentifier.uuid();\n    return Futures.zipWith(\n            this.takeEC(uuid, deviceId),\n            this.getEcSignedPreKey(uuid, deviceId),\n            this.takePQ(uuid, deviceId),\n            (maybeUnsignedEcPreKey, maybeSignedEcPreKey, maybePqPreKey) -> {\n\n              Metrics.counter(GET_KEYS_COUNTER_NAME, Tags.of(\n                      UserAgentTagUtil.getPlatformTag(userAgent),\n                      Tag.of(\"identityType\", serviceIdentifier.identityType().name()),\n                      Tag.of(\"oneTimeEcKeyAvailable\", String.valueOf(maybeUnsignedEcPreKey.isPresent())),\n                      Tag.of(\"signedEcKeyAvailable\", String.valueOf(maybeSignedEcPreKey.isPresent())),\n                      Tag.of(\"pqKeyAvailable\", String.valueOf(maybePqPreKey.isPresent()))))\n                  .increment();\n\n              // The pq prekey and signed EC prekey should never be null for an existing account. This should only happen\n              // if the account or device has been removed and the read was split, so we can return empty in those cases.\n              return Optionals.zipWith(maybeSignedEcPreKey, maybePqPreKey, (signedEcPreKey, pqPreKey) ->\n                  new DevicePreKeys(signedEcPreKey, maybeUnsignedEcPreKey, pqPreKey));\n            })\n        .toCompletableFuture();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/LinkDeviceTokenAlreadyUsedException.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\npublic class LinkDeviceTokenAlreadyUsedException extends Exception {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersistenceException.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class MessagePersistenceException extends Exception {\n\n  public MessagePersistenceException(String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagePersister.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.lifecycle.Managed;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.LongTaskTimer;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Locale;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.atomic.AtomicLong;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.metrics.DevicePlatformUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.util.function.Tuples;\nimport reactor.util.retry.Retry;\nimport reactor.util.retry.RetryBackoffSpec;\nimport software.amazon.awssdk.services.dynamodb.model.ItemCollectionSizeLimitExceededException;\n\npublic class MessagePersister implements Managed {\n\n  private final MessagesCache messagesCache;\n  private final MessagesManager messagesManager;\n  private final AccountsManager accountsManager;\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  private final Scheduler persistQueueScheduler;\n  private final Clock clock;\n\n  private final Duration persistDelay;\n  private final int maxConcurrency;\n  private final int scanCount;\n\n  private final RetryBackoffSpec retryBackoffSpec;\n\n  private final String persisterId = UUID.randomUUID().toString();\n\n  private volatile boolean running;\n\n  @Nullable\n  private Thread workerThread;\n\n  private static final String INSPECTED_QUEUE_COUNTER_NAME = name(MessagePersister.class, \"inspectedQueue\");\n\n  private static final String OVERSIZED_QUEUE_COUNTER_NAME = name(MessagePersister.class, \"persistQueueOversized\");\n  private static final String PERSISTED_MESSAGE_COUNTER_NAME = name(MessagePersister.class, \"persistMessage\");\n  private static final String PERSISTED_BYTES_COUNTER_NAME = name(MessagePersister.class, \"persistBytes\");\n\n  private static final Timer PERSIST_QUEUE_TIMER = Metrics.timer(name(MessagePersister.class, \"persistQueue\"));\n  private static final Counter TRIMMED_MESSAGE_COUNTER = Metrics.counter(name(MessagePersister.class, \"trimmedMessage\"));\n  private static final Counter TRIMMED_MESSAGE_BYTES_COUNTER = Metrics.counter(name(MessagePersister.class, \"trimmedMessageBytes\"));\n\n  private static final Counter PERSIST_NODE_COUNTER = Metrics.counter(name(MessagePersister.class, \"persistNodeCount\"));\n  private static final LongTaskTimer PERSIST_NODE_TIMER = Metrics.more().longTaskTimer(name(MessagePersister.class, \"persistNode\"));\n\n  private static final String QUEUE_SIZE_DISTRIBUTION_SUMMARY_NAME = name(MessagePersister.class, \"queueSize\");\n\n  static final int QUEUE_BATCH_LIMIT = 100;\n  static final int MESSAGE_BATCH_LIMIT = 100;\n\n  private static final DistributionSummary QUEUE_COUNT_DISTRIBUTION_SUMMARY =\n      DistributionSummary.builder(name(MessagePersister.class, \"queueCount\"))\n          .register(Metrics.globalRegistry);\n\n  private static final long EXCEPTION_PAUSE_MILLIS = Duration.ofSeconds(3).toMillis();\n\n  private static final Logger logger = LoggerFactory.getLogger(MessagePersister.class);\n\n  public MessagePersister(final MessagesCache messagesCache,\n      final MessagesManager messagesManager,\n      final AccountsManager accountsManager,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final Scheduler persistQueueScheduler,\n      final Clock clock,\n      final Duration persistDelay,\n      final int maxConcurrency,\n      final int scanCount) {\n\n    this(messagesCache,\n        messagesManager,\n        accountsManager,\n        dynamicConfigurationManager,\n        persistQueueScheduler,\n        clock,\n        persistDelay,\n        maxConcurrency,\n        scanCount,\n        Retry.backoff(3, Duration.ofSeconds(1)));\n  }\n\n  @VisibleForTesting\n  MessagePersister(final MessagesCache messagesCache,\n      final MessagesManager messagesManager,\n      final AccountsManager accountsManager,\n      final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n      final Scheduler persistQueueScheduler,\n      final Clock clock,\n      final Duration persistDelay,\n      final int maxConcurrency,\n      final int scanCount,\n      final RetryBackoffSpec retryBackoffSpec) {\n\n    this.messagesCache = messagesCache;\n    this.messagesManager = messagesManager;\n    this.accountsManager = accountsManager;\n    this.dynamicConfigurationManager = dynamicConfigurationManager;\n    this.clock = clock;\n    this.persistDelay = persistDelay;\n    this.maxConcurrency = maxConcurrency;\n    this.scanCount = scanCount;\n    this.retryBackoffSpec = retryBackoffSpec;\n    this.persistQueueScheduler = persistQueueScheduler;\n  }\n\n  @Override\n  public synchronized void start() {\n    running = true;\n\n    workerThread = new Thread(() -> {\n      while (running) {\n        if (dynamicConfigurationManager.getConfiguration().getMessagePersisterConfiguration().isPersistenceEnabled()) {\n          try {\n            final int queuesPersisted = persistNextNode();\n            QUEUE_COUNT_DISTRIBUTION_SUMMARY.record(queuesPersisted);\n\n            if (queuesPersisted == 0) {\n              Util.sleep(100);\n            }\n          } catch (final Throwable t) {\n            logger.warn(\"Failed to persist queues\", t);\n            Util.sleep(EXCEPTION_PAUSE_MILLIS);\n          }\n        }\n\n        try {\n          // Persisters work through nodes quickly, and running in a hot loop taxes Redis nodes unnecessarily\n          Thread.sleep(dynamicConfigurationManager.getConfiguration().getMessagePersisterConfiguration().getSleepBetweenNodes());\n        } catch (final InterruptedException _) {\n        }\n      }\n    }, \"MessagePersister\");\n\n    workerThread.start();\n  }\n\n  @Override\n  public synchronized void stop() {\n    running = false;\n\n    if (workerThread != null) {\n      try {\n        workerThread.join();\n      } catch (final InterruptedException e) {\n        logger.warn(\"Interrupted while waiting for worker thread to complete current operation\");\n      }\n\n      workerThread = null;\n    }\n  }\n\n  @VisibleForTesting\n  int persistNextNode() {\n    final RedisClusterNode node;\n    {\n      final Duration nodeClaimTtl =\n          dynamicConfigurationManager.getConfiguration().getMessagePersisterConfiguration().getNodeClaimTtl();\n\n      final Optional<RedisClusterNode> maybeNode = messagesCache.claimNextNodeToPersist(persisterId, nodeClaimTtl);\n\n      if (maybeNode.isEmpty()) {\n        return 0;\n      }\n\n      node = maybeNode.get();\n    }\n\n    try {\n      return persistNode(node);\n    } finally {\n      messagesCache.releaseNodeClaim(node, persisterId);\n    }\n  }\n\n  int persistNode(final RedisClusterNode node) {\n    final LongTaskTimer.Sample sample = PERSIST_NODE_TIMER.start();\n\n    try {\n      final Tags tags = Tags.of(\"node\", node.getUri().getHost());\n\n      return messagesCache.getQueues(node, scanCount)\n          .filter(_ -> dynamicConfigurationManager.getConfiguration().getMessagePersisterConfiguration()\n              .isPersistenceEnabled())\n          .flatMap(queueKey -> Mono.defer(() -> shouldPersistQueue(queueKey))\n              .retryWhen(retryBackoffSpec)\n              .onErrorResume(e -> {\n                logger.warn(\"Failed to determine whether queue {} should be persisted\", queueKey, e);\n                return Mono.empty();\n              })\n              .mapNotNull(shouldPersist -> shouldPersist ? queueKey : null)\n              .doOnTerminate(() -> Metrics.counter(INSPECTED_QUEUE_COUNTER_NAME, tags).increment()), maxConcurrency)\n          .distinct()\n          .flatMap(queueKey -> {\n            final UUID accountIdentifier = MessagesCache.getAccountUuidFromQueueName(queueKey);\n            final byte deviceId = MessagesCache.getDeviceIdFromQueueName(queueKey);\n\n            return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(accountIdentifier))\n                .map(maybeAccount -> maybeAccount\n                    .flatMap(account -> account.getDevice(deviceId))\n                    .map(device -> Tuples.of(maybeAccount.get(), device)))\n                .flatMap(Mono::justOrEmpty)\n                .retryWhen(retryBackoffSpec)\n                .onErrorResume(e -> {\n                  logger.warn(\"Failed to fetch account/device for queue: {}\", queueKey, e);\n                  return Mono.empty();\n                });\n          }, maxConcurrency)\n          .flatMap(accountAndDevice -> {\n            final Account account = accountAndDevice.getT1();\n            final Device device = accountAndDevice.getT2();\n\n            return persistQueue(account, device, tags)\n                .thenReturn(1)\n                .retryWhen(retryBackoffSpec\n                    // Don't retry with backoff for persistence exceptions\n                    .filter(e -> !(e instanceof MessagePersistenceException)))\n                .onErrorResume(e -> {\n                  logger.warn(\"Failed to persist queue {}::{} ({}); will schedule for retry\",\n                      account.getIdentifier(IdentityType.ACI), device.getId(), node.getUri().getHost(), e);\n\n                  return Mono.empty();\n                });\n          }, maxConcurrency)\n          .onErrorResume(e -> {\n            logger.warn(\"Unhandled error while persisting messages\", e);\n            return Mono.empty();\n          })\n          .count()\n          .blockOptional()\n          .map(Math::toIntExact)\n          .orElse(0);\n    } finally {\n      sample.stop();\n      PERSIST_NODE_COUNTER.increment();\n    }\n  }\n\n  @VisibleForTesting\n  Mono<Void> persistQueue(final Account account, final Device device, final Tags baseTags) {\n    final UUID accountUuid = account.getUuid();\n    final byte deviceId = device.getId();\n\n    final Tag platformTag = Tag.of(\"platform\", DevicePlatformUtil.getDevicePlatform(device)\n        .map(platform -> platform.name().toLowerCase(Locale.ROOT))\n        .orElse(\"unknown\"));\n\n    final Timer.Sample sample = Timer.start();\n    final Tags tags = baseTags.and(platformTag);\n\n    return Flux.usingWhen(\n        messagesCache.lockQueueForPersistence(accountUuid, deviceId)\n            .thenReturn(true),\n        _ -> Flux.from(messagesCache.getMessagesToPersist(accountUuid, deviceId))\n            .buffer(MESSAGE_BATCH_LIMIT)\n            .flatMap(messages -> {\n              final int urgentMessageCount = (int) messages.stream().filter(MessageProtos.Envelope::getUrgent).count();\n              final int nonUrgentMessageCount = messages.size() - urgentMessageCount;\n\n              Metrics.counter(PERSISTED_MESSAGE_COUNTER_NAME, tags.and(\"urgent\", \"true\")).increment(urgentMessageCount);\n              Metrics.counter(PERSISTED_MESSAGE_COUNTER_NAME, tags.and(\"urgent\", \"false\")).increment(nonUrgentMessageCount);\n              Metrics.counter(PERSISTED_BYTES_COUNTER_NAME, tags)\n                  .increment(messages.stream().mapToInt(MessageProtos.Envelope::getSerializedSize).sum());\n\n              return Mono.fromRunnable(() -> messagesManager.persistMessages(accountUuid, device, messages))\n                  .subscribeOn(persistQueueScheduler)\n                  .thenReturn(messages.size());\n            }, 1)\n            .reduce(0, Integer::sum)\n            .onErrorResume(ItemCollectionSizeLimitExceededException.class, _ -> {\n              final boolean isPrimary = deviceId == Device.PRIMARY_ID;\n              Metrics.counter(OVERSIZED_QUEUE_COUNTER_NAME, \"primary\", String.valueOf(isPrimary)).increment();\n              // may throw, in which case we'll retry later by the usual mechanism\n              if (isPrimary) {\n                logger.warn(\"Failed to persist queue {}::{} due to overfull queue; will trim oldest messages\",\n                    account.getUuid(), deviceId);\n\n                return trimQueue(account, device)\n                    .then(Mono.error(new MessagePersistenceException(\"Could not persist due to an overfull queue. Trimmed primary queue, a subsequent retry may succeed\")));\n              } else {\n                logger.warn(\"Failed to persist queue {}::{} due to overfull queue; will unlink device\", accountUuid, deviceId);\n\n                return Mono.fromRunnable(() -> accountsManager.removeDevice(account, deviceId))\n                    .subscribeOn(persistQueueScheduler)\n                    .then(Mono.empty());\n              }\n            })\n            .doOnSuccess(messagesPersisted -> {\n              if (messagesPersisted != null) {\n                DistributionSummary.builder(QUEUE_SIZE_DISTRIBUTION_SUMMARY_NAME)\n                    .tags(Tags.of(platformTag))\n                    .register(Metrics.globalRegistry)\n                    .record(messagesPersisted);\n              }\n            })\n            .doOnTerminate(() -> sample.stop(PERSIST_QUEUE_TIMER)),\n        _ -> messagesCache.unlockQueueForPersistence(accountUuid, deviceId))\n        .then();\n  }\n\n  private Mono<Void> trimQueue(final Account account, final Device device) {\n    final UUID aci = account.getIdentifier(IdentityType.ACI);\n    final byte deviceId = device.getId();\n\n    final double extraRoomRatio = this.dynamicConfigurationManager.getConfiguration()\n        .getMessagePersisterConfiguration()\n        .getTrimOversizedQueueExtraRoomRatio();\n\n    final AtomicLong oldestMessage = new AtomicLong(0L);\n    final AtomicLong newestMessage = new AtomicLong(0L);\n    final AtomicLong bytesDeleted = new AtomicLong(0L);\n\n    final AtomicLong cachedMessageBytes = new AtomicLong(0L);\n    final AtomicLong targetDeleteBytes = new AtomicLong(0L);\n\n    return Mono.fromFuture(() -> messagesCache.estimatePersistedQueueSizeBytes(aci, deviceId))\n        .flatMap(estimatedPersistedQueueSize -> {\n          cachedMessageBytes.set(estimatedPersistedQueueSize);\n          targetDeleteBytes.set(Math.round(estimatedPersistedQueueSize * extraRoomRatio));\n\n          return Flux.from(messagesManager.getMessagesForDeviceReactive(aci, device, false))\n              .concatMap(envelope -> {\n                if (bytesDeleted.getAndAdd(envelope.getSerializedSize()) >= targetDeleteBytes.get()) {\n                  return Mono.just(Optional.<MessageProtos.Envelope>empty());\n                }\n                oldestMessage.compareAndSet(0L, envelope.getServerTimestamp());\n                newestMessage.set(envelope.getServerTimestamp());\n                return Mono.just(Optional.of(envelope));\n              })\n              .takeWhile(Optional::isPresent)\n              .flatMap(maybeEnvelope -> {\n                // We know this must be present because we `takeWhile` values are present\n                final MessageProtos.Envelope envelope = maybeEnvelope.orElseThrow(AssertionError::new);\n                TRIMMED_MESSAGE_COUNTER.increment();\n                TRIMMED_MESSAGE_BYTES_COUNTER.increment(envelope.getSerializedSize());\n                return Mono\n                    .fromCompletionStage(() -> messagesManager\n                        .delete(aci, device, UUID.fromString(envelope.getServerGuid()), envelope.getServerTimestamp()))\n                    .retryWhen(retryBackoffSpec)\n                    .map(Optional::isPresent);\n              })\n              .reduce(Pair.of(0L, 0L), (acc, deleted) -> deleted\n                  ? Pair.of(acc.getLeft() + 1, acc.getRight())\n                  : Pair.of(acc.getLeft(), acc.getRight() + 1));\n        })\n        .doOnSuccess(outcomes -> {\n          if (outcomes != null) {\n            logger.warn(\n                \"Finished trimming {}:{}. Oldest message = {}, newest message = {}. Attempted to delete {} persisted bytes to make room for {} cached message bytes.  Delete outcomes: {} present, {} missing.\",\n                aci, deviceId,\n                Instant.ofEpochMilli(oldestMessage.get()), Instant.ofEpochMilli(newestMessage.get()),\n                targetDeleteBytes, cachedMessageBytes,\n                outcomes.getLeft(), outcomes.getRight());\n          }\n        })\n        .then();\n  }\n\n  @VisibleForTesting\n  Mono<Boolean> shouldPersistQueue(final String queueKey) {\n    return messagesCache.getEarliestUndeliveredTimestamp(\n            MessagesCache.getAccountUuidFromQueueName(queueKey),\n            MessagesCache.getDeviceIdFromQueueName(queueKey))\n        .map(oldestMessageTimestamp ->\n            Duration.between(Instant.ofEpochMilli(oldestMessageTimestamp), clock.instant()).compareTo(persistDelay) > 0)\n        .switchIfEmpty(Mono.just(false));\n  }\n\n  @VisibleForTesting\n  String getPersisterId() {\n    return persisterId;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessageStream.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Flow;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\n\n/// A message stream publishes an ordered stream of Signal messages from a destination device's queue and provides a\n/// mechanism for consumers to acknowledge receipt of delivered messages.\npublic interface MessageStream {\n\n  /// Publishes a non-terminating stream of [MessageStreamEntry.Envelope] entities and at most one\n  /// [MessageStreamEntry.QueueEmpty].\n  ///\n  /// @return a non-terminating stream of message stream entries\n  Flow.Publisher<MessageStreamEntry> getMessages();\n\n  /// Acknowledges receipt of the given message. Implementations may delete the message immediately or defer deletion for\n  /// inclusion in a more efficient batch deletion.\n  ///\n  /// @param message the message to acknowledge\n  ///\n  /// @return a future that completes when the message stream has processed the acknowledgement\n  CompletableFuture<Void> acknowledgeMessage(MessageProtos.Envelope message);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessageStreamEntry.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\n\n/// A `MessageStreamEntr` is an entity that can be emitted by the publisher returned by [MessageStream#getMessages()].\n/// Message stream entries either produce an individual message (see [Envelope]) or that the initial contents of a\n/// message queue have been drained (see [QueueEmpty]).\npublic sealed interface MessageStreamEntry permits MessageStreamEntry.Envelope, MessageStreamEntry.QueueEmpty {\n\n  /// A message stream entry that carries a single message.\n  ///\n  /// @param message the message emitted by the publisher\n  record Envelope(MessageProtos.Envelope message) implements MessageStreamEntry {\n  }\n\n  /// A message stream entry that indicates that the initial contents of a message queue have been emitted by the\n  /// publisher; any [Envelope] entries after a `QueueEmpty` entry arrived after caller started reading\n  /// messages from the queue.\n  record QueueEmpty() implements MessageStreamEntry {\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCache.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.github.resilience4j.reactor.retry.RetryOperator;\nimport io.lettuce.core.Limit;\nimport io.lettuce.core.Range;\nimport io.lettuce.core.ScanArgs;\nimport io.lettuce.core.ScanStream;\nimport io.lettuce.core.ScoredValue;\nimport io.lettuce.core.SetArgs;\nimport io.lettuce.core.cluster.SlotHash;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Function;\nimport java.util.function.Predicate;\nimport java.util.stream.Collectors;\nimport org.reactivestreams.Publisher;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport reactor.core.observability.micrometer.Micrometer;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\n\n/**\n * Manages short-term storage of messages in Redis. Messages are frequently delivered to their destination and deleted\n * shortly after they reach the server, and this cache acts as a low-latency holding area for new messages, reducing\n * load on higher-latency, longer-term storage systems.\n * <p>\n * The following structures are used:\n * <dl>\n *   <dt>{@code queueKey}</code></dt>\n *   <dd>A sorted set of messages in a device’s queue. A message’s score is its queue-local message ID. See\n *   <a href=\"https://redis.io/docs/latest/develop/use/patterns/twitter-clone/#the-sorted-set-data-type\">Redis.io: The\n *   Sorted Set data type</a> for background on scores and this data structure.</dd>\n *   <dt>{@code queueMetadataKey}</dt>\n *   <dd>A hash containing message guids and their queue-local message ID. It also contains a {@code counter} key, which is\n *   incremented to supply the next message ID. This is used to remove a message by GUID from {@code queueKey} by its\n *   local messageId.</dd>\n *   <dt>{@code sharedMrmKey}</dt>\n *   <dd>A hash containing a single multi-recipient message pending delivery. It contains:\n *     <ul>\n *       <li>{@code data} - the serialized SealedSenderMultiRecipientMessage data</li>\n *       <li>fields with each recipient device's “view” into the payload ({@link SealedSenderMultiRecipientMessage#serializedRecipientView(SealedSenderMultiRecipientMessage.Recipient)}</li>\n *     </ul>\n *     Note: this is shared among all of the message's recipients, and it may be located in any Redis shard. As each recipient’s\n *     message is delivered, its corresponding view is idempotently removed. When {@code data} is the only remaining\n *     field, the hash will be deleted.\n *     </dd>\n *   <dt>{@code queueLockKey}</dt>\n *   <dd>Used to indicate that a queue is being modified by the {@link MessagePersister} and that {@code get_items} should\n *   return an empty list.</dd>\n * </dl>\n * <p>\n * At a high level, the process is:\n * <ol>\n *   <li>Insert: the queue metadata is queried for the next incremented message ID. The message data is inserted into\n *   the queue at that ID, and the message GUID is inserted in the queue metadata.</li>\n *   <li>Get: a batch of messages are retrieved from the queue, potentially with an after-message-ID offset.</li>\n *   <li>Remove: a set of messages are remove by GUID. For each GUID, the message ID is retrieved from the queue metadata,\n *   and then that single-value range is removed from the queue.</li>\n * </ol>\n * For multi-recipient messages (sometimes abbreviated “MRM”), there are similar operations on the common data during\n * insert, get, and remove. MRM inserts must occur before individual queue inserts, while removal is considered\n * best-effort, and uses key expiration as back-stop garbage collection.\n * <p>\n * For atomicity, many operations are implemented as Lua scripts that are executed on the Redis server using\n * {@code EVAL}/{@code EVALSHA}.\n *\n * @see MessagesCacheInsertScript\n * @see MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript\n * @see MessagesCacheGetItemsScript\n * @see MessagesCacheRemoveByGuidScript\n * @see MessagesCacheRemoveRecipientViewFromMrmDataScript\n * @see MessagesCacheRemoveQueueScript\n */\npublic class MessagesCache {\n\n  private final FaultTolerantRedisClusterClient redisCluster;\n  private final Clock clock;\n\n  private final Scheduler messageDeliveryScheduler;\n  private final ExecutorService messageDeletionExecutorService;\n  // messageDeletionExecutorService wrapped into a reactor Scheduler\n  private final Scheduler messageDeletionScheduler;\n  private final ExperimentEnrollmentManager experimentEnrollmentManager;\n\n  private final MessagesCacheInsertScript insertScript;\n  private final MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript insertMrmScript;\n  private final MessagesCacheRemoveByGuidScript removeByGuidScript;\n  private final MessagesCacheGetItemsScript getItemsScript;\n  private final MessagesCacheReleaseNodeClaimScript releaseNodeClaimScript;\n  private final MessagesCacheRemoveQueueScript removeQueueScript;\n  private final MessagesCacheRemoveRecipientViewFromMrmDataScript removeRecipientViewFromMrmDataScript;\n  private final MessagesCacheUnlockQueueScript unlockQueueScript;\n\n  private int nextNodeRotateDistance = 0;\n\n  private final Timer insertTimer = Metrics.timer(name(MessagesCache.class, \"insert\"));\n  private final Timer insertSharedMrmPayloadTimer = Metrics.timer(name(MessagesCache.class, \"insertSharedMrmPayload\"));\n  private final Timer removeByGuidTimer = Metrics.timer(name(MessagesCache.class, \"removeByGuid\"));\n  private final Timer removeRecipientViewTimer = Metrics.timer(name(MessagesCache.class, \"removeRecipientView\"));\n  private final Timer clearQueueTimer = Metrics.timer(name(MessagesCache.class, \"clear\"));\n  private final Counter removeMessageCounter = Metrics.counter(name(MessagesCache.class, \"remove\"));\n  private final Counter staleEphemeralMessagesCounter = Metrics.counter(\n      name(MessagesCache.class, \"staleEphemeralMessages\"));\n  private final Counter staleMrmMessagesCounter = Metrics.counter(name(MessagesCache.class, \"staleMrmMessages\"));\n  private final Counter mrmContentRetrievedCounter = Metrics.counter(name(MessagesCache.class, \"mrmViewRetrieved\"));\n  private final String MRM_RETRIEVAL_ERROR_COUNTER_NAME = name(MessagesCache.class, \"mrmRetrievalError\");\n  private final String EPHEMERAL_TAG_NAME = \"ephemeral\";\n  private final String MISSING_MRM_DATA_TAG_NAME = \"missingMrmData\";\n  private final Counter skippedStaleEphemeralMrmCounter = Metrics.counter(\n      name(MessagesCache.class, \"skippedStaleEphemeralMrm\"));\n  private final Counter sharedMrmDataKeyRemovedCounter = Metrics.counter(\n      name(MessagesCache.class, \"sharedMrmKeyRemoved\"));\n\n  static final String RETRY_NAME = ResilienceUtil.name(MessagesCache.class);\n\n  private static final byte[] LOCK_VALUE = \"1\".getBytes(StandardCharsets.UTF_8);\n\n  @VisibleForTesting\n  static final ByteString STALE_MRM_KEY = ByteString.copyFromUtf8(\"stale\");\n\n  @VisibleForTesting\n  static final Duration MAX_EPHEMERAL_MESSAGE_DELAY = Duration.ofSeconds(10);\n\n  private static final String GET_FLUX_NAME = MetricsUtil.name(MessagesCache.class, \"get\");\n\n  @VisibleForTesting\n  static final int PAGE_SIZE = 100;\n\n  private static final int REMOVE_MRM_RECIPIENT_VIEW_CONCURRENCY = 8;\n\n  private static final Logger logger = LoggerFactory.getLogger(MessagesCache.class);\n\n  public MessagesCache(final FaultTolerantRedisClusterClient redisCluster,\n      final Scheduler messageDeliveryScheduler,\n      final ExecutorService messageDeletionExecutorService,\n      final ScheduledExecutorService retryExecutor,\n      final Clock clock,\n      final ExperimentEnrollmentManager experimentEnrollmentManager)\n      throws IOException {\n\n    this(\n        redisCluster,\n        messageDeliveryScheduler,\n        messageDeletionExecutorService,\n        clock,\n        experimentEnrollmentManager,\n        new MessagesCacheInsertScript(redisCluster, retryExecutor),\n        new MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript(redisCluster, retryExecutor),\n        new MessagesCacheGetItemsScript(redisCluster),\n        new MessagesCacheReleaseNodeClaimScript(redisCluster),\n        new MessagesCacheRemoveByGuidScript(redisCluster, retryExecutor),\n        new MessagesCacheRemoveQueueScript(redisCluster),\n        new MessagesCacheRemoveRecipientViewFromMrmDataScript(redisCluster),\n        new MessagesCacheUnlockQueueScript(redisCluster)\n    );\n  }\n\n  @VisibleForTesting\n  MessagesCache(final FaultTolerantRedisClusterClient redisCluster,\n                final Scheduler messageDeliveryScheduler,\n                final ExecutorService messageDeletionExecutorService, final Clock clock,\n                final ExperimentEnrollmentManager experimentEnrollmentManager,\n                final MessagesCacheInsertScript insertScript,\n                final MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript insertMrmScript,\n                final MessagesCacheGetItemsScript getItemsScript,\n                final MessagesCacheReleaseNodeClaimScript releaseNodeClaimScript,\n                final MessagesCacheRemoveByGuidScript removeByGuidScript,\n                final MessagesCacheRemoveQueueScript removeQueueScript,\n                final MessagesCacheRemoveRecipientViewFromMrmDataScript removeRecipientViewFromMrmDataScript,\n                final MessagesCacheUnlockQueueScript unlockQueueScript) throws IOException {\n\n    this.redisCluster = redisCluster;\n    this.clock = clock;\n\n    this.messageDeliveryScheduler = messageDeliveryScheduler;\n    this.messageDeletionExecutorService = messageDeletionExecutorService;\n    this.messageDeletionScheduler = Schedulers.fromExecutorService(messageDeletionExecutorService, \"messageDeletion\");\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n\n    this.insertScript = insertScript;\n    this.insertMrmScript = insertMrmScript;\n    this.removeByGuidScript = removeByGuidScript;\n    this.getItemsScript = getItemsScript;\n    this.releaseNodeClaimScript = releaseNodeClaimScript;\n    this.removeQueueScript = removeQueueScript;\n    this.removeRecipientViewFromMrmDataScript = removeRecipientViewFromMrmDataScript;\n    this.unlockQueueScript = unlockQueueScript;\n  }\n\n  public CompletableFuture<Boolean> insert(final UUID messageGuid,\n      final UUID destinationAccountIdentifier,\n      final byte destinationDeviceId,\n      final MessageProtos.Envelope message) {\n\n    final MessageProtos.Envelope messageWithGuid = message.toBuilder().setServerGuid(messageGuid.toString()).build();\n    final Timer.Sample sample = Timer.start();\n\n    return insertScript.executeAsync(destinationAccountIdentifier, destinationDeviceId, messageWithGuid)\n        .toCompletableFuture()\n        .whenComplete((_, _) -> sample.stop(insertTimer));\n  }\n\n  public CompletableFuture<byte[]> insertSharedMultiRecipientMessagePayload(\n      final SealedSenderMultiRecipientMessage sealedSenderMultiRecipientMessage) {\n\n    final Timer.Sample sample = Timer.start();\n\n    final byte[] sharedMrmKey = getSharedMrmKey(UUID.randomUUID());\n\n    return insertMrmScript.executeAsync(sharedMrmKey, sealedSenderMultiRecipientMessage)\n        .thenApply(_ -> sharedMrmKey)\n        .toCompletableFuture()\n        .whenComplete((_, _) -> sample.stop(insertSharedMrmPayloadTimer));\n  }\n\n  public CompletableFuture<Optional<RemovedMessage>> remove(final UUID destinationUuid, final byte destinationDevice,\n      final UUID messageGuid) {\n\n    return remove(destinationUuid, destinationDevice, List.of(messageGuid))\n        .thenApply(removed -> removed.isEmpty() ? Optional.empty() : Optional.of(removed.getFirst()));\n  }\n\n  public CompletableFuture<List<RemovedMessage>> remove(final UUID destinationUuid, final byte destinationDevice,\n      final List<UUID> messageGuids) {\n\n    final Timer.Sample sample = Timer.start();\n\n    return removeByGuidScript.execute(destinationUuid, destinationDevice, messageGuids)\n        .thenApplyAsync(serialized -> {\n\n          final List<RemovedMessage> removedMessages = new ArrayList<>(serialized.size());\n          final Map<ServiceIdentifier, List<byte[]>> serviceIdentifierToMrmKeys = new HashMap<>();\n\n          for (final byte[] bytes : serialized) {\n            try {\n              final MessageProtos.Envelope envelope = parseEnvelope(bytes);\n              removedMessages.add(RemovedMessage.fromEnvelope(envelope));\n              if (envelope.hasSharedMrmKey()) {\n                serviceIdentifierToMrmKeys.computeIfAbsent(\n                        ServiceIdentifier.valueOf(envelope.getDestinationServiceId()), _ -> new ArrayList<>())\n                    .add(envelope.getSharedMrmKey().toByteArray());\n              }\n            } catch (final InvalidProtocolBufferException e) {\n              logger.warn(\"Failed to parse envelope\", e);\n            }\n          }\n\n          serviceIdentifierToMrmKeys.forEach(\n              (serviceId, keysToUpdate) -> removeRecipientViewFromMrmData(keysToUpdate, serviceId, destinationDevice));\n\n          return removedMessages;\n        }, messageDeletionExecutorService)\n        .toCompletableFuture()\n        .whenComplete((removedMessages, _) -> {\n          if (removedMessages != null) {\n            removeMessageCounter.increment(removedMessages.size());\n          }\n\n          sample.stop(removeByGuidTimer);\n        });\n\n  }\n\n  public CompletableFuture<Boolean> hasMessagesAsync(final UUID destinationUuid, final byte destinationDevice) {\n    return redisCluster.withBinaryCluster(connection ->\n            connection.async().zcard(getMessageQueueKey(destinationUuid, destinationDevice))\n                .thenApply(cardinality -> cardinality > 0))\n        .toCompletableFuture();\n  }\n\n  public Publisher<MessageProtos.Envelope> get(final UUID destinationUuid, final byte destinationDeviceId) {\n    return get(destinationUuid,\n        destinationDeviceId,\n        clock.instant().minus(MAX_EPHEMERAL_MESSAGE_DELAY),\n        false);\n  }\n\n  Publisher<MessageProtos.Envelope> getMessagesToPersist(final UUID accountUuid, final byte destinationDeviceId) {\n    return Flux.from(get(accountUuid,\n        destinationDeviceId,\n        // Discard all ephemeral messages when persisting\n        Instant.ofEpochMilli(Long.MAX_VALUE),\n        true));\n  }\n\n  private Publisher<MessageProtos.Envelope> get(final UUID destinationUuid,\n      final byte destinationDeviceId,\n      final Instant earliestAllowableEphemeralTimestamp,\n      final boolean bypassLock) {\n\n    final long earliestAllowableEphemeralTimestampMillis = earliestAllowableEphemeralTimestamp.toEpochMilli();\n\n    final Flux<MessageProtos.Envelope> allMessages = getAllMessages(destinationUuid, destinationDeviceId,\n        earliestAllowableEphemeralTimestampMillis, PAGE_SIZE, bypassLock)\n        .publish()\n        // We expect exactly three subscribers to this base flux:\n        // 1. the websocket that delivers messages to clients\n        // 2. an internal processes to discard stale ephemeral messages\n        // 3. an internal process to discard stale MRM messages\n        // The discard subscribers will subscribe immediately, but we don’t want to do any work if the\n        // websocket never subscribes.\n        .autoConnect(3);\n\n    final Flux<MessageProtos.Envelope> messagesToPublish = allMessages\n        .filter(Predicate.not(envelope ->\n            isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestampMillis) || isStaleMrmMessage(envelope)));\n\n    final Flux<MessageProtos.Envelope> staleEphemeralMessages = allMessages\n        .filter(envelope -> isStaleEphemeralMessage(envelope, earliestAllowableEphemeralTimestampMillis));\n    discardStaleMessages(destinationUuid, destinationDeviceId, staleEphemeralMessages, staleEphemeralMessagesCounter, \"ephemeral\");\n\n    final Flux<MessageProtos.Envelope> staleMrmMessages = allMessages.filter(MessagesCache::isStaleMrmMessage)\n        // clearing the sharedMrmKey prevents unnecessary calls to update the shared MRM data\n        .map(envelope -> envelope.toBuilder().clearSharedMrmKey().build());\n    discardStaleMessages(destinationUuid, destinationDeviceId, staleMrmMessages, staleMrmMessagesCounter, \"mrm\");\n\n    return messagesToPublish.name(GET_FLUX_NAME)\n        .tap(Micrometer.metrics(Metrics.globalRegistry));\n  }\n\n  /// Returns the server-issued timestamp of the earliest message in the queue for the given account/device, regardless\n  /// of the type of message. Note that this method may return a timestamp for a message that would ultimately be\n  /// discarded were a caller to actually fetch messages (i.e. an expired ephemeral message).\n  ///\n  /// @param destinationUuid the account identifier for the queue for which to get a timestamp\n  /// @param destinationDeviceId the device identifier for the queue for which to get a timestamp\n  ///\n  /// @return a `Mono` that publishes the timestamp (in milliseconds since the epoch) of the earliest message in the\n  /// given queue or yields no value if the queue is empty or does not exist\n  public Mono<Long> getEarliestUndeliveredTimestamp(final UUID destinationUuid, final byte destinationDeviceId) {\n    return redisCluster.withBinaryCluster(connection ->\n            connection.reactive().zrange(getMessageQueueKey(destinationUuid, destinationDeviceId), 0, 0))\n        .next()\n        .flatMap(serializedEnvelope -> {\n          try {\n            return Mono.just(parseEnvelope(serializedEnvelope).getServerTimestamp());\n          } catch (final InvalidProtocolBufferException e) {\n            return Mono.error(e);\n          }\n        });\n  }\n\n  private static boolean isStaleEphemeralMessage(final MessageProtos.Envelope message,\n      long earliestAllowableTimestamp) {\n    return message.getEphemeral() && message.getClientTimestamp() < earliestAllowableTimestamp;\n  }\n\n  /**\n   * Checks whether the given message is a stale MRM message\n   *\n   * @see #getMessageWithSharedMrmData(MessageProtos.Envelope, byte)\n   */\n  private static boolean isStaleMrmMessage(final MessageProtos.Envelope message) {\n    return message.hasSharedMrmKey() && STALE_MRM_KEY.equals(message.getSharedMrmKey());\n  }\n\n  private void discardStaleMessages(final UUID destinationUuid, final byte destinationDevice,\n      Flux<MessageProtos.Envelope> staleMessages, final Counter counter, final String context) {\n    staleMessages\n        .map(e -> UUID.fromString(e.getServerGuid()))\n        .buffer(PAGE_SIZE)\n        .subscribeOn(messageDeletionScheduler)\n        .subscribe(messageGuids ->\n                remove(destinationUuid, destinationDevice, messageGuids)\n                    .thenAccept(removedMessages -> counter.increment(removedMessages.size())),\n            e -> logger.warn(\"Could not remove stale {} messages from cache\", context, e));\n  }\n\n  @VisibleForTesting\n  Flux<MessageProtos.Envelope> getAllMessages(final UUID destinationUuid,\n      final byte destinationDevice,\n      final long earliestAllowableEphemeralTimestamp,\n      final int pageSize,\n      final boolean bypassLock) {\n\n    // fetch messages by page\n    return getNextMessagePage(destinationUuid, destinationDevice, -1, pageSize, bypassLock)\n        .expand(queueItemsAndLastMessageId -> {\n          // expand() is breadth-first, so each page will be published in order\n          if (queueItemsAndLastMessageId.first().isEmpty()) {\n            return Mono.empty();\n          }\n\n          return getNextMessagePage(destinationUuid, destinationDevice, queueItemsAndLastMessageId.second(), pageSize, bypassLock);\n        })\n        .limitRate(1)\n        // we want to ensure we don’t accidentally block the Lettuce/netty i/o executors\n        .publishOn(messageDeliveryScheduler)\n        .map(Pair::first)\n        .concatMap(queueItems -> {\n\n          final List<Mono<MessageProtos.Envelope>> envelopes = new ArrayList<>(queueItems.size() / 2);\n\n          for (int i = 0; i < queueItems.size() - 1; i += 2) {\n            try {\n              final MessageProtos.Envelope message = parseEnvelope(queueItems.get(i));\n\n              final Mono<MessageProtos.Envelope> messageMono;\n              if (message.hasSharedMrmKey()) {\n\n                if (isStaleEphemeralMessage(message, earliestAllowableEphemeralTimestamp)) {\n                  // skip fetching content for message that will be discarded\n                  messageMono = Mono.just(message.toBuilder().clearSharedMrmKey().build());\n                  skippedStaleEphemeralMrmCounter.increment();\n                } else {\n                  messageMono = getMessageWithSharedMrmData(message, destinationDevice);\n                }\n\n              } else {\n                messageMono = Mono.just(message);\n              }\n\n              envelopes.add(messageMono);\n\n            } catch (InvalidProtocolBufferException e) {\n              logger.warn(\"Failed to parse envelope\", e);\n            }\n          }\n\n          return Flux.mergeSequential(envelopes);\n        });\n  }\n\n  /**\n   * Returns the given message with its shared MRM data. There are three possible cases:\n   * <ol>\n   *   <li>The reconstructed message for delivery with {@code content} set and {@code sharedMrmKey} cleared</li>\n   *   <li>The input with {@code sharedMrmKey} set to a static value, indicating that the shared MRM data is no longer available, and the message should be\n   *       discarded from the queue</li>\n   *   <li>An empty {@code Mono}, if an unexpected error occurred</li>\n   * </ol>\n   */\n  private Mono<MessageProtos.Envelope> getMessageWithSharedMrmData(final MessageProtos.Envelope mrmMessage,\n      final byte destinationDevice) {\n\n    assert mrmMessage.hasSharedMrmKey();\n\n    final byte[] key = mrmMessage.getSharedMrmKey().toByteArray();\n    final byte[] sharedMrmViewKey = MessagesCache.getSharedMrmViewKey(\n        // the message might be addressed to the account's PNI, so use the service ID from the envelope\n        ServiceIdentifier.valueOf(mrmMessage.getDestinationServiceId()), destinationDevice);\n\n    return Mono.from(redisCluster.withBinaryCluster(\n            conn -> conn.reactive().hmget(key, \"data\".getBytes(StandardCharsets.UTF_8), sharedMrmViewKey)\n                .collectList()\n                .publishOn(messageDeliveryScheduler)))\n        .transformDeferred(RetryOperator.of(ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)))\n        .<MessageProtos.Envelope>handle((mrmDataAndView, sink) -> {\n          try {\n            assert mrmDataAndView.size() == 2;\n\n            if (mrmDataAndView.getFirst().isEmpty()) {\n              // shared data is missing\n              //noinspection ReactiveStreamsThrowInOperator\n              throw new MrmDataMissingException(MrmDataMissingException.Type.SHARED);\n            }\n\n            if (mrmDataAndView.getLast().isEmpty()) {\n              // recipient's view is missing\n              //noinspection ReactiveStreamsThrowInOperator\n              throw new MrmDataMissingException(MrmDataMissingException.Type.RECIPIENT_VIEW);\n            }\n\n            final byte[] content = SealedSenderMultiRecipientMessage.messageForRecipient(\n                mrmDataAndView.getFirst().getValue(),\n                mrmDataAndView.getLast().getValue());\n\n            sink.next(mrmMessage.toBuilder()\n                .clearSharedMrmKey()\n                .setContent(ByteString.copyFrom(content))\n                .build());\n\n            mrmContentRetrievedCounter.increment();\n          } catch (Exception e) {\n            sink.error(e);\n          }\n        })\n        .onErrorResume(throwable -> {\n\n          final List<Tag> tags = new ArrayList<>();\n          tags.add(Tag.of(EPHEMERAL_TAG_NAME, String.valueOf(mrmMessage.getEphemeral())));\n\n          final Mono<MessageProtos.Envelope> result;\n          if (throwable instanceof MrmDataMissingException mdme) {\n            tags.add(Tag.of(MISSING_MRM_DATA_TAG_NAME, mdme.getType().name()));\n            // MRM data may be missing if either of the two non-transactional writes (delete from queue, update shared\n            // MRM data) fails after it has been delivered. We return it so that it may be discarded from the queue.\n            result = Mono.just(mrmMessage.toBuilder().setSharedMrmKey(STALE_MRM_KEY).build());\n          } else {\n            logger.warn(\"Failed to retrieve shared mrm data\", throwable);\n            // For unexpected errors, return empty. The message will remain in the queue and be retried in the future.\n            result = Mono.empty();\n          }\n\n          Metrics.counter(MRM_RETRIEVAL_ERROR_COUNTER_NAME, tags).increment();\n\n          return result;\n        })\n        .share();\n  }\n\n  /**\n   * Makes a best-effort attempt at asynchronously updating (and removing when empty) the MRM data structure\n   */\n  void removeRecipientViewFromMrmData(final List<byte[]> sharedMrmKeys, final ServiceIdentifier serviceIdentifier,\n      final byte deviceId) {\n\n    if (sharedMrmKeys.isEmpty()) {\n      return;\n    }\n\n    final Timer.Sample sample = Timer.start();\n    Flux.fromIterable(sharedMrmKeys)\n        .collectMultimap(SlotHash::getSlot)\n        .flatMapMany(slotsAndKeys -> Flux.fromIterable(slotsAndKeys.values()))\n        .flatMap(\n            keys -> removeRecipientViewFromMrmDataScript.execute(keys, serviceIdentifier, deviceId),\n            REMOVE_MRM_RECIPIENT_VIEW_CONCURRENCY)\n        .doOnNext(sharedMrmDataKeyRemovedCounter::increment)\n        .onErrorResume(e -> {\n          logger.warn(\"Error removing recipient view\", e);\n          return Mono.just(0L);\n        })\n        .then()\n        .doOnTerminate(() -> sample.stop(removeRecipientViewTimer))\n        .subscribe();\n  }\n\n  private Mono<Pair<List<byte[]>, Long>> getNextMessagePage(final UUID destinationUuid,\n      final byte destinationDevice,\n      final long messageId,\n      final int pageSize,\n      final boolean bypassLock) {\n\n    return getItemsScript.execute(destinationUuid, destinationDevice, pageSize, messageId, bypassLock)\n        .map(queueItems -> {\n          logger.trace(\"Processing page: {}\", messageId);\n\n          if (queueItems.isEmpty()) {\n            return new Pair<>(Collections.emptyList(), null);\n          }\n\n          if (queueItems.size() % 2 != 0) {\n            logger.error(\"\\\"Get messages\\\" operation returned a list with a non-even number of elements.\");\n            return new Pair<>(Collections.emptyList(), null);\n          }\n\n          final long lastMessageId = Long.parseLong(\n              new String(queueItems.getLast(), StandardCharsets.UTF_8));\n\n          return new Pair<>(queueItems, lastMessageId);\n        });\n  }\n\n  /**\n   * Estimate the size of the cached queue if it were to be persisted\n   * @param accountUuid The account identifier\n   * @param destinationDevice The destination device id\n   * @return A future that completes with the approximate size of stored messages that need to be persisted\n   */\n  CompletableFuture<Long> estimatePersistedQueueSizeBytes(final UUID accountUuid, final byte destinationDevice) {\n    final Function<Optional<Long>, Mono<List<ScoredValue<byte[]>>>> getNextPage = (Optional<Long> start) ->\n        Mono.fromCompletionStage(() -> redisCluster.withBinaryCluster(connection ->\n            connection.async().zrangebyscoreWithScores(\n                getMessageQueueKey(accountUuid, destinationDevice),\n                Range.from(\n                    start.map(Range.Boundary::excluding).orElse(Range.Boundary.unbounded()),\n                    Range.Boundary.unbounded()),\n                Limit.from(PAGE_SIZE))));\n    final Flux<byte[]> allSerializedMessages = getNextPage.apply(Optional.empty())\n        .expand(scoredValues -> {\n          if (scoredValues.isEmpty()) {\n            return Mono.empty();\n          }\n          long lastTimestamp = (long) scoredValues.getLast().getScore();\n          return getNextPage.apply(Optional.of(lastTimestamp));\n        })\n        .concatMap(scoredValues -> Flux.fromStream(scoredValues.stream().map(ScoredValue::getValue)));\n\n    return parseAndFetchMrms(allSerializedMessages, destinationDevice)\n        .filter(Predicate.not(envelope -> envelope.getEphemeral() || isStaleMrmMessage(envelope)))\n        .reduce(0L, (acc, envelope) -> acc + envelope.getSerializedSize())\n        .toFuture();\n  }\n\n  private Flux<MessageProtos.Envelope> parseAndFetchMrms(final Flux<byte[]> serializedMessages, final byte destinationDevice) {\n    return serializedMessages\n        .mapNotNull(message -> {\n          try {\n            return parseEnvelope(message);\n          } catch (InvalidProtocolBufferException e) {\n            logger.warn(\"Failed to parse envelope\", e);\n            return null;\n          }\n        })\n        .concatMap(message -> {\n          final Mono<MessageProtos.Envelope> messageMono;\n          if (message.hasSharedMrmKey()) {\n            messageMono = getMessageWithSharedMrmData(message, destinationDevice);\n          } else {\n            messageMono = Mono.just(message);\n          }\n\n          return messageMono;\n        });\n  }\n\n  public CompletableFuture<Void> clear(final UUID destinationUuid) {\n    return CompletableFuture.allOf(\n        Device.ALL_POSSIBLE_DEVICE_IDS.stream()\n            .map(deviceId -> clear(destinationUuid, deviceId))\n            .toList()\n            .toArray(CompletableFuture[]::new));\n  }\n\n  public CompletableFuture<Void> clear(final UUID destinationUuid, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n\n    return removeQueueScript.execute(destinationUuid, deviceId, Collections.emptyList())\n        .publishOn(messageDeletionScheduler)\n        .expand(messagesToProcess -> {\n          if (messagesToProcess.isEmpty()) {\n            return Mono.empty();\n          }\n\n          final Map<ServiceIdentifier, List<byte[]>> serviceIdentifierToMrmKeys = new HashMap<>();\n          final List<String> processedMessages = new ArrayList<>(messagesToProcess.size());\n          for (byte[] serialized : messagesToProcess) {\n            try {\n              final MessageProtos.Envelope message = parseEnvelope(serialized);\n\n              processedMessages.add(message.getServerGuid());\n\n              if (message.hasSharedMrmKey()) {\n                serviceIdentifierToMrmKeys.computeIfAbsent(ServiceIdentifier.valueOf(message.getDestinationServiceId()),\n                        _ -> new ArrayList<>())\n                    .add(message.getSharedMrmKey().toByteArray());\n              }\n            } catch (final InvalidProtocolBufferException e) {\n              logger.warn(\"Failed to parse envelope\", e);\n            }\n          }\n\n          serviceIdentifierToMrmKeys.forEach((serviceId, keysToUpdate) ->\n              removeRecipientViewFromMrmData(keysToUpdate, serviceId, deviceId));\n\n          return removeQueueScript.execute(destinationUuid, deviceId, processedMessages);\n        })\n        .then()\n        .toFuture()\n        .thenRun(() -> sample.stop(clearQueueTimer));\n  }\n\n  Optional<RedisClusterNode> claimNextNodeToPersist(final String persisterId, final Duration ttl) {\n    final List<RedisClusterNode> primaryNodes =  redisCluster.withCluster(connection -> connection.getPartitions().stream()\n        .filter(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM))\n        .collect(Collectors.toCollection(ArrayList::new)));\n\n    Collections.rotate(primaryNodes, Math.abs(nextNodeRotateDistance++) % primaryNodes.size());\n\n    return primaryNodes.stream()\n        .filter(node -> claimNode(node, persisterId, ttl))\n        .findFirst();\n  }\n\n  @VisibleForTesting\n  boolean claimNode(final RedisClusterNode node, final String persisterId, final Duration claimTtl) {\n    return redisCluster.withCluster(connection ->\n        \"OK\".equals(connection.sync().set(getPersisterNodeClaimKey(node), persisterId, SetArgs.Builder.nx().ex(claimTtl))));\n  }\n\n  void releaseNodeClaim(final RedisClusterNode node, final String persisterId) {\n    releaseNodeClaimScript.execute(node, persisterId);\n  }\n\n  @VisibleForTesting\n  static String getPersisterNodeClaimKey(final RedisClusterNode node) {\n    return \"persister_node_claim::\" + node.getNodeId();\n  }\n\n  Flux<String> getQueues(final RedisClusterNode node, final int scanCount) {\n    return redisCluster.withCluster(connection ->\n        ScanStream.scan(connection.getConnection(node.getNodeId()).reactive(),\n            ScanArgs.Builder.matches(\"user_queue::*\").limit(scanCount)));\n  }\n\n  Mono<Void> lockQueueForPersistence(final UUID accountUuid, final byte deviceId) {\n    return redisCluster.withBinaryCluster(\n        connection -> connection.reactive().setex(getPersistInProgressKey(accountUuid, deviceId), 30, LOCK_VALUE))\n        .then();\n  }\n\n  Mono<Void> unlockQueueForPersistence(final UUID accountUuid, final byte deviceId) {\n    return unlockQueueScript.execute(accountUuid, deviceId);\n  }\n\n  static byte[] getMessageQueueKey(final UUID accountUuid, final byte deviceId) {\n    return (\"user_queue::{\" + accountUuid.toString() + \"::\" + deviceId + \"}\").getBytes(StandardCharsets.UTF_8);\n  }\n\n  static byte[] getMessageQueueMetadataKey(final UUID accountUuid, final byte deviceId) {\n    return (\"user_queue_metadata::{\" + accountUuid.toString() + \"::\" + deviceId + \"}\").getBytes(StandardCharsets.UTF_8);\n  }\n\n  static byte[] getSharedMrmKey(final UUID mrmGuid) {\n    return (\"mrm::{\" + mrmGuid.toString() + \"}\").getBytes(StandardCharsets.UTF_8);\n  }\n\n  static byte[] getPersistInProgressKey(final UUID accountUuid, final byte deviceId) {\n    return (\"user_queue_persisting::{\" + accountUuid + \"::\" + deviceId + \"}\").getBytes(StandardCharsets.UTF_8);\n  }\n\n  static byte[] getSharedMrmViewKey(final ServiceId serviceId, final byte deviceId) {\n    return getSharedMrmViewKey(serviceId.toServiceIdFixedWidthBinary(), deviceId);\n  }\n\n  static byte[] getSharedMrmViewKey(final ServiceIdentifier serviceIdentifier, final byte deviceId) {\n    return getSharedMrmViewKey(serviceIdentifier.toFixedWidthByteArray(), deviceId);\n  }\n\n  private static byte[] getSharedMrmViewKey(final byte[] fixedWithServiceId, final byte deviceId) {\n    assert fixedWithServiceId.length == 17;\n\n    final ByteBuffer keyBb = ByteBuffer.allocate(18);\n    keyBb.put(fixedWithServiceId);\n    keyBb.put(deviceId);\n    assert !keyBb.hasRemaining();\n    return keyBb.array();\n  }\n\n  static UUID getAccountUuidFromQueueName(final String queueName) {\n    final int startOfHashTag = queueName.indexOf('{');\n\n    return UUID.fromString(queueName.substring(startOfHashTag + 1, queueName.indexOf(\"::\", startOfHashTag)));\n  }\n\n  static byte getDeviceIdFromQueueName(final String queueName) {\n    return Byte.parseByte(queueName.substring(queueName.lastIndexOf(\"::\") + 2, queueName.lastIndexOf('}')));\n  }\n\n  private MessageProtos.Envelope parseEnvelope(final byte[] envelopeBytes)\n      throws InvalidProtocolBufferException {\n\n    return EnvelopeUtil.expand(MessageProtos.Envelope.parseFrom(envelopeBytes), experimentEnrollmentManager);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheGetItemsScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.github.resilience4j.reactor.retry.RetryOperator;\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.List;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport reactor.core.publisher.Mono;\n\n/**\n * Retrieves a list of messages and their corresponding queue-local IDs for the device. To support streaming processing,\n * the last queue-local message ID from a previous call may be used as the {@code afterMessageId}.\n */\nclass MessagesCacheGetItemsScript {\n\n  private final ClusterLuaScript getItemsScript;\n\n  MessagesCacheGetItemsScript(FaultTolerantRedisClusterClient redisCluster) throws IOException {\n    this.getItemsScript = ClusterLuaScript.fromResource(redisCluster, \"lua/get_items.lua\", ScriptOutputType.OBJECT);\n  }\n\n  Mono<List<byte[]>> execute(final UUID destinationUuid,\n      final byte destinationDevice,\n      final int limit,\n      final long afterMessageId,\n      final boolean bypassLock) {\n\n    final List<byte[]> keys = List.of(\n        MessagesCache.getMessageQueueKey(destinationUuid, destinationDevice), // queueKey\n        MessagesCache.getPersistInProgressKey(destinationUuid, destinationDevice) // queueLockKey\n    );\n    final List<byte[]> args = List.of(\n        String.valueOf(limit).getBytes(StandardCharsets.UTF_8), // limit\n        String.valueOf(afterMessageId).getBytes(StandardCharsets.UTF_8), // afterMessageId\n        String.valueOf(bypassLock).getBytes(StandardCharsets.UTF_8) // bypassLock\n    );\n    //noinspection unchecked\n    return getItemsScript.executeBinaryReactive(keys, args)\n        .transformDeferred(RetryOperator.of(ResilienceUtil.getGeneralRedisRetry(MessagesCache.RETRY_NAME)))\n        .map(result -> (List<byte[]>) result)\n        .next();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheInsertScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.push.ClientEvent;\nimport org.whispersystems.textsecuregcm.push.NewMessageAvailableEvent;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\n/**\n * Inserts an envelope into the message queue for a destination device and publishes a \"new message available\" event.\n */\nclass MessagesCacheInsertScript {\n\n  private final ClusterLuaScript insertScript;\n  private final ScheduledExecutorService retryExecutor;\n\n  private static final byte[] NEW_MESSAGE_EVENT_BYTES = ClientEvent.newBuilder()\n      .setNewMessageAvailable(NewMessageAvailableEvent.getDefaultInstance())\n      .build()\n      .toByteArray();\n\n  MessagesCacheInsertScript(FaultTolerantRedisClusterClient redisCluster,\n      final ScheduledExecutorService retryExecutor) throws IOException {\n\n    this.insertScript = ClusterLuaScript.fromResource(redisCluster, \"lua/insert_item.lua\", ScriptOutputType.BOOLEAN);\n    this.retryExecutor = retryExecutor;\n  }\n\n  /**\n   * Inserts a message into the given device's message queue and publishes a \"new message available\" event.\n   *\n   * @param destinationUuid the account identifier for the receiving account\n   * @param destinationDevice the ID of the receiving device within the given account\n   * @param envelope the message to insert\n   * @return {@code true} if the destination device had a registered \"presence\"/event subscriber or {@code false}\n   * otherwise\n   */\n  CompletionStage<Boolean> executeAsync(final UUID destinationUuid, final byte destinationDevice, final MessageProtos.Envelope envelope) {\n    assert envelope.hasServerGuid();\n    assert envelope.hasServerTimestamp();\n\n    final List<byte[]> keys = List.of(\n        MessagesCache.getMessageQueueKey(destinationUuid, destinationDevice), // queueKey\n        MessagesCache.getMessageQueueMetadataKey(destinationUuid, destinationDevice), // queueMetadataKey\n        RedisMessageAvailabilityManager.getClientEventChannel(destinationUuid, destinationDevice) // eventChannelKey\n    );\n\n    final List<byte[]> args = new ArrayList<>(Arrays.asList(\n        EnvelopeUtil.compress(envelope).toByteArray(), // message\n        envelope.getServerGuid().getBytes(StandardCharsets.UTF_8), // guid\n        NEW_MESSAGE_EVENT_BYTES // eventPayload\n    ));\n\n    return ResilienceUtil.getGeneralRedisRetry(MessagesCache.RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> insertScript.executeBinaryAsync(keys, args))\n        .thenApply(result -> (boolean) result);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n/**\n * Inserts the shared multi-recipient message payload into the cache. The list of recipients and views will be set as\n * fields in the hash.\n *\n * @see SealedSenderMultiRecipientMessage#serializedRecipientView(SealedSenderMultiRecipientMessage.Recipient)\n */\nclass MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript {\n\n  private final ClusterLuaScript script;\n  private final ScheduledExecutorService retryExecutor;\n\n  static final String ERROR_KEY_EXISTS = \"ERR key exists\";\n\n  MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript(FaultTolerantRedisClusterClient redisCluster,\n      final ScheduledExecutorService retryExecutor) throws IOException {\n\n    this.script = ClusterLuaScript.fromResource(redisCluster, \"lua/insert_shared_multirecipient_message_data.lua\",\n        ScriptOutputType.INTEGER);\n\n    this.retryExecutor = retryExecutor;\n  }\n\n  CompletionStage<Void> executeAsync(final byte[] sharedMrmKey, final SealedSenderMultiRecipientMessage message) {\n    final List<byte[]> keys = List.of(\n        sharedMrmKey // sharedMrmKey\n    );\n\n    // Pre-allocate capacity for the most fields we expect -- 6 devices per recipient, plus the data field.\n    final List<byte[]> args = new ArrayList<>(message.getRecipients().size() * 6 + 1);\n    args.add(message.serialized());\n\n    message.getRecipients().forEach((serviceId, recipient) -> {\n      for (byte device : recipient.getDevices()) {\n        args.add(MessagesCache.getSharedMrmViewKey(serviceId, device));\n        args.add(message.serializedRecipientView(recipient));\n      }\n    });\n\n    return ResilienceUtil.getGeneralRedisRetry(MessagesCache.RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> script.executeBinaryAsync(keys, args))\n        .thenRun(Util.NOOP);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheReleaseNodeClaimScript.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport java.io.IOException;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\nclass MessagesCacheReleaseNodeClaimScript {\n\n  private final ClusterLuaScript releaseNodeClaimScript;\n\n  MessagesCacheReleaseNodeClaimScript(final FaultTolerantRedisClusterClient redisCluster) throws IOException {\n    this.releaseNodeClaimScript =\n        ClusterLuaScript.fromResource(redisCluster, \"lua/release_node_claim.lua\", ScriptOutputType.STATUS);\n  }\n\n  void execute(final RedisClusterNode node, final String persisterId) {\n    final List<String> keys = List.of(MessagesCache.getPersisterNodeClaimKey(node));\n    final List<String> arguments = List.of(persisterId);\n\n    ResilienceUtil.getGeneralRedisRetry(getClass().getSimpleName())\n        .executeRunnable(() -> releaseNodeClaimScript.execute(keys, arguments));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheRemoveByGuidScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\n/**\n * Removes a list of message GUIDs from the queue of a destination device.\n */\nclass MessagesCacheRemoveByGuidScript {\n\n  private final ClusterLuaScript removeByGuidScript;\n  private final ScheduledExecutorService retryExecutor;\n\n  MessagesCacheRemoveByGuidScript(final FaultTolerantRedisClusterClient redisCluster,\n      final ScheduledExecutorService retryExecutor) throws IOException {\n\n    this.removeByGuidScript = ClusterLuaScript.fromResource(redisCluster, \"lua/remove_item_by_guid.lua\",\n        ScriptOutputType.OBJECT);\n    this.retryExecutor = retryExecutor;\n  }\n\n  CompletionStage<List<byte[]>> execute(final UUID destinationUuid, final byte destinationDevice,\n      final List<UUID> messageGuids) {\n\n    final List<byte[]> keys = List.of(\n        MessagesCache.getMessageQueueKey(destinationUuid, destinationDevice), // queueKey\n        MessagesCache.getMessageQueueMetadataKey(destinationUuid, destinationDevice) // queueMetadataKey\n    );\n    final List<byte[]> args = messageGuids.stream().map(guid -> guid.toString().getBytes(StandardCharsets.UTF_8))\n        .toList();\n\n    //noinspection unchecked\n    return ResilienceUtil.getGeneralRedisRetry(MessagesCache.RETRY_NAME)\n        .executeCompletionStage(retryExecutor, () -> removeByGuidScript.executeBinaryAsync(keys, args))\n        .thenApply(result -> (List<byte[]>) result);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheRemoveQueueScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport reactor.core.publisher.Mono;\n\n/**\n * Removes a device's queue from the cache. For a non-empty queue, this script must be executed multiple times.\n * <ol>\n *  <li>The first call will return a list of messages to check for {@code sharedMrmKeys}. If a {@code sharedMrmKey} is present, {@link MessagesCacheRemoveRecipientViewFromMrmDataScript} must be called.</li>\n *  <li>Once theses messages have been processed, this script should be called again, confirming that the messages have been processed.</li>\n *  <li>This should be repeated until the script returns an empty list, as the script only returns a page ({@value PAGE_SIZE}) of messages at a time.</li>\n * </ol>\n */\nclass MessagesCacheRemoveQueueScript {\n\n  private static final int PAGE_SIZE = 100;\n\n  private final ClusterLuaScript removeQueueScript;\n\n  MessagesCacheRemoveQueueScript(FaultTolerantRedisClusterClient redisCluster) throws IOException {\n    this.removeQueueScript = ClusterLuaScript.fromResource(redisCluster, \"lua/remove_queue.lua\",\n        ScriptOutputType.MULTI);\n  }\n\n  Mono<List<byte[]>> execute(final UUID destinationUuid, final byte destinationDevice,\n      final List<String> processedMessageGuids) {\n\n    final List<byte[]> keys = List.of(\n        MessagesCache.getMessageQueueKey(destinationUuid, destinationDevice), // queueKey\n        MessagesCache.getMessageQueueMetadataKey(destinationUuid, destinationDevice) // queueMetadataKey\n    );\n\n    final List<byte[]> args = new ArrayList<>();\n\n    args.addFirst(String.valueOf(PAGE_SIZE).getBytes(StandardCharsets.UTF_8)); // limit\n    args.addAll(processedMessageGuids.stream().map(guid -> guid.getBytes(StandardCharsets.UTF_8))\n        .toList()); // processedMessageGuids\n\n    //noinspection unchecked\n    return removeQueueScript.executeBinaryReactive(keys, args)\n        .map(result -> (List<byte[]>) result)\n        .next();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheRemoveRecipientViewFromMrmDataScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport reactor.core.publisher.Mono;\n\n/**\n * Removes the given destination device from the given {@code sharedMrmKeys}. If there are no devices remaining in the\n * hash as a result, the shared payload is deleted.\n * <p>\n * NOTE: Callers are responsible for ensuring that all keys are in the same slot.\n */\nclass MessagesCacheRemoveRecipientViewFromMrmDataScript {\n\n  private final ClusterLuaScript removeRecipientViewFromMrmDataScript;\n\n  MessagesCacheRemoveRecipientViewFromMrmDataScript(final FaultTolerantRedisClusterClient redisCluster) throws IOException {\n    this.removeRecipientViewFromMrmDataScript = ClusterLuaScript.fromResource(redisCluster,\n        \"lua/remove_recipient_view_from_mrm_data.lua\", ScriptOutputType.INTEGER);\n  }\n\n  Mono<Long> execute(final Collection<byte[]> keysCollection, final ServiceIdentifier serviceIdentifier,\n      final byte deviceId) {\n\n    final List<byte[]> keys = keysCollection instanceof List<byte[]>\n        ? (List<byte[]>) keysCollection\n        : new ArrayList<>(keysCollection);\n\n    return removeRecipientViewFromMrmDataScript.executeBinaryReactive(keys,\n            List.of(MessagesCache.getSharedMrmViewKey(serviceIdentifier, deviceId)))\n        .map(o -> (long) o)\n        .next();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesCacheUnlockQueueScript.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.push.ClientEvent;\nimport org.whispersystems.textsecuregcm.push.MessagesPersistedEvent;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport reactor.core.publisher.Mono;\nimport reactor.util.retry.Retry;\n\n/**\n * Unlocks a message queue for persistence/message retrieval.\n */\nclass MessagesCacheUnlockQueueScript {\n\n  private final ClusterLuaScript unlockQueueScript;\n\n  private final List<byte[]> MESSAGES_PERSISTED_EVENT_ARGS = List.of(ClientEvent.newBuilder()\n      .setMessagesPersisted(MessagesPersistedEvent.getDefaultInstance())\n      .build()\n      .toByteArray()); // eventPayload\n\n  MessagesCacheUnlockQueueScript(final FaultTolerantRedisClusterClient redisCluster) throws IOException {\n    this.unlockQueueScript =\n        ClusterLuaScript.fromResource(redisCluster, \"lua/unlock_queue.lua\", ScriptOutputType.STATUS);\n  }\n\n  Mono<Void> execute(final UUID accountIdentifier, final byte deviceId) {\n    final List<byte[]> keys = List.of(\n        MessagesCache.getPersistInProgressKey(accountIdentifier, deviceId), // persistInProgressKey\n        RedisMessageAvailabilityManager.getClientEventChannel(accountIdentifier, deviceId) // eventChannelKey\n    );\n\n    return unlockQueueScript.executeBinaryReactive(keys, MESSAGES_PERSISTED_EVENT_ARGS)\n        .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n        .then();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDb.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static io.micrometer.core.instrument.Metrics.timer;\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.collect.ImmutableMap;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.micrometer.core.instrument.Timer;\nimport java.nio.ByteBuffer;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.function.Predicate;\nimport org.reactivestreams.Publisher;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport reactor.core.publisher.Flux;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.WriteRequest;\n\npublic class MessagesDynamoDb extends AbstractDynamoDbStore {\n\n  @VisibleForTesting\n  static final String KEY_PARTITION = \"H\";\n\n  @VisibleForTesting\n  static final String KEY_SORT = \"S\";\n\n  @VisibleForTesting\n  static final String LOCAL_INDEX_MESSAGE_UUID_KEY_SORT = \"U\";\n\n  @VisibleForTesting\n  static final int MAY_HAVE_URGENT_MESSAGES_QUERY_LIMIT = 20;\n\n  private static final String KEY_TTL = \"E\";\n  private static final String KEY_ENVELOPE_BYTES = \"EB\";\n\n  private final Timer storeTimer = timer(name(getClass(), \"store\"));\n\n  private final DynamoDbAsyncClient dbAsyncClient;\n  private final String tableName;\n  private final Duration timeToLive;\n  private final ExecutorService messageDeletionExecutor;\n  private final ExperimentEnrollmentManager experimentEnrollmentManager;\n\n  private static final Logger logger = LoggerFactory.getLogger(MessagesDynamoDb.class);\n\n  public MessagesDynamoDb(DynamoDbClient dynamoDb, DynamoDbAsyncClient dynamoDbAsyncClient, String tableName,\n      Duration timeToLive, ExecutorService messageDeletionExecutor,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) {\n    super(dynamoDb);\n\n    this.dbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n    this.timeToLive = timeToLive;\n\n    this.messageDeletionExecutor = messageDeletionExecutor;\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n  }\n\n  public void store(final List<MessageProtos.Envelope> messages, final UUID destinationAccountUuid,\n      final Device destinationDevice) {\n    storeTimer.record(() -> writeInBatches(messages, (messageBatch) -> storeBatch(messageBatch, destinationAccountUuid, destinationDevice)));\n  }\n\n  private void storeBatch(final List<MessageProtos.Envelope> messages, final UUID destinationAccountUuid,\n      final Device destinationDevice) {\n    if (messages.size() > DYNAMO_DB_MAX_BATCH_SIZE) {\n      throw new IllegalArgumentException(\"Maximum batch size of \" + DYNAMO_DB_MAX_BATCH_SIZE + \" exceeded with \" + messages.size() + \" messages\");\n    }\n\n    final AttributeValue partitionKey = convertPartitionKey(destinationAccountUuid, destinationDevice);\n    List<WriteRequest> writeItems = new ArrayList<>();\n    for (MessageProtos.Envelope message : messages) {\n      final UUID messageUuid = UUID.fromString(message.getServerGuid());\n\n      final ImmutableMap.Builder<String, AttributeValue> item = ImmutableMap.<String, AttributeValue>builder()\n          .put(KEY_PARTITION, partitionKey)\n          .put(KEY_SORT, convertSortKey(message.getServerTimestamp(), messageUuid))\n          .put(LOCAL_INDEX_MESSAGE_UUID_KEY_SORT, convertLocalIndexMessageUuidSortKey(messageUuid))\n          .put(KEY_TTL, AttributeValues.fromLong(getTtlForMessage(message)))\n          .put(KEY_ENVELOPE_BYTES, AttributeValue.builder().b(SdkBytes.fromByteArray(EnvelopeUtil.compress(message).toByteArray())).build());\n\n      writeItems.add(WriteRequest.builder().putRequest(PutRequest.builder()\n          .item(item.build())\n          .build()).build());\n    }\n\n    executeTableWriteItemsUntilComplete(Map.of(tableName, writeItems));\n  }\n\n  public CompletableFuture<Boolean> mayHaveMessages(final UUID accountIdentifier, final Device device) {\n    return dbAsyncClient.query(QueryRequest.builder()\n            .tableName(tableName)\n            .consistentRead(false)\n            .limit(1)\n            .keyConditionExpression(\"#part = :part\")\n            .expressionAttributeNames(Map.of(\"#part\", KEY_PARTITION))\n            .expressionAttributeValues(Map.of(\":part\", convertPartitionKey(accountIdentifier, device))).build())\n        .thenApply(queryResponse -> queryResponse.count() > 0);\n  }\n\n  public CompletableFuture<Boolean> mayHaveUrgentMessages(final UUID accountIdentifier, final Device device) {\n    return Flux.from(load(accountIdentifier, device, MAY_HAVE_URGENT_MESSAGES_QUERY_LIMIT))\n        .any(MessageProtos.Envelope::getUrgent)\n        .toFuture();\n  }\n\n  public Publisher<MessageProtos.Envelope> load(final UUID destinationAccountUuid, final Device device, final Integer limit) {\n    QueryRequest.Builder queryRequestBuilder = QueryRequest.builder()\n        .tableName(tableName)\n        .consistentRead(true)\n        .keyConditionExpression(\"#part = :part\")\n        .expressionAttributeNames(Map.of(\"#part\", KEY_PARTITION))\n        .expressionAttributeValues(Map.of(\":part\", convertPartitionKey(destinationAccountUuid, device)));\n\n    if (limit != null) {\n      // some callers don’t take advantage of reactive streams, so we want to support limiting the fetch size. Otherwise,\n      // we could fetch up to 1 MB (likely >1,000 messages) and discard 90% of them\n      queryRequestBuilder.limit(Math.min(RESULT_SET_CHUNK_SIZE, limit));\n    }\n\n    final QueryRequest queryRequest = queryRequestBuilder.build();\n\n    return dbAsyncClient.queryPaginator(queryRequest).items()\n        .map(message -> {\n          try {\n            return convertItemToEnvelope(message, experimentEnrollmentManager);\n          } catch (final InvalidProtocolBufferException e) {\n            logger.error(\"Failed to parse envelope\", e);\n            return null;\n          }\n        })\n        .filter(Predicate.not(Objects::isNull));\n  }\n\n  public CompletableFuture<Optional<MessageProtos.Envelope>> deleteMessage(final UUID destinationAccountUuid,\n      final Device destinationDevice, final UUID messageUuid, final long serverTimestamp) {\n    DeleteItemRequest.Builder deleteItemRequest = DeleteItemRequest.builder()\n        .tableName(tableName)\n        .key(Map.of(KEY_PARTITION, convertPartitionKey(destinationAccountUuid, destinationDevice), KEY_SORT, convertSortKey(serverTimestamp, messageUuid)))\n        .returnValues(ReturnValue.ALL_OLD);\n\n    return dbAsyncClient.deleteItem(deleteItemRequest.build())\n        .thenApplyAsync(deleteItemResponse -> {\n          if (deleteItemResponse.attributes() != null && deleteItemResponse.attributes().containsKey(KEY_PARTITION)) {\n            try {\n              return Optional.of(convertItemToEnvelope(deleteItemResponse.attributes(), experimentEnrollmentManager));\n            } catch (final InvalidProtocolBufferException e) {\n              logger.error(\"Failed to parse envelope\", e);\n            }\n          }\n\n          return Optional.empty();\n        }, messageDeletionExecutor);\n  }\n\n  @VisibleForTesting\n  static MessageProtos.Envelope convertItemToEnvelope(final Map<String, AttributeValue> item,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) throws InvalidProtocolBufferException {\n\n    return EnvelopeUtil.expand(MessageProtos.Envelope.parseFrom(item.get(KEY_ENVELOPE_BYTES).b().asByteArray()),\n        experimentEnrollmentManager);\n  }\n\n  private long getTtlForMessage(MessageProtos.Envelope message) {\n    return message.getServerTimestamp() / 1000 + timeToLive.getSeconds();\n  }\n\n  private static AttributeValue convertPartitionKey(final UUID destinationAccountUuid, final Device destinationDevice) {\n    final ByteBuffer byteBuffer = ByteBuffer.allocate(24);\n    byteBuffer.putLong(destinationAccountUuid.getMostSignificantBits());\n    byteBuffer.putLong(destinationAccountUuid.getLeastSignificantBits());\n    byteBuffer.putLong((destinationDevice.getCreated() & ~0x7f) + destinationDevice.getId());\n    return AttributeValues.fromByteBuffer(byteBuffer.flip());\n  }\n\n  private static AttributeValue convertSortKey(final long serverTimestamp, final UUID messageUuid) {\n    final ByteBuffer byteBuffer = ByteBuffer.allocate(24);\n    byteBuffer.putLong(serverTimestamp);\n    byteBuffer.putLong(messageUuid.getMostSignificantBits());\n    byteBuffer.putLong(messageUuid.getLeastSignificantBits());\n    return AttributeValues.fromByteBuffer(byteBuffer.flip());\n  }\n\n  private static AttributeValue convertLocalIndexMessageUuidSortKey(final UUID messageUuid) {\n    return AttributeValues.fromUUID(messageUuid);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MessagesManager.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.protobuf.ByteString;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport javax.annotation.Nullable;\nimport org.reactivestreams.Publisher;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport reactor.core.observability.micrometer.Micrometer;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\n\npublic class MessagesManager {\n\n  private static final int RESULT_SET_CHUNK_SIZE = 100;\n  final String GET_MESSAGES_FOR_DEVICE_FLUX_NAME = name(MessagesManager.class, \"getMessagesForDevice\");\n\n  private static final Logger logger = LoggerFactory.getLogger(MessagesManager.class);\n\n  private static final Counter PERSIST_MESSAGE_COUNTER = Metrics.counter(\n      name(MessagesManager.class, \"persistMessage\"));\n\n  private static final Counter PERSIST_MESSAGE_BYTES_COUNTER = Metrics.counter(\n      name(MessagesManager.class, \"persistMessageBytes\"));\n\n  private static final String MAY_HAVE_MESSAGES_COUNTER_NAME =\n      MetricsUtil.name(MessagesManager.class, \"mayHaveMessages\");\n\n  private final MessagesDynamoDb messagesDynamoDb;\n  private final MessagesCache messagesCache;\n  private final RedisMessageAvailabilityManager redisMessageAvailabilityManager;\n  private final ReportMessageManager reportMessageManager;\n  private final ExecutorService messageDeletionExecutor;\n  private final Clock clock;\n\n  public MessagesManager(\n      final MessagesDynamoDb messagesDynamoDb,\n      final MessagesCache messagesCache,\n      final RedisMessageAvailabilityManager redisMessageAvailabilityManager,\n      final ReportMessageManager reportMessageManager,\n      final ExecutorService messageDeletionExecutor,\n      final Clock clock) {\n\n    this.messagesDynamoDb = messagesDynamoDb;\n    this.messagesCache = messagesCache;\n    this.redisMessageAvailabilityManager = redisMessageAvailabilityManager;\n    this.reportMessageManager = reportMessageManager;\n    this.messageDeletionExecutor = messageDeletionExecutor;\n    this.clock = clock;\n  }\n\n  /**\n   * Inserts messages into the message queues for devices associated with the identified account.\n   *\n   * @param accountIdentifier the account identifier for the destination queue\n   * @param messagesByDeviceId a map of device IDs to messages\n   *\n   * @return a map of device IDs to a device's presence state (i.e. if the device has an active event listener)\n   *\n   * @see RedisMessageAvailabilityManager\n   */\n  public Map<Byte, Boolean> insert(final UUID accountIdentifier, final Map<Byte, Envelope> messagesByDeviceId) {\n    return insertAsync(accountIdentifier, messagesByDeviceId).join();\n  }\n\n  private CompletableFuture<Map<Byte, Boolean>> insertAsync(final UUID accountIdentifier, final Map<Byte, Envelope> messagesByDeviceId) {\n    final Map<Byte, Boolean> devicePresenceById = new ConcurrentHashMap<>();\n\n    return CompletableFuture.allOf(messagesByDeviceId.entrySet().stream()\n            .map(deviceIdAndMessage -> {\n              final byte deviceId = deviceIdAndMessage.getKey();\n              final Envelope message = deviceIdAndMessage.getValue();\n              final UUID messageGuid = UUID.randomUUID();\n\n              return messagesCache.insert(messageGuid, accountIdentifier, deviceId, message)\n                  .thenAccept(present -> {\n                    if (message.hasSourceServiceId() && !accountIdentifier.toString()\n                        .equals(message.getSourceServiceId())) {\n                      // Note that this is an asynchronous, best-effort, fire-and-forget operation\n                      reportMessageManager.store(message.getSourceServiceId(), messageGuid);\n                    }\n\n                    devicePresenceById.put(deviceId, present);\n                  });\n            })\n            .toArray(CompletableFuture[]::new))\n        .thenApply(ignored -> devicePresenceById);\n  }\n\n  /**\n   * Inserts messages into the message queues for devices associated with the identified accounts.\n   *\n   * @param multiRecipientMessage the multi-recipient message to insert into destination queues\n   * @param resolvedRecipients    a map of multi-recipient message {@code Recipient} entities to their corresponding\n   *                              Signal accounts; messages will not be delivered to unresolved recipients\n   * @param clientTimestamp       the timestamp for the message as reported by the sending party\n   * @param isStory               {@code true} if the given message is a story or {@code false} otherwise\n   * @param isEphemeral           {@code true} if the given message should only be delivered to devices with active\n   *                              connections to a Signal server or {@code false} otherwise\n   * @param isUrgent              {@code true} if the given message is urgent or {@code false} otherwise\n   *\n   * @return a map of accounts to maps of device IDs to a device's presence state (i.e. if the device has an active\n   * event listener)\n   *\n   * @see RedisMessageAvailabilityManager\n   */\n  public CompletableFuture<Map<Account, Map<Byte, Boolean>>> insertMultiRecipientMessage(\n      final SealedSenderMultiRecipientMessage multiRecipientMessage,\n      final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients,\n      final long clientTimestamp,\n      final boolean isStory,\n      final boolean isEphemeral,\n      final boolean isUrgent) {\n\n    final long serverTimestamp = clock.millis();\n\n    return insertSharedMultiRecipientMessagePayload(multiRecipientMessage)\n        .thenCompose(sharedMrmKey -> {\n          final Envelope.Builder envelopeBuilder = Envelope.newBuilder()\n              .setType(Envelope.Type.UNIDENTIFIED_SENDER)\n              .setClientTimestamp(clientTimestamp == 0 ? serverTimestamp : clientTimestamp)\n              .setServerTimestamp(serverTimestamp)\n              .setEphemeral(isEphemeral)\n              .setUrgent(isUrgent)\n              .setSharedMrmKey(ByteString.copyFrom(sharedMrmKey));\n\n          if (isStory) {\n            // Avoid sending this field if it's false.\n            envelopeBuilder.setStory(true);\n          }\n\n          final Envelope prototypeMessage = envelopeBuilder.build();\n\n          final Map<Account, Map<Byte, Boolean>> clientPresenceByAccountAndDevice = new ConcurrentHashMap<>();\n\n          return CompletableFuture.allOf(multiRecipientMessage.getRecipients().entrySet().stream()\n                  .filter(serviceIdAndRecipient -> resolvedRecipients.containsKey(serviceIdAndRecipient.getValue()))\n                  .map(serviceIdAndRecipient -> {\n                    final ServiceIdentifier serviceIdentifier = ServiceIdentifier.fromLibsignal(serviceIdAndRecipient.getKey());\n                    final SealedSenderMultiRecipientMessage.Recipient recipient = serviceIdAndRecipient.getValue();\n                    final byte[] devices = recipient.getDevices();\n\n                    return insertAsync(resolvedRecipients.get(recipient).getIdentifier(IdentityType.ACI),\n                        IntStream.range(0, devices.length).mapToObj(i -> devices[i])\n                            .collect(Collectors.toMap(deviceId -> deviceId, deviceId -> prototypeMessage.toBuilder()\n                                .setDestinationServiceId(serviceIdentifier.toServiceIdentifierString())\n                                .build())))\n                        .thenAccept(clientPresenceByDeviceId ->\n                            clientPresenceByAccountAndDevice.put(resolvedRecipients.get(recipient),\n                                clientPresenceByDeviceId));\n                  })\n                  .toArray(CompletableFuture[]::new))\n              .thenApply(ignored -> clientPresenceByAccountAndDevice);\n        });\n  }\n\n  public CompletableFuture<Boolean> mayHavePersistedMessages(final UUID destinationUuid, final Device destinationDevice) {\n    return messagesDynamoDb.mayHaveMessages(destinationUuid, destinationDevice);\n  }\n\n  public CompletableFuture<Boolean> mayHaveMessages(final UUID destinationUuid, final Device destinationDevice) {\n    return messagesCache.hasMessagesAsync(destinationUuid, destinationDevice.getId())\n        .thenCombine(messagesDynamoDb.mayHaveMessages(destinationUuid, destinationDevice),\n            (mayHaveCachedMessages, mayHavePersistedMessages) -> {\n              final String outcome;\n\n              if (mayHaveCachedMessages && mayHavePersistedMessages) {\n                outcome = \"both\";\n              } else if (mayHaveCachedMessages) {\n                outcome = \"cached\";\n              } else if (mayHavePersistedMessages) {\n                outcome = \"persisted\";\n              } else {\n                outcome = \"none\";\n              }\n\n              Metrics.counter(MAY_HAVE_MESSAGES_COUNTER_NAME, \"outcome\", outcome).increment();\n\n              return mayHaveCachedMessages || mayHavePersistedMessages;\n            });\n  }\n\n  public CompletableFuture<Boolean> mayHaveUrgentPersistedMessages(final UUID destinationUuid, final Device destinationDevice) {\n    return messagesDynamoDb.mayHaveUrgentMessages(destinationUuid, destinationDevice);\n  }\n\n  public Mono<Pair<List<Envelope>, Boolean>> getMessagesForDevice(UUID destinationUuid, Device destinationDevice,\n      boolean cachedMessagesOnly) {\n\n    return Flux.from(\n            getMessagesForDevice(destinationUuid, destinationDevice, RESULT_SET_CHUNK_SIZE, cachedMessagesOnly))\n        .take(RESULT_SET_CHUNK_SIZE)\n        .collectList()\n        .map(envelopes -> new Pair<>(envelopes, envelopes.size() >= RESULT_SET_CHUNK_SIZE));\n  }\n\n  public Publisher<Envelope> getMessagesForDeviceReactive(UUID destinationUuid, Device destinationDevice,\n      final boolean cachedMessagesOnly) {\n\n    return getMessagesForDevice(destinationUuid, destinationDevice, null, cachedMessagesOnly);\n  }\n\n  public MessageStream getMessages(final UUID destinationUuid, final Device destinationDevice) {\n    return new RedisDynamoDbMessageStream(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, destinationUuid, destinationDevice);\n  }\n\n  private Publisher<Envelope> getMessagesForDevice(UUID destinationUuid, Device destinationDevice,\n      @Nullable Integer limit, final boolean cachedMessagesOnly) {\n\n    final Publisher<Envelope> dynamoPublisher =\n        cachedMessagesOnly ? Flux.empty() : messagesDynamoDb.load(destinationUuid, destinationDevice, limit);\n    final Publisher<Envelope> cachePublisher = messagesCache.get(destinationUuid, destinationDevice.getId());\n\n    return Flux.concat(dynamoPublisher, cachePublisher)\n        .name(GET_MESSAGES_FOR_DEVICE_FLUX_NAME)\n        .tap(Micrometer.metrics(Metrics.globalRegistry));\n  }\n\n  public CompletableFuture<Void> clear(UUID destinationUuid) {\n    return messagesCache.clear(destinationUuid);\n  }\n\n  public CompletableFuture<Void> clear(UUID destinationUuid, byte deviceId) {\n    return messagesCache.clear(destinationUuid, deviceId);\n  }\n\n  public CompletableFuture<Optional<RemovedMessage>> delete(final UUID destinationUuid,\n      final Device destinationDevice,\n      final UUID guid,\n      final long serverTimestamp) {\n\n    return messagesCache.remove(destinationUuid, destinationDevice.getId(), guid)\n        .thenComposeAsync(removed -> removed\n            .map(_ -> CompletableFuture.completedFuture(removed))\n            .orElseGet(() -> messagesDynamoDb.deleteMessage(destinationUuid, destinationDevice, guid, serverTimestamp)\n                .thenApply(maybeEnvelope -> maybeEnvelope.map(RemovedMessage::fromEnvelope))\n            ), messageDeletionExecutor);\n  }\n\n  /**\n   * @return the number of messages successfully removed from the cache.\n   */\n  public int persistMessages(\n      final UUID destinationUuid,\n      final Device destinationDevice,\n      final List<Envelope> messages) {\n\n    messagesDynamoDb.store(messages, destinationUuid, destinationDevice);\n\n    final List<UUID> messageGuids = messages.stream().map(message -> UUID.fromString(message.getServerGuid()))\n        .collect(Collectors.toList());\n    int messagesRemovedFromCache = 0;\n    try {\n      messagesRemovedFromCache = messagesCache.remove(destinationUuid, destinationDevice.getId(), messageGuids)\n          .get(30, TimeUnit.SECONDS).size();\n      PERSIST_MESSAGE_COUNTER.increment(messages.size());\n      PERSIST_MESSAGE_BYTES_COUNTER.increment(messages.stream()\n          .mapToInt(Envelope::getSerializedSize)\n          .sum());\n\n    } catch (InterruptedException | ExecutionException | TimeoutException e) {\n      logger.warn(\"Failed to remove messages from cache\", e);\n    }\n    return messagesRemovedFromCache;\n  }\n\n  public CompletableFuture<Optional<Instant>> getEarliestUndeliveredTimestampForDevice(UUID destinationUuid, Device destinationDevice) {\n    // If there's any message in the persisted layer, return the oldest\n    return Mono.from(messagesDynamoDb.load(destinationUuid, destinationDevice, 1)).map(Envelope::getServerTimestamp)\n        // If not, return the oldest message in the cache\n        .switchIfEmpty(messagesCache.getEarliestUndeliveredTimestamp(destinationUuid, destinationDevice.getId()))\n        .map(epochMilli -> Optional.of(Instant.ofEpochMilli(epochMilli)))\n        .switchIfEmpty(Mono.just(Optional.empty()))\n        .toFuture();\n  }\n\n  /**\n   * Inserts the shared multi-recipient message payload to storage.\n   *\n   * @return a key where the shared data is stored\n   * @see MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript\n   */\n  private CompletableFuture<byte[]> insertSharedMultiRecipientMessagePayload(\n      final SealedSenderMultiRecipientMessage sealedSenderMultiRecipientMessage) {\n    return messagesCache.insertSharedMultiRecipientMessagePayload(sealedSenderMultiRecipientMessage);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/MrmDataMissingException.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport org.whispersystems.textsecuregcm.util.NoStackTraceRuntimeException;\n\nclass MrmDataMissingException extends NoStackTraceRuntimeException {\n\n  enum Type {\n    SHARED,\n    RECIPIENT_VIEW\n  }\n\n  private final Type type;\n\n  MrmDataMissingException(final Type type) {\n    this.type = type;\n  }\n\n  Type getType() {\n    return type;\n  }\n\n  @Override\n  public String toString() {\n    return \"MrmDataMissingException{type=%s}\".formatted(type);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/OneTimeDonationsManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nonnull;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\n\npublic class OneTimeDonationsManager {\n  public static final String KEY_PAYMENT_ID = \"P\"; // S\n  public static final String ATTR_PAID_AT = \"A\"; // N\n  public static final String ATTR_TTL = \"E\"; // N\n\n  private static final String ONETIME_DONATION_NOT_FOUND_COUNTER_NAME = name(OneTimeDonationsManager.class, \"onetimeDonationNotFound\");\n  private final String table;\n  private final Duration ttl;\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n\n  public OneTimeDonationsManager(\n      @Nonnull String table,\n      @Nonnull Duration ttl,\n      @Nonnull DynamoDbAsyncClient dynamoDbAsyncClient) {\n    this.table = Objects.requireNonNull(table);\n    this.ttl = Objects.requireNonNull(ttl);\n    this.dynamoDbAsyncClient = Objects.requireNonNull(dynamoDbAsyncClient);\n  }\n\n  public CompletableFuture<Instant> getPaidAt(final String paymentId, final Instant fallbackTimestamp) {\n    final GetItemRequest getItemRequest = GetItemRequest.builder()\n        .consistentRead(Boolean.TRUE)\n        .tableName(table)\n        .key(Map.of(KEY_PAYMENT_ID, AttributeValues.fromString(paymentId)))\n        .projectionExpression(ATTR_PAID_AT)\n        .build();\n\n    return dynamoDbAsyncClient.getItem(getItemRequest).thenApply(getItemResponse -> {\n      if (!getItemResponse.hasItem()) {\n        Metrics.counter(ONETIME_DONATION_NOT_FOUND_COUNTER_NAME).increment();\n        return fallbackTimestamp;\n      }\n\n      return Instant.ofEpochSecond(AttributeValues.getLong(getItemResponse.item(), ATTR_PAID_AT, fallbackTimestamp.getEpochSecond()));\n    });\n  }\n\n  public CompletableFuture<String> putPaidAt(final String paymentId, final Instant paidAt) {\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(table)\n            .item(Map.of(\n                KEY_PAYMENT_ID, AttributeValues.fromString(paymentId),\n                ATTR_PAID_AT, AttributeValues.fromLong(paidAt.getEpochSecond()),\n                ATTR_TTL, AttributeValues.fromLong(paidAt.plus(ttl).getEpochSecond())))\n            .build())\n        .thenApply(unused -> paymentId);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/OptimisticLockRetryLimitExceededException.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class OptimisticLockRetryLimitExceededException extends RuntimeException {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/PagedSingleUseKEMPreKeyStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.time.Instant;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport software.amazon.awssdk.core.async.AsyncRequestBody;\nimport software.amazon.awssdk.core.async.AsyncResponseTransformer;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\nimport software.amazon.awssdk.services.s3.S3AsyncClient;\nimport software.amazon.awssdk.services.s3.model.DeleteObjectRequest;\nimport software.amazon.awssdk.services.s3.model.GetObjectRequest;\nimport software.amazon.awssdk.services.s3.model.ListObjectsV2Request;\nimport software.amazon.awssdk.services.s3.model.ListObjectsV2Response;\nimport software.amazon.awssdk.services.s3.model.PutObjectRequest;\nimport software.amazon.awssdk.services.s3.model.S3Object;\n\n/**\n * @implNote This an analog of {@link SingleUseECPreKeyStore} store bundles prekeys into \"pages\", which are stored in on\n * an object store and referenced via dynamodb. Each device may only have a single active page at a time. Crashes or\n * errors may leave orphaned pages which are no longer referenced by the database. A background process must\n * periodically check for orphaned pages and remove them.\n * @see SingleUseECPreKeyStore\n */\npublic class PagedSingleUseKEMPreKeyStore {\n\n  private static final Logger log = LoggerFactory.getLogger(PagedSingleUseKEMPreKeyStore.class);\n\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final S3AsyncClient s3AsyncClient;\n  private final String tableName;\n  private final String bucketName;\n\n  private final Timer getKeyCountTimer = Metrics.timer(name(getClass(), \"getCount\"));\n  private final Timer storeKeyBatchTimer = Metrics.timer(name(getClass(), \"storeKeyBatch\"));\n  private final Timer deleteForDeviceTimer = Metrics.timer(name(getClass(), \"deleteForDevice\"));\n  private final Timer deleteForAccountTimer = Metrics.timer(name(getClass(), \"deleteForAccount\"));\n\n  private final Counter outOfRangeKeysDiscarded = Metrics.counter(name(getClass(), \"outOfRangeKeysDiscarded\"));\n  final DistributionSummary availableKeyCountDistributionSummary = DistributionSummary\n      .builder(name(getClass(), \"availableKeyCount\"))\n      .register(Metrics.globalRegistry);\n\n  private final String takeKeyTimerName = name(getClass(), \"takeKey\");\n  private static final String KEY_PRESENT_TAG_NAME = \"keyPresent\";\n\n  static final String KEY_ACCOUNT_UUID = \"U\";\n  static final String KEY_DEVICE_ID = \"D\";\n  static final String ATTR_PAGE_ID = \"ID\";\n  static final String ATTR_PAGE_IDX = \"I\";\n  static final String ATTR_PAGE_NUM_KEYS = \"N\";\n  static final String ATTR_PAGE_FORMAT_VERSION = \"F\";\n\n  public PagedSingleUseKEMPreKeyStore(\n      final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final S3AsyncClient s3AsyncClient,\n      final String tableName,\n      final String bucketName) {\n    this.s3AsyncClient = s3AsyncClient;\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n    this.bucketName = bucketName;\n  }\n\n  /**\n   * Stores a batch of single-use pre-keys for a specific device. All previously-stored keys for the device are cleared\n   * before storing new keys.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId   the identifier for the device within the given account/identity\n   * @param preKeys    a collection of single-use pre-keys to store for the target device\n   * @return a future that completes when all previously-stored keys have been removed and the given collection of\n   * pre-keys has been stored in its place\n   */\n  public CompletableFuture<Void> store(\n      final UUID identifier, final byte deviceId, final List<KEMSignedPreKey> preKeys) {\n    final Timer.Sample sample = Timer.start();\n\n    final List<KEMSignedPreKey> sorted = preKeys.stream().sorted(Comparator.comparing(KEMSignedPreKey::keyId)).toList();\n\n    final int bundleFormat = KEMPreKeyPage.FORMAT;\n    final ByteBuffer bundle = KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, sorted);\n\n    // Write the bundle to S3, then update the database. Delete the S3 object that was in the database before. This can\n    // leave orphans in S3 if we fail to update after writing to S3, or fail to delete the old page. However, it can\n    // never leave a broken pointer in the database. To keep this invariant, we must make sure to generate a new\n    // name for the page any time we were to retry this entire operation.\n    return writeBundleToS3(identifier, deviceId, bundle)\n        .thenCompose(pageId -> dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .item(Map.of(\n                KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),\n                KEY_DEVICE_ID, AttributeValues.fromInt(deviceId),\n                ATTR_PAGE_ID, AttributeValues.fromUUID(pageId),\n                ATTR_PAGE_IDX, AttributeValues.fromInt(0),\n                ATTR_PAGE_NUM_KEYS, AttributeValues.fromInt(sorted.size()),\n                ATTR_PAGE_FORMAT_VERSION, AttributeValues.fromInt(bundleFormat)\n            ))\n            .returnValues(ReturnValue.ALL_OLD)\n            .build()))\n        .thenCompose(response -> {\n          if (response.hasAttributes()) {\n            final UUID pageId = AttributeValues.getUUID(response.attributes(), ATTR_PAGE_ID, null);\n            if (pageId == null) {\n              log.error(\"Replaced record: {} with no pageId\", response.attributes());\n              return CompletableFuture.completedFuture(null);\n            }\n            return deleteBundleFromS3(identifier, deviceId, pageId);\n          } else {\n            return CompletableFuture.completedFuture(null);\n          }\n        })\n        .whenComplete((result, error) -> sample.stop(storeKeyBatchTimer));\n  }\n\n  /**\n   * Attempts to retrieve a single-use pre-key for a specific device. Keys may only be returned by this method at most\n   * once; once the key is returned, it is removed from the key store and subsequent calls to this method will never\n   * return the same key.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId   the identifier for the device within the given account/identity\n   * @return a future that yields a single-use pre-key if one is available or empty if no single-use pre-keys are\n   * available for the target device\n   */\n  public CompletableFuture<Optional<KEMSignedPreKey>> take(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n    return takeHelper(identifier, deviceId)\n        .whenComplete((maybeKey, throwable) ->\n            sample.stop(Metrics.timer(\n                takeKeyTimerName,\n                KEY_PRESENT_TAG_NAME, String.valueOf(maybeKey != null && maybeKey.isPresent()))));\n  }\n\n  private CompletableFuture<Optional<KEMSignedPreKey>> takeHelper(final UUID identifier, final byte deviceId) {\n    return dynamoDbAsyncClient.updateItem(UpdateItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),\n                KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))\n            .updateExpression(\"SET #index = #index + :one\")\n            .conditionExpression(\"#id = :id AND #index < #numkeys\")\n            .expressionAttributeNames(Map.of(\n                \"#id\", KEY_ACCOUNT_UUID,\n                \"#index\", ATTR_PAGE_IDX,\n                \"#numkeys\", ATTR_PAGE_NUM_KEYS))\n            .expressionAttributeValues(Map.of(\n                \":one\", AttributeValues.n(1),\n                \":id\", AttributeValues.fromUUID(identifier)))\n            .returnValues(ReturnValue.ALL_OLD)\n            .build())\n        .thenCompose(updateItemResponse -> {\n          if (!updateItemResponse.hasAttributes()) {\n            throw new IllegalStateException(\"update succeeded but did not return an item\");\n          }\n\n          final int index = AttributeValues.getInt(updateItemResponse.attributes(), ATTR_PAGE_IDX, -1);\n          final UUID pageId = AttributeValues.getUUID(updateItemResponse.attributes(), ATTR_PAGE_ID, null);\n          final int format = AttributeValues.getInt(updateItemResponse.attributes(), ATTR_PAGE_FORMAT_VERSION, -1);\n          if (index < 0 || format < 0 || pageId == null) {\n            throw new CompletionException(\n                new IOException(\"unexpected page descriptor \" + updateItemResponse.attributes()));\n          }\n\n          return readPreKeyAtIndexFromS3(identifier, deviceId, pageId, format, index).thenApply(Optional::of);\n        })\n        // If this check fails, it means that the item did not exist, or its index was already at the last key. Either\n        // way, there are no keys left so we return empty\n        .exceptionally(ExceptionUtils.exceptionallyHandler(\n            ConditionalCheckFailedException.class,\n            e -> Optional.empty()))\n        .thenCompose(maybeKey -> {\n          if (!maybeKey.map(KEMSignedPreKey::keyId).map(KeyIdUtil::keyIdValid).orElse(true)) {\n            // At some point we did not validate that keyIds fit in an unsigned 32-bit integer, which clients require.\n            // This keyId was invalid, so just recursively fetch the next key\n            outOfRangeKeysDiscarded.increment();\n            return takeHelper(identifier, deviceId);\n          }\n          return CompletableFuture.completedFuture(maybeKey);\n        });\n  }\n\n  /**\n   * Returns the number of single-use pre-keys available for a given device.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId   the identifier for the device within the given account/identity\n   * @return a future that yields the approximate number of single-use pre-keys currently available for the target\n   * device\n   */\n  public CompletableFuture<Integer> getCount(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n\n    return dynamoDbAsyncClient.getItem(GetItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),\n                KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))\n            .consistentRead(true)\n            .projectionExpression(\"#total, #index\")\n            .expressionAttributeNames(Map.of(\n                \"#total\", ATTR_PAGE_NUM_KEYS,\n                \"#index\", ATTR_PAGE_IDX))\n            .build())\n        .thenApply(getResponse -> {\n          if (!getResponse.hasItem()) {\n            return 0;\n          }\n          final int numKeys = AttributeValues.getInt(getResponse.item(), ATTR_PAGE_NUM_KEYS, -1);\n          final int index = AttributeValues.getInt(getResponse.item(), ATTR_PAGE_IDX, -1);\n          if (numKeys < 0 || index < 0 || index > numKeys) {\n            log.error(\"unexpected index/length in page descriptor: {}\", getResponse.item());\n            return 0;\n          }\n\n          return numKeys - index;\n        })\n        .whenComplete((keyCount, throwable) -> {\n          sample.stop(getKeyCountTimer);\n\n          if (throwable == null && keyCount != null) {\n            availableKeyCountDistributionSummary.record(keyCount);\n          }\n        });\n  }\n\n  /**\n   * Removes all single-use pre-keys for all devices associated with the given account/identity.\n   *\n   * @param identifier the identifier for the account/identity for which to remove single-use pre-keys\n   * @return a future that completes when all single-use pre-keys have been removed for all devices associated with the\n   * given account/identity\n   */\n  public CompletableFuture<Void> delete(final UUID identifier) {\n    final Timer.Sample sample = Timer.start();\n\n    return deleteItems(identifier, Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n            .tableName(tableName)\n            .keyConditionExpression(\"#uuid = :uuid\")\n            .projectionExpression(\"#uuid,#deviceid,#pageid\")\n            .expressionAttributeNames(Map.of(\n                \"#uuid\", KEY_ACCOUNT_UUID,\n                \"#deviceid\", KEY_DEVICE_ID,\n                \"#pageid\", ATTR_PAGE_ID))\n            .expressionAttributeValues(Map.of(\":uuid\", AttributeValues.fromUUID(identifier)))\n            .consistentRead(true)\n            .build())\n        .items()))\n        .thenRun(() -> sample.stop(deleteForAccountTimer));\n  }\n\n  /**\n   * Removes all single-use pre-keys for a specific device.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId   the identifier for the device within the given account/identity\n   * @return a future that completes when all single-use pre-keys have been removed for the target device\n   */\n  public CompletableFuture<Void> delete(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n\n    return dynamoDbAsyncClient.getItem(GetItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),\n                KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))\n            .consistentRead(true)\n            .projectionExpression(\"#uuid,#deviceid,#pageid\")\n            .expressionAttributeNames(Map.of(\n                \"#uuid\", KEY_ACCOUNT_UUID,\n                \"#deviceid\", KEY_DEVICE_ID,\n                \"#pageid\", ATTR_PAGE_ID))\n            .build())\n        .thenCompose(getItemResponse -> deleteItems(identifier, getItemResponse.hasItem()\n            ? Flux.just(getItemResponse.item())\n            : Flux.empty()))\n        .thenRun(() -> sample.stop(deleteForDeviceTimer));\n  }\n\n\n  public Flux<DeviceKEMPreKeyPages> listStoredPages(int lookupConcurrency) {\n    return Flux\n        .from(s3AsyncClient.listObjectsV2Paginator(ListObjectsV2Request.builder()\n            .bucket(bucketName)\n            .build()))\n        .flatMapIterable(ListObjectsV2Response::contents)\n        .map(PagedSingleUseKEMPreKeyStore::parseS3Key)\n        .bufferUntilChanged(Function.identity(), S3PageKey::fromSameDevice)\n        .flatMapSequential(pages -> {\n          final UUID identifier = pages.getFirst().identifier();\n          final byte deviceId = pages.getFirst().deviceId();\n          return Mono.fromCompletionStage(() -> dynamoDbAsyncClient.getItem(GetItemRequest.builder()\n                  .tableName(tableName)\n                  .key(Map.of(\n                      KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),\n                      KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)))\n                  // Make sure we get the most up to date pageId to minimize cases where we see a new page in S3 but\n                  // view a stale dynamodb record\n                  .consistentRead(true)\n                  .projectionExpression(\"#uuid,#deviceid,#pageid\")\n                  .expressionAttributeNames(Map.of(\n                      \"#uuid\", KEY_ACCOUNT_UUID,\n                      \"#deviceid\", KEY_DEVICE_ID,\n                      \"#pageid\", ATTR_PAGE_ID))\n                  .build())\n              .thenApply(getItemResponse -> new DeviceKEMPreKeyPages(\n                  identifier,\n                  deviceId,\n                  Optional.ofNullable(AttributeValues.getUUID(getItemResponse.item(), ATTR_PAGE_ID, null)),\n                  pages.stream().collect(Collectors.toMap(S3PageKey::pageId, S3PageKey::lastModified)))));\n        }, lookupConcurrency);\n  }\n\n  private CompletableFuture<Void> deleteItems(final UUID identifier,\n      final Flux<Map<String, AttributeValue>> items) {\n    return items\n        .flatMap(item -> {\n          final UUID aci = AttributeValues.getUUID(item, KEY_ACCOUNT_UUID, null);\n          final byte deviceId = (byte) AttributeValues.getInt(item, KEY_DEVICE_ID, -1);\n          final UUID pageId = AttributeValues.getUUID(item, ATTR_PAGE_ID, null);\n          if (aci == null || deviceId < 0 || pageId == null) {\n            log.error(\"can't delete page from unexpected page descriptor {}\", item);\n          }\n          return Mono.fromFuture(deleteBundleFromS3(aci, deviceId, pageId))\n              .thenReturn(Map.of(\n                  KEY_ACCOUNT_UUID, AttributeValues.fromUUID(identifier),\n                  KEY_DEVICE_ID, AttributeValues.fromInt(deviceId)));\n        })\n        .flatMap(itemToDelete -> Mono.fromFuture(() -> dynamoDbAsyncClient.deleteItem(DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(itemToDelete)\n            .build())))\n        .then()\n        .toFuture()\n        .thenRun(Util.NOOP);\n  }\n\n  private static String s3Key(final UUID identifier, final byte deviceId, final UUID pageId) {\n    return String.format(\"%s/%s/%s\", identifier, deviceId, pageId);\n  }\n\n  private record S3PageKey(UUID identifier, byte deviceId, UUID pageId, Instant lastModified) {\n\n    boolean fromSameDevice(final S3PageKey other) {\n      return deviceId == other.deviceId && identifier.equals(other.identifier);\n    }\n  }\n\n  private static S3PageKey parseS3Key(final S3Object page) {\n    try {\n      final String[] parts = page.key().split(\"/\", 3);\n      if (parts.length != 3 || parts[2].contains(\"/\")) {\n        throw new IllegalArgumentException(\"wrong number of path components\");\n      }\n      return new S3PageKey(\n          UUID.fromString(parts[0]),\n          Byte.parseByte(parts[1]),\n          UUID.fromString(parts[2]), page.lastModified());\n    } catch (IllegalArgumentException e) {\n      throw new IllegalArgumentException(\"invalid s3 page key: \" + page.key(), e);\n    }\n  }\n\n\n  private CompletableFuture<UUID> writeBundleToS3(final UUID identifier, final byte deviceId,\n      final ByteBuffer bundle) {\n    final UUID pageId = UUID.randomUUID();\n    return s3AsyncClient.putObject(PutObjectRequest.builder()\n                .bucket(bucketName)\n                .key(s3Key(identifier, deviceId, pageId)).build(),\n            AsyncRequestBody.fromByteBuffer(bundle))\n        .thenApply(ignoredResponse -> pageId);\n  }\n\n  CompletableFuture<Void> deleteBundleFromS3(final UUID identifier, final byte deviceId, final UUID pageId) {\n    return s3AsyncClient.deleteObject(DeleteObjectRequest.builder()\n            .bucket(bucketName)\n            .key(s3Key(identifier, deviceId, pageId))\n            .build())\n        .thenRun(Util.NOOP);\n  }\n\n  private CompletableFuture<KEMSignedPreKey> readPreKeyAtIndexFromS3(\n      final UUID identifier, final byte deviceId, final UUID pageId, final int format, final int index) {\n    final KEMPreKeyPage.KeyLocation keyLocation = KEMPreKeyPage.keyLocation(format, index);\n    return s3AsyncClient.getObject(GetObjectRequest.builder()\n            .bucket(bucketName)\n            .key(s3Key(identifier, deviceId, pageId))\n            // An RFC9110 range header, inclusive on both ends\n            // https://www.rfc-editor.org/rfc/rfc9110.html#section-14.1.2\n            .range(\"bytes=%s-%s\".formatted(keyLocation.getStartInclusive(), keyLocation.getEndInclusive()))\n            .build(), AsyncResponseTransformer.toBytes())\n        .thenApply(bytes -> {\n          final ByteBuffer serialized = bytes.asByteBuffer();\n          if (serialized.remaining() != keyLocation.length()) {\n            log.error(\"Unexpected ranged read response, requested {} got {} for offset {} in page {}\",\n                keyLocation.length(), serialized.remaining(), keyLocation, s3Key(identifier, deviceId, pageId));\n            throw new CompletionException(new IOException(\"Invalid response to ranged read\"));\n          }\n          try {\n            return KEMPreKeyPage.deserializeKey(format, bytes.asByteBuffer());\n          } catch (InvalidKeyException e) {\n            throw new CompletionException(new IOException(e));\n          }\n        });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/PaymentTime.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Objects;\nimport javax.annotation.Nullable;\n\n/**\n * The time at which a receipt was purchased. Some providers provide the end of the period, others the beginning. Either\n * way, lets you calculate the expiration time for a product associated with the payment.\n * <p>\n * A subscription is typically for a fixed pay period. For example, a subscription may require renewal every 30 days.\n * Until the end of a period, a subscriber may create a receipt credential that can be cashed in for access to the\n * purchase. This receipt credential has an expiration that at least includes the end of the payment period but may\n * additionally include allowance (gracePeriod) for missed payments. The product obtained with the receipt will be\n * usable until this expiration time.\n */\npublic class PaymentTime {\n\n  @Nullable\n  Instant periodStart;\n  @Nullable\n  Instant periodEnd;\n\n  private PaymentTime(@Nullable Instant periodStart, @Nullable Instant periodEnd) {\n    if ((periodStart == null && periodEnd == null) || (periodStart != null && periodEnd != null)) {\n      throw new IllegalArgumentException(\"Only one of periodStart and periodEnd should be provided\");\n    }\n    this.periodStart = periodStart;\n    this.periodEnd = periodEnd;\n  }\n\n  public static PaymentTime periodEnds(Instant periodEnd) {\n    return new PaymentTime(null, Objects.requireNonNull(periodEnd));\n  }\n\n  public static PaymentTime periodStart(Instant periodStart) {\n    return new PaymentTime(Objects.requireNonNull(periodStart), null);\n  }\n\n  /**\n   * Calculate the expiration time for this period\n   *\n   * @param periodLength How long after the time of payment should the receipt be valid\n   * @param gracePeriod  An additional grace period after the end of the period to add to the expiration\n   * @return Instant when the receipt should expire\n   */\n  public Instant receiptExpiration(final Duration periodLength, final Duration gracePeriod) {\n    final Instant expiration = periodStart != null\n        ? periodStart.plus(periodLength).plus(gracePeriod)\n        : periodEnd.plus(gracePeriod);\n\n    return expiration.truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/PersistentTimer.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.lettuce.core.SetArgs;\nimport io.micrometer.core.instrument.Timer;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n/**\n * Timers for operations that may span machines or requests and require a persistently stored timer start itme\n */\npublic class PersistentTimer {\n\n  private static final Logger logger = LoggerFactory.getLogger(PersistentTimer.class);\n\n  private static String TIMER_NAMESPACE = \"persistent_timer\";\n  @VisibleForTesting\n  static final Duration TIMER_TTL = Duration.ofHours(1);\n\n  private final FaultTolerantRedisClusterClient redisClient;\n  private final Clock clock;\n\n\n  public PersistentTimer(final FaultTolerantRedisClusterClient redisClient, final Clock clock) {\n    this.redisClient = redisClient;\n    this.clock = clock;\n  }\n\n  public class Sample {\n\n    private final Instant start;\n    private final String redisKey;\n\n    public Sample(final Instant start, final String redisKey) {\n      this.start = start;\n      this.redisKey = redisKey;\n    }\n\n    /**\n     * Stop the timer, recording the duration between now and the first call to start. This deletes the persistent timer.\n     *\n     * @param timer The micrometer timer to record the duration to\n     * @return A future that completes when the resources associated with the persistent timer have been destroyed\n     */\n    public CompletableFuture<Void> stop(Timer timer) {\n      Duration duration = Duration.between(start, clock.instant());\n      timer.record(duration);\n      return redisClient.withCluster(connection -> connection.async().del(redisKey))\n          .toCompletableFuture()\n          .thenRun(Util.NOOP);\n    }\n  }\n\n  /**\n   * Start the timer if a timer with the provided namespaced key has not already been started, otherwise return the\n   * existing sample.\n   *\n   * @param namespace A namespace prefix to use for the timer\n   * @param key The unique key within the namespace that identifies the timer\n   * @return A future that completes with a {@link Sample} that can later be used to record the final duration.\n   */\n  public CompletableFuture<Sample> start(final String namespace, final String key) {\n    final Instant now = clock.instant();\n    final String redisKey = redisKey(namespace, key);\n\n    return redisClient.withCluster(connection ->\n            connection.async().setGet(redisKey, String.valueOf(now.getEpochSecond()), SetArgs.Builder.nx().ex(TIMER_TTL)))\n        .toCompletableFuture()\n        .thenApply(serialized -> new Sample(parseStoredTimestamp(serialized).orElse(now), redisKey));\n  }\n\n  @VisibleForTesting\n  String redisKey(final String namespace, final String key) {\n    return String.format(\"%s::%s::%s\", TIMER_NAMESPACE, namespace, key);\n  }\n\n  private static Optional<Instant> parseStoredTimestamp(final @Nullable String serialized) {\n    return Optional\n        .ofNullable(serialized)\n        .flatMap(s -> {\n          try {\n            return Optional.of(Long.parseLong(s));\n          } catch (NumberFormatException e) {\n            logger.warn(\"Failed to parse stored timestamp {}\", s, e);\n            return Optional.empty();\n          }\n        })\n        .map(Instant::ofEpochSecond);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiers.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Supplier;\nimport java.util.stream.Collectors;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.BatchGetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.CancellationReason;\nimport software.amazon.awssdk.services.dynamodb.model.KeysAndAttributes;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionConflictException;\nimport software.amazon.awssdk.services.dynamodb.model.Update;\n\n/**\n * Manages a global, persistent mapping of phone numbers to phone number identifiers regardless of whether those\n * numbers/identifiers are actually associated with an account.\n */\npublic class PhoneNumberIdentifiers {\n\n  private final DynamoDbAsyncClient dynamoDbClient;\n  private final String tableName;\n\n  @VisibleForTesting\n  static final String KEY_E164 = \"P\";\n  @VisibleForTesting\n  static final String INDEX_NAME = \"pni_to_p\";\n  @VisibleForTesting\n  static final String ATTR_PHONE_NUMBER_IDENTIFIER = \"PNI\";\n\n  private static final String CONDITIONAL_CHECK_FAILED = \"ConditionalCheckFailed\";\n\n  private static final Timer GET_PNI_TIMER = Metrics.timer(name(PhoneNumberIdentifiers.class, \"get\"));\n  private static final Timer SET_PNI_TIMER = Metrics.timer(name(PhoneNumberIdentifiers.class, \"set\"));\n  private static final int MAX_RETRIES = 10;\n\n  private static final Logger logger = LoggerFactory.getLogger(PhoneNumberIdentifiers.class);\n\n  public PhoneNumberIdentifiers(final DynamoDbAsyncClient dynamoDbClient, final String tableName) {\n    this.dynamoDbClient = dynamoDbClient;\n    this.tableName = tableName;\n  }\n\n  /**\n   * Returns the phone number identifier (PNI) associated with the given phone number. If one doesn't exist, it is\n   * created.\n   *\n   * @param phoneNumber the phone number for which to retrieve a phone number identifier\n   * @return the phone number identifier associated with the given phone number\n   */\n  public CompletableFuture<UUID> getPhoneNumberIdentifier(final String phoneNumber) {\n    // Each e164 phone number string represents a potential equivalence class e164s that represent the same number. If\n    // this is a new phone number, we'll want to set all the numbers in the equivalence class to the same PNI\n    final List<String> allPhoneNumberForms = Util.getAlternateForms(phoneNumber);\n\n    return retry(MAX_RETRIES, TransactionConflictException.class, () -> fetchPhoneNumbers(allPhoneNumberForms)\n        .thenCompose(mappings -> setPniIfRequired(phoneNumber, allPhoneNumberForms, mappings)));\n  }\n\n  /**\n   * Returns the list of phone numbers associated with a given phone number identifier. If this\n   * UUID was not previously assigned as a PNI by {@link #getPhoneNumberIdentifier(String)}, the\n   * returned list will be empty.\n   *\n   * @param phoneNumberIdentifier a phone number identifier\n   * @return the list of all e164s associated with the given phone number identifier\n   */\n  public CompletableFuture<List<String>> getPhoneNumber(final UUID phoneNumberIdentifier) {\n    return dynamoDbClient.query(QueryRequest.builder()\n            .tableName(tableName)\n            .indexName(INDEX_NAME)\n            .keyConditionExpression(\"#pni = :pni\")\n            .projectionExpression(\"#phone_number\")\n            .expressionAttributeNames(Map.of(\n                \"#phone_number\", KEY_E164,\n                \"#pni\", ATTR_PHONE_NUMBER_IDENTIFIER\n            ))\n            .expressionAttributeValues(Map.of(\n                \":pni\", AttributeValues.fromUUID(phoneNumberIdentifier)\n            ))\n            .build())\n        .thenApply(response -> response.items().stream().map(item -> item.get(KEY_E164).s()).toList());\n  }\n\n  @VisibleForTesting\n  static <T, E extends Exception> CompletableFuture<T> retry(\n      final int numRetries, final Class<E> exceptionToRetry, final Supplier<CompletableFuture<T>> supplier) {\n    return supplier.get().exceptionallyCompose(ExceptionUtils.exceptionallyHandler(exceptionToRetry, e -> {\n      if (numRetries - 1 <= 0) {\n        throw ExceptionUtils.wrap(e);\n      }\n      return retry(numRetries - 1, exceptionToRetry, supplier);\n    }));\n  }\n\n  /**\n   * Determine what PNI to set for the provided numbers, and set them if required\n   *\n   * @param phoneNumber          The original e164 the operation is for\n   * @param allPhoneNumberForms  The e164s to set. The first e164 in this list should be phoneNumber\n   * @param existingAssociations The current associations of allPhoneNumberForms in the table\n   * @return The PNI now associated with phoneNumber\n   */\n  @VisibleForTesting\n  CompletableFuture<UUID> setPniIfRequired(\n      final String phoneNumber,\n      final List<String> allPhoneNumberForms,\n      Map<String, UUID> existingAssociations) {\n    if (!phoneNumber.equals(allPhoneNumberForms.getFirst())) {\n      throw new IllegalArgumentException(\"allPhoneNumberForms must start with the target phoneNumber\");\n    }\n\n    if (existingAssociations.containsKey(phoneNumber)) {\n      // If the provided phone number already has an association, just return that\n      return CompletableFuture.completedFuture(existingAssociations.get(phoneNumber));\n    }\n\n    if (allPhoneNumberForms.size() == 1 || existingAssociations.isEmpty()) {\n      // Easy case, if we're the only phone number in our equivalence class or there are no existing associations,\n      // we can just make an association for a new PNI\n      return setPni(phoneNumber, allPhoneNumberForms, UUID.randomUUID());\n    }\n\n    // Otherwise, what members of the equivalence class have a PNI association?\n    final Map<UUID, List<String>> byPni = existingAssociations.entrySet().stream().collect(Collectors.groupingBy(\n        Map.Entry::getValue,\n        Collectors.mapping(Map.Entry::getKey, Collectors.toList())));\n\n    // Usually there should be only a single PNI associated with the equivalence class, but it's possible there's\n    // more. This could only happen if an equivalence class had more than two numbers, and had accumulated 2 unique\n    // PNI associations before they were merged into a single class. In this case we've picked one of those pnis\n    // arbitrarily (according to their ordering as returned by getAlternateForms)\n    final UUID existingPni = allPhoneNumberForms.stream()\n        .filter(existingAssociations::containsKey)\n        .findFirst()\n        .map(existingAssociations::get)\n        .orElseThrow(() -> new IllegalStateException(\"Previously checked that a mapping existed\"));\n\n    if (byPni.size() > 1) {\n      logger.warn(\"More than one PNI existed in the PNI table for the numbers that map to {}. \" +\n              \"Arbitrarily picking {} to be the representative PNI for the numbers without PNI associations\",\n          phoneNumber, existingPni);\n    }\n\n    // Find all the unmapped phoneNumbers and set them to the PNI we chose from another member of the equivalence class\n    final List<String> unmappedNumbers = allPhoneNumberForms.stream()\n        .filter(number -> !existingAssociations.containsKey(number))\n        .toList();\n\n    return setPni(phoneNumber, unmappedNumbers, existingPni);\n  }\n\n\n  /**\n   * Attempt to associate phoneNumbers with the provided pni. If any of the phoneNumbers have an existing association\n   * that is not the target pni, no update will occur. If the first phoneNumber in phoneNumbers has an existing\n   * association, it will be returned, otherwise an exception will be thrown.\n   *\n   * @param originalPhoneNumber The original e164 the operation is for\n   * @param allPhoneNumberForms The e164s to set. The first e164 in this list should be originalPhoneNumber\n   * @param pni                 The PNI to set\n   * @return The provided PNI if the update occurred, or the existing PNI associated with originalPhoneNumber\n   */\n  @VisibleForTesting\n  CompletableFuture<UUID> setPni(final String originalPhoneNumber, final List<String> allPhoneNumberForms,\n      final UUID pni) {\n    if (!originalPhoneNumber.equals(allPhoneNumberForms.getFirst())) {\n      throw new IllegalArgumentException(\"allPhoneNumberForms must start with the target phoneNumber\");\n    }\n\n    final Timer.Sample sample = Timer.start();\n    final List<TransactWriteItem> transactWriteItems = allPhoneNumberForms\n        .stream()\n        .map(phoneNumber -> TransactWriteItem.builder()\n            .update(Update.builder()\n                .tableName(tableName)\n                .key(Map.of(KEY_E164, AttributeValues.fromString(phoneNumber)))\n                .updateExpression(\"SET #pni = :pni\")\n                // It's possible we're racing with someone else to update, but both of us selected the same PNI because\n                // an equivalent number already had it. That's fine, as long as the association happens.\n                .conditionExpression(\"attribute_not_exists(#pni) OR #pni = :pni\")\n                .expressionAttributeNames(Map.of(\"#pni\", ATTR_PHONE_NUMBER_IDENTIFIER))\n                .expressionAttributeValues(Map.of(\":pni\", AttributeValues.fromUUID(pni)))\n                .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n                .build()).build())\n        .toList();\n\n    return dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder()\n            .transactItems(transactWriteItems)\n            .build())\n        .thenApply(ignored -> pni)\n        .exceptionally(ExceptionUtils.exceptionallyHandler(TransactionCanceledException.class, e -> {\n          if (e.hasCancellationReasons()) {\n            // Get the cancellation reason for the number that we were primarily trying to associate with a PNI\n            final CancellationReason cancelReason = e.cancellationReasons().getFirst();\n            if (CONDITIONAL_CHECK_FAILED.equals(cancelReason.code())) {\n              // Someone else beat us to the update, use the PNI they set.\n              return AttributeValues.getUUID(cancelReason.item(), ATTR_PHONE_NUMBER_IDENTIFIER, null);\n            }\n          }\n          throw e;\n        }))\n        .whenComplete((ignored, throwable) -> sample.stop(SET_PNI_TIMER));\n  }\n\n  @VisibleForTesting\n  CompletableFuture<Map<String, UUID>> fetchPhoneNumbers(List<String> phoneNumbers) {\n    final Timer.Sample sample = Timer.start();\n    return dynamoDbClient.batchGetItem(\n            BatchGetItemRequest.builder().requestItems(Map.of(tableName, KeysAndAttributes.builder()\n                    // If we have a stale value, the subsequent conditional update will fail\n                    .consistentRead(false)\n                    .projectionExpression(\"#number,#pni\")\n                    .expressionAttributeNames(Map.of(\"#number\", KEY_E164, \"#pni\", ATTR_PHONE_NUMBER_IDENTIFIER))\n                    .keys(phoneNumbers.stream()\n                        .map(number -> Map.of(KEY_E164, AttributeValues.fromString(number)))\n                        .toArray(Map[]::new))\n                    .build()))\n                .build())\n        .thenApply(batchResponse -> batchResponse.responses().get(tableName).stream().collect(Collectors.toMap(\n            item -> AttributeValues.getString(item, KEY_E164, null),\n            item -> AttributeValues.getUUID(item, ATTR_PHONE_NUMBER_IDENTIFIER, null))))\n        .whenComplete((ignored, throwable) -> sample.stop(GET_PNI_TIMER));\n  }\n\n  CompletableFuture<Void> regeneratePhoneNumberIdentifierMappings(final Account account) {\n    return setPni(account.getNumber(), Util.getAlternateForms(account.getNumber()), account.getIdentifier(IdentityType.PNI))\n        .thenRun(Util.NOOP);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/Profiles.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.util.AsyncTimerUtil;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\npublic class Profiles {\n\n  private final DynamoDbClient dynamoDbClient;\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n\n  // UUID of the account that owns this profile; byte array\n  @VisibleForTesting\n  static final String KEY_ACCOUNT_UUID = \"U\";\n\n  // Version of this profile; string\n  @VisibleForTesting\n  static final String ATTR_VERSION = \"V\";\n\n  // User's name; byte array\n  private static final String ATTR_NAME = \"N\";\n\n  // Avatar path/filename; string\n  private static final String ATTR_AVATAR = \"A\";\n\n  // Bio/about text; byte array\n  private static final String ATTR_ABOUT = \"B\";\n\n  // Bio/about emoji; byte array\n  private static final String ATTR_EMOJI = \"E\";\n\n  // Payment address; byte array\n  private static final String ATTR_PAYMENT_ADDRESS = \"P\";\n\n  // Phone number sharing setting; byte array\n  private static final String ATTR_PHONE_NUMBER_SHARING = \"S\";\n\n  // Commitment; byte array\n  private static final String ATTR_COMMITMENT = \"C\";\n\n  private static final Map<String, String> UPDATE_EXPRESSION_ATTRIBUTE_NAMES = Map.of(\n      \"#commitment\", ATTR_COMMITMENT,\n      \"#name\", ATTR_NAME,\n      \"#avatar\", ATTR_AVATAR,\n      \"#about\", ATTR_ABOUT,\n      \"#aboutEmoji\", ATTR_EMOJI,\n      \"#paymentAddress\", ATTR_PAYMENT_ADDRESS,\n      \"#phoneNumberSharing\", ATTR_PHONE_NUMBER_SHARING);\n\n  private static final Timer SET_PROFILES_TIMER = Metrics.timer(name(Profiles.class, \"set\"));\n  private static final Timer GET_PROFILE_TIMER = Metrics.timer(name(Profiles.class, \"get\"));\n  private static final String DELETE_PROFILES_TIMER_NAME = name(Profiles.class, \"delete\");\n  private static final String PARSE_BYTE_ARRAY_COUNTER_NAME = name(Profiles.class, \"parseByteArray\");\n\n  private static final int MAX_CONCURRENCY = 32;\n\n  public Profiles(final DynamoDbClient dynamoDbClient,\n      final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final String tableName) {\n\n    this.dynamoDbClient = dynamoDbClient;\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n  }\n\n  public void set(final UUID uuid, final VersionedProfile profile) {\n    SET_PROFILES_TIMER.record(() -> {\n      dynamoDbClient.updateItem(UpdateItemRequest.builder()\n          .tableName(tableName)\n          .key(buildPrimaryKey(uuid, profile.version()))\n          .updateExpression(buildUpdateExpression(profile))\n          .expressionAttributeNames(UPDATE_EXPRESSION_ATTRIBUTE_NAMES)\n          .expressionAttributeValues(buildUpdateExpressionAttributeValues(profile))\n          .build());\n    });\n  }\n\n  public CompletableFuture<Void> setAsync(final UUID uuid, final VersionedProfile profile) {\n    return AsyncTimerUtil.record(SET_PROFILES_TIMER, () -> dynamoDbAsyncClient.updateItem(UpdateItemRequest.builder()\n            .tableName(tableName)\n            .key(buildPrimaryKey(uuid, profile.version()))\n            .updateExpression(buildUpdateExpression(profile))\n            .expressionAttributeNames(UPDATE_EXPRESSION_ATTRIBUTE_NAMES)\n            .expressionAttributeValues(buildUpdateExpressionAttributeValues(profile))\n            .build()\n        ).thenRun(Util.NOOP)\n    ).toCompletableFuture();\n  }\n\n  private static Map<String, AttributeValue> buildPrimaryKey(final UUID uuid, final String version) {\n    return Map.of(\n        KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),\n        ATTR_VERSION, AttributeValues.fromString(version));\n  }\n\n  @VisibleForTesting\n  static String buildUpdateExpression(final VersionedProfile profile) {\n    final List<String> updatedAttributes = new ArrayList<>(5);\n    final List<String> deletedAttributes = new ArrayList<>(5);\n\n    if (profile.name() != null) {\n      updatedAttributes.add(\"name\");\n    } else {\n      deletedAttributes.add(\"name\");\n    }\n\n    if (StringUtils.isNotBlank(profile.avatar())) {\n      updatedAttributes.add(\"avatar\");\n    } else {\n      deletedAttributes.add(\"avatar\");\n    }\n\n    if (profile.about() != null) {\n      updatedAttributes.add(\"about\");\n    } else {\n      deletedAttributes.add(\"about\");\n    }\n\n    if (profile.aboutEmoji() != null) {\n      updatedAttributes.add(\"aboutEmoji\");\n    } else {\n      deletedAttributes.add(\"aboutEmoji\");\n    }\n\n    if (profile.paymentAddress() != null) {\n      updatedAttributes.add(\"paymentAddress\");\n    } else {\n      deletedAttributes.add(\"paymentAddress\");\n    }\n\n    if (profile.phoneNumberSharing() != null) {\n      updatedAttributes.add(\"phoneNumberSharing\");\n    } else {\n      deletedAttributes.add(\"phoneNumberSharing\");\n    }\n\n    final StringBuilder updateExpressionBuilder = new StringBuilder(\n        \"SET #commitment = if_not_exists(#commitment, :commitment)\");\n\n    if (!updatedAttributes.isEmpty()) {\n      updatedAttributes.forEach(token -> updateExpressionBuilder\n          .append(\", #\")\n          .append(token)\n          .append(\" = :\")\n          .append(token));\n    }\n\n    if (!deletedAttributes.isEmpty()) {\n      updateExpressionBuilder.append(\" REMOVE \");\n      updateExpressionBuilder.append(deletedAttributes.stream()\n          .map(token -> \"#\" + token)\n          .collect(Collectors.joining(\", \")));\n    }\n\n    return updateExpressionBuilder.toString();\n  }\n\n  @VisibleForTesting\n  static Map<String, AttributeValue> buildUpdateExpressionAttributeValues(final VersionedProfile profile) {\n    final Map<String, AttributeValue> expressionValues = new HashMap<>();\n\n    expressionValues.put(\":commitment\", AttributeValues.fromByteArray(profile.commitment()));\n\n    if (profile.name() != null) {\n      expressionValues.put(\":name\", AttributeValues.fromByteArray(profile.name()));\n    }\n\n    if (StringUtils.isNotBlank(profile.avatar())) {\n      expressionValues.put(\":avatar\", AttributeValues.fromString(profile.avatar()));\n    }\n\n    if (profile.about() != null) {\n      expressionValues.put(\":about\", AttributeValues.fromByteArray(profile.about()));\n    }\n\n    if (profile.aboutEmoji() != null) {\n      expressionValues.put(\":aboutEmoji\", AttributeValues.fromByteArray(profile.aboutEmoji()));\n    }\n\n    if (profile.paymentAddress() != null) {\n      expressionValues.put(\":paymentAddress\", AttributeValues.fromByteArray(profile.paymentAddress()));\n    }\n\n    if (profile.phoneNumberSharing() != null) {\n      expressionValues.put(\":phoneNumberSharing\", AttributeValues.fromByteArray(profile.phoneNumberSharing()));\n    }\n    return expressionValues;\n  }\n\n  public Optional<VersionedProfile> get(final UUID uuid, final String version) {\n    return GET_PROFILE_TIMER.record(() -> {\n      final GetItemResponse response = dynamoDbClient.getItem(GetItemRequest.builder()\n          .tableName(tableName)\n          .key(buildPrimaryKey(uuid, version))\n          .consistentRead(true)\n          .build());\n\n      return response.hasItem() ? Optional.of(fromItem(response.item())) : Optional.empty();\n    });\n  }\n\n  public CompletableFuture<Optional<VersionedProfile>> getAsync(final UUID uuid, final String version) {\n    return AsyncTimerUtil.record(GET_PROFILE_TIMER, () -> dynamoDbAsyncClient.getItem(GetItemRequest.builder()\n        .tableName(tableName)\n        .key(buildPrimaryKey(uuid, version))\n        .consistentRead(true)\n        .build())\n        .thenApply(response ->\n            response.hasItem() ? Optional.of(fromItem(response.item())) : Optional.<VersionedProfile>empty())\n    ).toCompletableFuture();\n  }\n\n  private static VersionedProfile fromItem(final Map<String, AttributeValue> item) {\n    return new VersionedProfile(\n        AttributeValues.getString(item, ATTR_VERSION, null),\n        getBytes(item, ATTR_NAME),\n        AttributeValues.getString(item, ATTR_AVATAR, null),\n        getBytes(item, ATTR_EMOJI),\n        getBytes(item, ATTR_ABOUT),\n        getBytes(item, ATTR_PAYMENT_ADDRESS),\n        getBytes(item, ATTR_PHONE_NUMBER_SHARING),\n        AttributeValues.getByteArray(item, ATTR_COMMITMENT, null));\n  }\n\n  private static byte[] getBytes(final Map<String, AttributeValue> item, final String attributeName) {\n    final AttributeValue attributeValue = item.get(attributeName);\n\n    if (attributeValue == null) {\n      return null;\n    }\n    return AttributeValues.extractByteArray(attributeValue, PARSE_BYTE_ARRAY_COUNTER_NAME);\n  }\n\n  /**\n   * Deletes all profile versions for the given UUID\n   *\n   * @return a list of avatar URLs that may be deleted\n   */\n  public CompletableFuture<List<String>> deleteAll(final UUID uuid) {\n    final Timer.Sample sample = Timer.start();\n\n    final AttributeValue uuidAttributeValue = AttributeValues.fromUUID(uuid);\n\n    return Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n                .tableName(tableName)\n                .keyConditionExpression(\"#uuid = :uuid\")\n                .expressionAttributeNames(Map.of(\"#uuid\", KEY_ACCOUNT_UUID))\n                .expressionAttributeValues(Map.of(\":uuid\", uuidAttributeValue))\n                .projectionExpression(String.join(\", \", ATTR_VERSION, ATTR_AVATAR))\n                .consistentRead(true)\n                .build())\n            .items())\n        .flatMap(item -> Mono.fromFuture(() -> dynamoDbAsyncClient.deleteItem(DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_ACCOUNT_UUID, uuidAttributeValue,\n                ATTR_VERSION, item.get(ATTR_VERSION)))\n            .build()))\n            .flatMap(ignored -> Mono.justOrEmpty(item.get(ATTR_AVATAR)).map(AttributeValue::s)), MAX_CONCURRENCY)\n        .collectList()\n        .doOnSuccess(ignored -> sample.stop(Metrics.timer(DELETE_PROFILES_TIMER_NAME, \"outcome\", \"success\")))\n        .doOnError(ignored -> sample.stop(Metrics.timer(DELETE_PROFILES_TIMER_NAME, \"outcome\", \"error\")))\n        .toFuture();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ProfilesManager.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.lettuce.core.RedisException;\nimport java.io.IOException;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Function;\nimport javax.annotation.Nullable;\nimport io.micrometer.core.instrument.Metrics;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Mono;\nimport software.amazon.awssdk.services.s3.S3AsyncClient;\nimport software.amazon.awssdk.services.s3.model.DeleteObjectRequest;\n\npublic class ProfilesManager {\n\n  private final Logger logger = LoggerFactory.getLogger(ProfilesManager.class);\n\n  private static final String CACHE_PREFIX = \"profiles::\";\n\n  private final Profiles profiles;\n  private final FaultTolerantRedisClusterClient cacheCluster;\n  private final ScheduledExecutorService retryExecutor;\n  private final S3AsyncClient s3Client;\n  private final String bucket;\n  private final ObjectMapper mapper;\n\n  private static final String RETRY_NAME = ResilienceUtil.name(ProfilesManager.class);\n\n  private static final String DELETE_AVATAR_COUNTER_NAME = name(ProfilesManager.class, \"deleteAvatar\");\n\n  public ProfilesManager(final Profiles profiles,\n      final FaultTolerantRedisClusterClient cacheCluster,\n      final ScheduledExecutorService retryExecutor,\n      final S3AsyncClient s3Client,\n      final String bucket) {\n    this.profiles = profiles;\n    this.cacheCluster = cacheCluster;\n    this.retryExecutor = retryExecutor;\n    this.s3Client = s3Client;\n    this.bucket = bucket;\n    this.mapper = SystemMapper.jsonMapper();\n  }\n\n  public void set(UUID uuid, VersionedProfile versionedProfile) {\n    redisSet(uuid, versionedProfile);\n    profiles.set(uuid, versionedProfile);\n  }\n\n  public CompletableFuture<Void> setAsync(UUID uuid, VersionedProfile versionedProfile) {\n    return profiles.setAsync(uuid, versionedProfile)\n        .thenCompose(ignored -> redisSetAsync(uuid, versionedProfile));\n  }\n\n  /**\n   * Delete all profiles for the given uuid.\n   * <p>\n   * Avatars should be included for explicit delete actions, such as API calls and expired accounts. Implicit\n   * deletions, such as registration, should preserve them, so that PIN recovery includes the avatar.\n   */\n  public CompletableFuture<Void> deleteAll(UUID uuid, final boolean includeAvatar) {\n\n    final CompletableFuture<Void> profilesAndAvatars = Mono.fromFuture(profiles.deleteAll(uuid))\n        .flatMapIterable(Function.identity())\n        .flatMap(avatar ->\n          Mono.fromFuture(includeAvatar ? deleteAvatar(avatar) : CompletableFuture.completedFuture(null))\n              // this is best-effort\n              .retry(3)\n              .onErrorComplete())\n        .then().toFuture();\n\n    return CompletableFuture.allOf(redisDelete(uuid), profilesAndAvatars);\n  }\n\n  public CompletableFuture<Void> deleteAvatar(String avatar) {\n    return s3Client.deleteObject(DeleteObjectRequest.builder()\n        .bucket(bucket)\n        .key(avatar)\n        .build())\n        .handle((ignored, throwable) -> {\n          final String outcome;\n          if (throwable != null) {\n            logger.warn(\"Error deleting avatar\", throwable);\n            outcome = \"error\";\n          } else {\n            outcome = \"success\";\n          }\n\n          Metrics.counter(DELETE_AVATAR_COUNTER_NAME, \"outcome\", outcome).increment();\n          return null;\n        })\n        .thenRun(Util.NOOP);\n  }\n\n  public Optional<VersionedProfile> get(UUID uuid, String version) {\n    Optional<VersionedProfile> profile = redisGet(uuid, version);\n\n    if (profile.isEmpty()) {\n      profile = profiles.get(uuid, version);\n      try {\n        profile.ifPresent(versionedProfile -> redisSet(uuid, versionedProfile));\n      } catch (RedisException e) {\n        logger.warn(\"Failed to cache retrieved profile\", e);\n      }\n    }\n\n    return profile;\n  }\n\n  public CompletableFuture<Optional<VersionedProfile>> getAsync(UUID uuid, String version) {\n    return redisGetAsync(uuid, version)\n        .thenCompose(maybeVersionedProfile -> maybeVersionedProfile\n            .map(versionedProfile -> CompletableFuture.completedFuture(maybeVersionedProfile))\n            .orElseGet(() -> profiles.getAsync(uuid, version)\n                .thenCompose(maybeVersionedProfileFromDynamo -> maybeVersionedProfileFromDynamo\n                    .map(profile -> redisSetAsync(uuid, profile)\n                        .exceptionally(ExceptionUtils.exceptionallyHandler(RedisException.class, e -> {\n                          logger.warn(\"Failed to cache retrieved profile\", e);\n                          return null;\n                        }))\n                        .thenApply(ignored -> maybeVersionedProfileFromDynamo))\n                    .orElseGet(() -> CompletableFuture.completedFuture(maybeVersionedProfileFromDynamo)))));\n  }\n\n  private void redisSet(UUID uuid, VersionedProfile profile) {\n    try {\n      final String profileJson = mapper.writeValueAsString(profile);\n\n      cacheCluster.useCluster(connection -> connection.sync().hset(getCacheKey(uuid), profile.version(), profileJson));\n    } catch (JsonProcessingException e) {\n      throw new IllegalArgumentException(e);\n    }\n  }\n\n  private CompletableFuture<Void> redisSetAsync(UUID uuid, VersionedProfile profile) {\n    final String profileJson;\n\n    try {\n      profileJson = mapper.writeValueAsString(profile);\n    } catch (JsonProcessingException e) {\n      throw new IllegalArgumentException(e);\n    }\n\n    return cacheCluster.withCluster(connection ->\n        connection.async().hset(getCacheKey(uuid), profile.version(), profileJson))\n            .thenRun(Util.NOOP)\n            .toCompletableFuture();\n  }\n\n  private Optional<VersionedProfile> redisGet(UUID uuid, String version) {\n    try {\n      @Nullable final String json = cacheCluster.withCluster(connection -> connection.sync().hget(getCacheKey(uuid), version));\n\n      return parseProfileJson(json);\n    } catch (RedisException e) {\n      logger.warn(\"Failed to retrieve profile from cache\", e);\n      return Optional.empty();\n    }\n  }\n\n  private CompletableFuture<Optional<VersionedProfile>> redisGetAsync(UUID uuid, String version) {\n    return cacheCluster.withCluster(connection ->\n        connection.async().hget(getCacheKey(uuid), version))\n        .thenApply(this::parseProfileJson)\n        .exceptionally(throwable -> {\n          logger.warn(\"Failed to read versioned profile from Redis\", throwable);\n          return Optional.empty();\n        })\n        .toCompletableFuture();\n  }\n\n  private Optional<VersionedProfile> parseProfileJson(@Nullable final String maybeJson) {\n    try {\n      if (maybeJson != null) {\n        return Optional.of(mapper.readValue(maybeJson, VersionedProfile.class));\n      }\n      return Optional.empty();\n    } catch (final IOException e) {\n      logger.warn(\"Error deserializing value...\", e);\n      return Optional.empty();\n    }\n  }\n\n  private CompletableFuture<Void> redisDelete(UUID uuid) {\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeCompletionStage(retryExecutor,\n            () -> cacheCluster.withCluster(connection -> connection.async().del(getCacheKey(uuid))))\n        .toCompletableFuture()\n        .thenRun(Util.NOOP);\n  }\n\n  @VisibleForTesting\n  static String getCacheKey(UUID uuid) {\n    return CACHE_PREFIX + uuid.toString();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDb.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\n\n/**\n * Stores push challenge tokens. Users may have at most one outstanding push challenge token at a time.\n */\npublic class PushChallengeDynamoDb extends AbstractDynamoDbStore {\n\n  private final String tableName;\n  private final Clock clock;\n\n  static final String KEY_ACCOUNT_UUID = \"U\";\n  static final String ATTR_CHALLENGE_TOKEN = \"C\";\n  static final String ATTR_TTL = \"T\";\n\n  private static final Map<String, String> UUID_NAME_MAP = Map.of(\"#uuid\", KEY_ACCOUNT_UUID);\n  private static final Map<String, String> CHALLENGE_TOKEN_NAME_MAP = Map.of(\"#challenge\", ATTR_CHALLENGE_TOKEN, \"#ttl\",\n      ATTR_TTL);\n\n  public PushChallengeDynamoDb(final DynamoDbClient dynamoDB, final String tableName) {\n    this(dynamoDB, tableName, Clock.systemUTC());\n  }\n\n  @VisibleForTesting\n  PushChallengeDynamoDb(final DynamoDbClient dynamoDB, final String tableName, final Clock clock) {\n    super(dynamoDB);\n\n    this.tableName = tableName;\n    this.clock = clock;\n  }\n\n  /**\n   * Stores a push challenge token for the given user if and only if the user doesn't already have a token stored. The\n   * existence check is strongly-consistent.\n   *\n   * @param accountUuid the UUID of the account for which to store a push challenge token\n   * @param challengeToken the challenge token itself\n   * @param ttl the time after which the token is no longer valid\n   * @return {@code true} if a new token was stored of {@code false} if another token already exists for the given\n   * account\n   */\n  public boolean add(final UUID accountUuid, final byte[] challengeToken, final Duration ttl) {\n    try {\n      db().putItem(PutItemRequest.builder()\n          .tableName(tableName)\n          .item(Map.of(\n              KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountUuid),\n              ATTR_CHALLENGE_TOKEN, AttributeValues.fromByteArray(challengeToken),\n              ATTR_TTL, AttributeValues.fromLong(getExpirationTimestamp(ttl))))\n          .conditionExpression(\"attribute_not_exists(#uuid)\")\n          .expressionAttributeNames(UUID_NAME_MAP)\n          .build());\n      return true;\n    } catch (final ConditionalCheckFailedException e) {\n      return false;\n    }\n  }\n\n  long getExpirationTimestamp(final Duration ttl) {\n    return clock.instant().plus(ttl).getEpochSecond();\n  }\n\n  /**\n   * Clears a push challenge token for the given user if and only if the given challenge token matches the stored token.\n   * The token comparison is a strongly-consistent operation.\n   *\n   * @param accountUuid the account for which to remove a stored token\n   * @param challengeToken the token to remove\n   * @return {@code true} if the given token matched the stored token for the given user or {@code false} otherwise\n   */\n  public boolean remove(final UUID accountUuid, final byte[] challengeToken) {\n    try {\n      db().deleteItem(DeleteItemRequest.builder()\n          .tableName(tableName)\n          .key(Map.of(KEY_ACCOUNT_UUID, AttributeValues.fromUUID(accountUuid)))\n          .conditionExpression(\"#challenge = :challenge AND #ttl >= :currentTime\")\n          .expressionAttributeNames(CHALLENGE_TOKEN_NAME_MAP)\n          .expressionAttributeValues(Map.of(\":challenge\", AttributeValues.fromByteArray(challengeToken),\n              \":currentTime\", AttributeValues.fromLong(clock.instant().getEpochSecond())))\n          .build());\n      return true;\n    } catch (final ConditionalCheckFailedException e) {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManager.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nonnull;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\npublic class RedeemedReceiptsManager {\n\n  public static final String KEY_SERIAL = \"S\";\n  public static final String KEY_TTL = \"E\";\n  public static final String KEY_RECEIPT_EXPIRATION = \"G\";\n  public static final String KEY_RECEIPT_LEVEL = \"L\";\n  public static final String KEY_ACCOUNT_UUID = \"U\";\n  public static final String KEY_REDEMPTION_TIME = \"R\";\n\n  private final Clock clock;\n  private final String table;\n  private final DynamoDbAsyncClient client;\n  private final Duration expirationTime;\n\n  public RedeemedReceiptsManager(\n      @Nonnull final Clock clock,\n      @Nonnull final String table,\n      @Nonnull final DynamoDbAsyncClient client,\n      @Nonnull final Duration expirationTime) {\n    this.clock = Objects.requireNonNull(clock);\n    this.table = Objects.requireNonNull(table);\n    this.client = Objects.requireNonNull(client);\n    this.expirationTime = Objects.requireNonNull(expirationTime);\n  }\n\n  /**\n   * Returns true either if it's able to insert a new redeemed receipt entry with the {@code receiptExpiration}, {@code\n   * receiptLevel}, and {@code accountUuid} provided or if an existing entry already exists with the same values thereby\n   * allowing idempotent request processing.\n   */\n  public CompletableFuture<Boolean> put(\n      @Nonnull final ReceiptSerial receiptSerial,\n      final long receiptExpiration,\n      final long receiptLevel,\n      @Nonnull final UUID accountUuid) {\n\n    // fail early if given bad inputs\n    Objects.requireNonNull(receiptSerial);\n    Objects.requireNonNull(accountUuid);\n\n    final Instant now = clock.instant();\n    final Instant rowExpiration = now.plus(expirationTime);\n    final AttributeValue serialAttributeValue = AttributeValues.b(receiptSerial.serialize());\n\n    final UpdateItemRequest updateItemRequest = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_SERIAL, serialAttributeValue))\n        .returnValues(ReturnValue.ALL_NEW)\n        .updateExpression(\"SET #ttl = if_not_exists(#ttl, :ttl), \"\n            + \"#receipt_expiration = if_not_exists(#receipt_expiration, :receipt_expiration), \"\n            + \"#receipt_level = if_not_exists(#receipt_level, :receipt_level), \"\n            + \"#account_uuid = if_not_exists(#account_uuid, :account_uuid), \"\n            + \"#redemption_time = if_not_exists(#redemption_time, :redemption_time)\")\n        .expressionAttributeNames(Map.of(\n            \"#ttl\", KEY_TTL,\n            \"#receipt_expiration\", KEY_RECEIPT_EXPIRATION,\n            \"#receipt_level\", KEY_RECEIPT_LEVEL,\n            \"#account_uuid\", KEY_ACCOUNT_UUID,\n            \"#redemption_time\", KEY_REDEMPTION_TIME))\n        .expressionAttributeValues(Map.of(\n            \":ttl\", AttributeValues.n(rowExpiration.getEpochSecond()),\n            \":receipt_expiration\", AttributeValues.n(receiptExpiration),\n            \":receipt_level\", AttributeValues.n(receiptLevel),\n            \":account_uuid\", AttributeValues.b(accountUuid),\n            \":redemption_time\", AttributeValues.n(now.getEpochSecond())))\n        .build();\n    return client.updateItem(updateItemRequest).thenApply(updateItemResponse -> {\n      final Map<String, AttributeValue> attributes = updateItemResponse.attributes();\n      final long ddbReceiptExpiration = Long.parseLong(attributes.get(KEY_RECEIPT_EXPIRATION).n());\n      final long ddbReceiptLevel = Long.parseLong(attributes.get(KEY_RECEIPT_LEVEL).n());\n      final UUID ddbAccountUuid = UUIDUtil.fromByteBuffer(attributes.get(KEY_ACCOUNT_UUID).b().asByteBuffer());\n      return ddbReceiptExpiration == receiptExpiration && ddbReceiptLevel == receiptLevel &&\n          Objects.equals(ddbAccountUuid, accountUuid);\n    });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RedisDynamoDbMessagePublisher.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.micrometer.core.instrument.Metrics;\nimport java.util.UUID;\nimport java.util.concurrent.Flow;\nimport javax.annotation.Nullable;\nimport org.reactivestreams.Publisher;\nimport org.reactivestreams.Subscription;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.push.MessageAvailabilityListener;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport reactor.core.observability.micrometer.Micrometer;\nimport reactor.core.publisher.BaseSubscriber;\nimport reactor.core.publisher.Flux;\n\n/// A Redis/DynamoDB message publisher produces a non-terminating stream of messages for a specific device. It listens\n/// for message availability signals from [RedisMessageAvailabilityManager] and emits new messages to its subscriber\n/// when available.\n///\n/// This publisher supports only a single subscriber. It assumes that subscribers acknowledge (delete) messages as they\n/// read the messages, and may emit duplicate messages if subscribers do not acknowledge messages before requesting more\n/// messages.\nclass RedisDynamoDbMessagePublisher implements MessageAvailabilityListener, Flow.Publisher<MessageStreamEntry> {\n\n  private final MessagesDynamoDb messagesDynamoDb;\n  private final MessagesCache messagesCache;\n  private final RedisMessageAvailabilityManager redisMessageAvailabilityManager;\n\n  private final UUID accountIdentifier;\n  private final Device device;\n\n  // Indicates which data source(s) we think might contain messages for the destination device. Messages initially land\n  // in Redis, but are eventually \"persisted\" to DynamoDB. This state changes in response to signals this publisher\n  // receives as a MessageAvailabilityListener. As an initial state, we assume that we have messages in both DynamoDB\n  // and Redis.\n  private StoredMessageState storedMessageState = StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE;\n\n  // Indicates whether we've sent an \"initial queue drain complete\" signal to subscribers. This state will transition\n  // from \"not ready\" to \"pending\" as soon as the initial message source subscription completes, and then from \"pending\"\n  // to \"send\" when the signal has been sent. This state will never roll backwards.\n  private QueueEmptySignalState queueEmptySignalState = QueueEmptySignalState.NOT_READY;\n\n  // The total requested demand from the subscriber across the whole lifetime of this publisher\n  private long requestedDemand = 0;\n\n  // The total number of signals (new messages or \"queue empty\" signals) published during the lifetime of this\n  // publisher. Must not exceed `requestedDemand`.\n  private long publishedEntries = 0;\n\n  // The total number of published signals that have been acknowledged by the subscriber; to avoid re-reading messages,\n  // we should never start a new message source subscriber until `acknowledgedEntries` is equal to `publishedEntries`\n  private long acknowledgedEntries = 0;\n\n  // Although technically nullable, operation of this publisher really begins once we get a subscriber. This publisher\n  // supports only a single subscriber.\n  @Nullable private Flow.Subscriber<? super MessageStreamEntry> subscriber;\n\n  // If terminated (i.e. by an error or by downstream cancellation), this publisher will stop emitting signals. Once\n  // terminated, a publisher cannot be un-terminated.\n  private boolean terminated = false;\n\n  // A message source subscriber subscribes to messages from upstream data sources (i.e. DynamoDB and Redis), and this\n  // publisher relays signals the message source subscriber to the downstream subscriber. The message source subscriber\n  // may be null if we're not actively fetching messages from an upstream source and changes every time an upstream\n  // publisher completes.\n  @Nullable private MessageSourceSubscriber messageSourceSubscriber;\n\n  private static final String GET_MESSAGES_FOR_DEVICE_FLUX_NAME =\n      name(RedisDynamoDbMessagePublisher.class, \"getMessagesForDevice\");\n\n  private enum StoredMessageState {\n    // Indicates that stored messages are available in at least DynamoDB and possibly also Redis\n    PERSISTED_NEW_MESSAGES_AVAILABLE,\n\n    // Indicates that messages are available in Redis, but have not yet been persisted to DynamoDB\n    CACHED_NEW_MESSAGES_AVAILABLE,\n\n    // Indicates that no new messages are available in either Redis or DynamoDB\n    EMPTY\n  }\n\n  private enum QueueEmptySignalState {\n    // Indicates that we are not yet ready to send the \"initial queue drain complete\" signal regardless of outstanding\n    // demand\n    NOT_READY,\n\n    // Indicates that we are ready to send the \"queue empty\" signal as soon as demand is available\n    PENDING,\n\n    // Indicates that we have sent the \"queue empty\" signal and must never send it again\n    SENT\n  }\n\n  /// A message source subscriber subscribes to upstream message source publishers and relays signals to the downstream\n  /// subscriber via the parent `RedisDynamoDbMessagePublisher`.\n  private static class MessageSourceSubscriber extends BaseSubscriber<MessageProtos.Envelope> {\n\n    private final RedisDynamoDbMessagePublisher redisDynamoDbMessagePublisher;\n\n    private MessageSourceSubscriber(RedisDynamoDbMessagePublisher redisDynamoDbMessagePublisher) {\n        this.redisDynamoDbMessagePublisher = redisDynamoDbMessagePublisher;\n    }\n\n    @Override\n    protected void hookOnSubscribe(final Subscription subscription) {\n      redisDynamoDbMessagePublisher.handleMessageSourceSubscribed(subscription);\n    }\n\n    @Override\n    protected void hookOnNext(final MessageProtos.Envelope message) {\n      redisDynamoDbMessagePublisher.handleNextMessage(message);\n    }\n\n    @Override\n    protected void hookOnComplete() {\n      redisDynamoDbMessagePublisher.handleMessageSourceComplete();\n    }\n\n    @Override\n    protected void hookOnError(final Throwable throwable) {\n      redisDynamoDbMessagePublisher.handleMessageSourceError(throwable);\n    }\n  }\n\n  RedisDynamoDbMessagePublisher(final MessagesDynamoDb messagesDynamoDb,\n      final MessagesCache messagesCache,\n      final RedisMessageAvailabilityManager redisMessageAvailabilityManager,\n      final UUID accountIdentifier,\n      final Device device) {\n\n    this.messagesDynamoDb = messagesDynamoDb;\n    this.messagesCache = messagesCache;\n    this.redisMessageAvailabilityManager = redisMessageAvailabilityManager;\n    this.accountIdentifier = accountIdentifier;\n    this.device = device;\n  }\n\n  @Override\n  public synchronized void subscribe(final Flow.Subscriber<? super MessageStreamEntry> subscriber) {\n    if (this.subscriber != null) {\n      subscriber.onError(new IllegalStateException(\"Redis/DynamoDB message publisher only allows one subscriber\"));\n      return;\n    }\n\n    this.subscriber = subscriber;\n\n    // Listen for signals indicating that new messages are available in Redis, that messages have been persisted from\n    // Redis to DynamoDB, or that there's a conflicting message reader connected somewhere else\n    redisMessageAvailabilityManager.handleClientConnected(accountIdentifier, device.getId(), this);\n\n    subscriber.onSubscribe(new Flow.Subscription() {\n      @Override\n      public void request(final long n) {\n        addDemand(n);\n      }\n\n      @Override\n      public void cancel() {\n        terminate();\n      }\n    });\n  }\n\n  @Override\n  public synchronized void handleNewMessageAvailable() {\n    // We only need to take action if we think there aren't already messages to pass downstream. Any other stored\n    // message state implies that we're either actively sending messages downstream or we're waiting for demand from the\n    // downstream subscriber and don't need to take any action now. We'll call `maybeGenerateMessageSource` either when\n    // we receive a request for more messages or when the current upstream publisher completes.\n    if (storedMessageState == StoredMessageState.EMPTY) {\n      storedMessageState = StoredMessageState.CACHED_NEW_MESSAGES_AVAILABLE;\n      maybeGenerateMessageSource();\n    }\n  }\n\n  @Override\n  public synchronized void handleMessagesPersisted() {\n    // We only need to take action if we think there aren't already messages in DynamoDB. If we're already aware of\n    // messages in DynamoDB, then we're either actively sending messages downstream or we're waiting for demand from the\n    // downstream subscriber and don't need to take any action now. We'll call `maybeGenerateMessageSource` either when\n    // we receive a request for more messages or when the current upstream publisher completes.\n    if (storedMessageState != StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE) {\n      storedMessageState = StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE;\n      maybeGenerateMessageSource();\n    }\n  }\n\n  @Override\n  public synchronized void handleConflictingMessageConsumer() {\n    // We don't register as a listener for conflicting consumer signals until we have a subscriber\n    assert subscriber != null;\n\n    if (!terminated) {\n      terminate();\n      subscriber.onError(new ConflictingMessageConsumerException());\n    }\n  }\n\n  synchronized void handleMessageAcknowledged() {\n    acknowledgedEntries += 1;\n    assert acknowledgedEntries <= publishedEntries;\n\n    maybeGenerateMessageSource();\n  }\n\n  private synchronized boolean maybeSendQueueEmptySignal() {\n    // Regardless of any other state, don't do anything if terminated\n    if (terminated) {\n      return false;\n    }\n\n    // The machinery that produces messages won't activate until we have a subscriber\n    assert subscriber != null;\n\n    if (queueEmptySignalState == QueueEmptySignalState.PENDING && publishedEntries < requestedDemand) {\n      queueEmptySignalState = QueueEmptySignalState.SENT;\n      publishedEntries += 1;\n\n      // Subscribers don't explicitly acknowledge \"queue empty\" signals, and we can consider them automatically\n      // acknowledged\n      acknowledgedEntries += 1;\n\n      subscriber.onNext(new MessageStreamEntry.QueueEmpty());\n\n      assert publishedEntries <= requestedDemand;\n      assert acknowledgedEntries <= publishedEntries;\n\n      return true;\n    }\n\n    return false;\n  }\n\n  private synchronized void maybeGenerateMessageSource() {\n    if (terminated) {\n      // Regardless of any other state, don't do anything if terminated\n      return;\n    }\n\n    if (messageSourceSubscriber != null) {\n      // We're still working through a previous message source; wait until it completes before starting a new one.\n      return;\n    }\n\n    if (storedMessageState == StoredMessageState.EMPTY) {\n      // We don't think there are any messages in either message source; don't do anything until the situation changes\n      // (when new messages arrive, we'll come back to this point with a non-empty stored message state)\n      return;\n    }\n\n    if (publishedEntries == requestedDemand) {\n      // Even if there are messages available, there's no demand for them yet (when there's new demand, we'll come back\n      // to this point with a higher value for `requestedDemand` via `addDemand`)\n      return;\n    }\n\n    if (acknowledgedEntries < publishedEntries) {\n      // To avoid double-reading messages from data stores that don't support cursors, don't get a new message source\n      // unless all previously-published signals have been acknowledged (when messages are acknowledged, we'll come back\n      // to this point with a higher value for `acknowledgedEntries` via `handleMessageAcknowledged`)\n      return;\n    }\n\n    // We maybe be able to skip reading from DynamoDB entirely if we think messages are only stored in Redis\n    final Publisher<MessageProtos.Envelope> dynamoPublisher =\n        storedMessageState == StoredMessageState.PERSISTED_NEW_MESSAGES_AVAILABLE\n            ? messagesDynamoDb.load(accountIdentifier, device, null)\n            : Flux.empty();\n\n    final Publisher<MessageProtos.Envelope> redisPublisher = messagesCache.get(accountIdentifier, device.getId());\n\n    final Flux<MessageProtos.Envelope> messageSource = Flux.concat(dynamoPublisher, redisPublisher)\n        .name(GET_MESSAGES_FOR_DEVICE_FLUX_NAME)\n        .tap(Micrometer.metrics(Metrics.globalRegistry));\n\n    messageSourceSubscriber = new MessageSourceSubscriber(this);\n    messageSource.subscribe(messageSourceSubscriber);\n\n    // If nothing else happens before the DynamoDB/Redis publisher completes, then we'll have emptied all stored\n    // messages; new signals about persisted messages or newly-arrived messages will change this state\n    storedMessageState = StoredMessageState.EMPTY;\n  }\n\n  private synchronized void handleMessageSourceSubscribed(final Subscription subscription) {\n    if (!terminated) {\n      // If we already have some unmet demand, pass that on to the upstream publisher immediately on subscribing\n      if (requestedDemand > publishedEntries) {\n        subscription.request(requestedDemand - publishedEntries);\n      }\n    }\n  }\n\n  private synchronized void handleNextMessage(final MessageProtos.Envelope message) {\n    // The machinery that produces messages won't activate until we have a subscriber\n    assert subscriber != null;\n\n    if (!terminated) {\n      // We only pass along unfulfilled demand to the message source subscriber, so if the message source subscriber\n      // emits a new signal, it should fit within the unfulfilled demand from the downstream subscriber\n      assert publishedEntries < requestedDemand;\n\n      publishedEntries += 1;\n      subscriber.onNext(new MessageStreamEntry.Envelope(message));\n    }\n  }\n\n  private synchronized void handleMessageSourceComplete() {\n    // The machinery that produces messages won't activate until we have a subscriber\n    assert subscriber != null;\n\n    messageSourceSubscriber = null;\n\n    if (queueEmptySignalState == QueueEmptySignalState.NOT_READY) {\n      queueEmptySignalState = QueueEmptySignalState.PENDING;\n      maybeSendQueueEmptySignal();\n    }\n\n    // New messages may have arrived already; fetch them if possible\n    maybeGenerateMessageSource();\n  }\n\n  private synchronized void handleMessageSourceError(final Throwable throwable) {\n    // The machinery that produces messages won't activate until we have a subscriber\n    assert subscriber != null;\n\n    if (!terminated) {\n      terminate();\n      subscriber.onError(throwable);\n    }\n  }\n\n  private synchronized void addDemand(final long demand) {\n    if (demand <= 0) {\n      throw new IllegalArgumentException(\"Demand must be positive\");\n    }\n\n    requestedDemand += demand;\n\n    final boolean sentQueueEmptySignal = maybeSendQueueEmptySignal();\n\n    // This is a little tricky; if we already have a subscriber, we only want to request NEW demand, not the total\n    // outstanding demand. On top of that, we may have consumed some demand by sending a \"queue empty\" message.\n    final long newDemand = demand - (sentQueueEmptySignal ? 1 : 0);\n\n    if (newDemand > 0) {\n      if (messageSourceSubscriber != null) {\n        messageSourceSubscriber.request(newDemand);\n      } else {\n        maybeGenerateMessageSource();\n      }\n    }\n  }\n\n  private synchronized void terminate() {\n    if (!terminated) {\n      terminated = true;\n\n      if (messageSourceSubscriber != null) {\n        messageSourceSubscriber.dispose();\n        messageSourceSubscriber = null;\n      }\n\n      // Stop receiving signals about new messages/conflicting consumers\n      redisMessageAvailabilityManager.handleClientDisconnected(accountIdentifier, device.getId());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RedisDynamoDbMessageStream.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Flow;\nimport com.google.common.annotations.VisibleForTesting;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n/// A [MessageStream] implementation that produces message from a joint DynamoDB/Redis message store.\npublic class RedisDynamoDbMessageStream implements MessageStream {\n\n  private final MessagesDynamoDb messagesDynamoDb;\n  private final MessagesCache messagesCache;\n\n  private final UUID accountIdentifier;\n  private final Device device;\n\n  private final RedisDynamoDbMessagePublisher messagePublisher;\n\n  public RedisDynamoDbMessageStream(final MessagesDynamoDb messagesDynamoDb,\n      final MessagesCache messagesCache,\n      final RedisMessageAvailabilityManager redisMessageAvailabilityManager,\n      final UUID accountIdentifier,\n      final Device device) {\n\n    this(messagesDynamoDb, messagesCache, accountIdentifier, device, new RedisDynamoDbMessagePublisher(messagesDynamoDb,\n        messagesCache,\n        redisMessageAvailabilityManager,\n        accountIdentifier,\n        device));\n  }\n\n  @VisibleForTesting\n  RedisDynamoDbMessageStream(final MessagesDynamoDb messagesDynamoDb,\n      final MessagesCache messagesCache,\n      final UUID accountIdentifier,\n      final Device device,\n      final RedisDynamoDbMessagePublisher messagePublisher) {\n\n    this.messagesDynamoDb = messagesDynamoDb;\n    this.messagesCache = messagesCache;\n    this.accountIdentifier = accountIdentifier;\n    this.device = device;\n    this.messagePublisher = messagePublisher;\n  }\n\n  @Override\n  public Flow.Publisher<MessageStreamEntry> getMessages() {\n    return messagePublisher;\n  }\n\n  @Override\n  public CompletableFuture<Void> acknowledgeMessage(final MessageProtos.Envelope message) {\n    final UUID guid = UUID.fromString(message.getServerGuid());\n\n    return messagesCache.remove(accountIdentifier, device.getId(), guid)\n        .thenCompose(removed -> removed.map(_ -> CompletableFuture.<Void>completedFuture(null))\n            .orElseGet(() ->\n                messagesDynamoDb.deleteMessage(accountIdentifier, device, guid, message.getServerTimestamp())\n                    .thenRun(Util.NOOP)))\n        .whenComplete((_, _) -> messagePublisher.handleMessageAcknowledged());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RefreshingAccountNotFoundException.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class RefreshingAccountNotFoundException extends RuntimeException {\n\n  public RefreshingAccountNotFoundException(final String message) {\n    super(message);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswords.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\n\npublic class RegistrationRecoveryPasswords {\n\n  // For historical reasons, we record the PNI as a UUID string rather than a compact byte array\n  static final String KEY_PNI = \"P\";\n  static final String ATTR_EXP = \"E\";\n  static final String ATTR_SALT = \"S\";\n  static final String ATTR_HASH = \"H\";\n\n  private final String tableName;\n\n  private final Duration expiration;\n\n  private final DynamoDbAsyncClient asyncClient;\n\n  private final Clock clock;\n\n  public RegistrationRecoveryPasswords(\n      final String tableName,\n      final Duration expiration,\n      final DynamoDbAsyncClient asyncClient,\n      final Clock clock) {\n    this.tableName = requireNonNull(tableName);\n    this.expiration = requireNonNull(expiration);\n    this.asyncClient = requireNonNull(asyncClient);\n    this.clock = requireNonNull(clock);\n  }\n\n  public CompletableFuture<Optional<SaltedTokenHash>> lookup(final UUID phoneNumberIdentifier) {\n    return asyncClient.getItem(GetItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(KEY_PNI, AttributeValues.fromString(phoneNumberIdentifier.toString())))\n            .consistentRead(true)\n            .build())\n        .thenApply(getItemResponse -> Optional.ofNullable(getItemResponse.item())\n            .filter(item -> item.containsKey(ATTR_SALT))\n            .filter(item -> item.containsKey(ATTR_HASH))\n            .map(RegistrationRecoveryPasswords::saltedTokenHashFromItem));\n  }\n\n  ///  Add a PNI -> RRP mapping, or replace the current one if it already exists\n  ///\n  /// @param phoneNumberIdentifier The PNI to associate the salted RRP with\n  /// @param data The salted registration recovery password\n  /// @return true if a new mapping was added, false if an existing mapping was updated\n  public CompletableFuture<Boolean> addOrReplace(final UUID phoneNumberIdentifier, final SaltedTokenHash data) {\n    final long expirationSeconds = expirationSeconds();\n\n    return asyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .returnValues(ReturnValue.ALL_OLD)\n            .item(Map.of(\n                KEY_PNI, AttributeValues.fromString(phoneNumberIdentifier.toString()),\n                ATTR_EXP, AttributeValues.fromLong(expirationSeconds),\n                ATTR_SALT, AttributeValues.fromString(data.salt()),\n                ATTR_HASH, AttributeValues.fromString(data.hash())))\n            .build())\n        .thenApply(response -> response.attributes() == null || response.attributes().isEmpty());\n  }\n\n  ///  Remove the entry associated with the provided PNI\n  ///\n  /// @return true if an entry was removed, false if no entry existed\n  public CompletableFuture<Boolean> removeEntry(final UUID phoneNumberIdentifier) {\n    return asyncClient.deleteItem(DeleteItemRequest.builder()\n            .tableName(tableName)\n            .returnValues(ReturnValue.ALL_OLD)\n            .key(Map.of(KEY_PNI, AttributeValues.fromString(phoneNumberIdentifier.toString())))\n            .build())\n        .thenApply(response -> response.attributes() != null && !response.attributes().isEmpty());\n  }\n\n  @VisibleForTesting\n  long expirationSeconds() {\n    return clock.instant().plus(expiration).getEpochSecond();\n  }\n\n  private static SaltedTokenHash saltedTokenHashFromItem(final Map<String, AttributeValue> item) {\n    return new SaltedTokenHash(item.get(ATTR_HASH).s(), item.get(ATTR_SALT).s());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryPasswordsManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static java.util.Objects.requireNonNull;\n\nimport java.lang.invoke.MethodHandles;\nimport java.util.HexFormat;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException;\n\npublic class RegistrationRecoveryPasswordsManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());\n\n  private final RegistrationRecoveryPasswords registrationRecoveryPasswords;\n\n  public RegistrationRecoveryPasswordsManager(final RegistrationRecoveryPasswords registrationRecoveryPasswords) {\n    this.registrationRecoveryPasswords = requireNonNull(registrationRecoveryPasswords);\n  }\n\n  public CompletableFuture<Boolean> verify(final UUID phoneNumberIdentifier, final byte[] password) {\n    return registrationRecoveryPasswords.lookup(phoneNumberIdentifier)\n        .thenApply(maybeHash -> maybeHash.filter(hash -> hash.verify(bytesToString(password))))\n        .whenComplete((_, error) -> {\n          if (error != null) {\n            logger.warn(\"Failed to lookup Registration Recovery Password\", error);\n          }\n        })\n        .thenApply(Optional::isPresent);\n  }\n\n  public CompletableFuture<Boolean> store(final UUID phoneNumberIdentifier, final byte[] password) {\n    final String token = bytesToString(password);\n    final SaltedTokenHash tokenHash = SaltedTokenHash.generateFor(token);\n\n    return registrationRecoveryPasswords.addOrReplace(phoneNumberIdentifier, tokenHash)\n        .whenComplete((_, error) -> {\n          if (error != null) {\n            logger.warn(\"Failed to store Registration Recovery Password\", error);\n          }\n        });\n  }\n\n  public CompletableFuture<Boolean> remove(final UUID phoneNumberIdentifier) {\n    return registrationRecoveryPasswords.removeEntry(phoneNumberIdentifier)\n        .whenComplete((_, error) -> {\n          if (error != null) {\n            logger.warn(\"Failed to remove Registration Recovery Password\", error);\n          }\n        });\n  }\n\n  private static String bytesToString(final byte[] bytes) {\n    return HexFormat.of().formatHex(bytes);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfig.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport jakarta.validation.constraints.Pattern;\nimport java.util.HashSet;\nimport java.util.Set;\nimport java.util.UUID;\n\npublic class RemoteConfig {\n\n  @JsonProperty\n  @Pattern(regexp = \"[A-Za-z0-9\\\\.]+\")\n  private String name;\n\n  @JsonProperty\n  @NotNull\n  @Min(0)\n  @Max(100)\n  private int percentage;\n\n  @JsonProperty\n  @NotNull\n  private Set<UUID> uuids = new HashSet<>();\n\n  @JsonProperty\n  private String defaultValue;\n\n  @JsonProperty\n  private String value;\n\n  @JsonProperty\n  private String hashKey;\n\n  public RemoteConfig() {}\n\n  public RemoteConfig(String name, int percentage, Set<UUID> uuids, String defaultValue, String value, String hashKey) {\n    this.name         = name;\n    this.percentage   = percentage;\n    this.uuids        = uuids;\n    this.defaultValue = defaultValue;\n    this.value        = value;\n    this.hashKey      = hashKey;\n  }\n\n  public int getPercentage() {\n    return percentage;\n  }\n\n  public String getName() {\n    return name;\n  }\n\n  public Set<UUID> getUuids() {\n    return uuids;\n  }\n\n  public String getDefaultValue() {\n    return defaultValue;\n  }\n\n  public String getValue() {\n    return value;\n  }\n\n  public String getHashKey() {\n    return hashKey;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigs.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.stream.Collectors;\n\npublic class RemoteConfigs {\n\n  private final DynamoDbClient dynamoDbClient;\n  private final String tableName;\n\n  // Config name; string\n  static final String KEY_NAME = \"N\";\n  // Rollout percentage; integer\n  private static final String ATTR_PERCENTAGE = \"P\";\n  // Enrolled UUIDs (ACIs); list of byte arrays\n  private static final String ATTR_UUIDS = \"U\";\n  // Default value; string\n  private static final String ATTR_DEFAULT_VALUE = \"D\";\n  // Value when enrolled; string\n  private static final String ATTR_VALUE = \"V\";\n  // Hash key; string\n  private static final String ATTR_HASH_KEY = \"H\";\n\n  public RemoteConfigs(final DynamoDbClient dynamoDbClient, final String tableName) {\n    this.dynamoDbClient = dynamoDbClient;\n    this.tableName = tableName;\n  }\n\n  public void set(final RemoteConfig remoteConfig) {\n    final Map<String, AttributeValue> item = new HashMap<>(Map.of(\n        KEY_NAME, AttributeValues.fromString(remoteConfig.getName()),\n        ATTR_PERCENTAGE, AttributeValues.fromInt(remoteConfig.getPercentage())));\n\n    if (remoteConfig.getUuids() != null && !remoteConfig.getUuids().isEmpty()) {\n      final List<SdkBytes> uuidByteSets = remoteConfig.getUuids().stream()\n          .map(UUIDUtil::toByteBuffer)\n          .map(SdkBytes::fromByteBuffer)\n          .collect(Collectors.toList());\n\n      item.put(ATTR_UUIDS, AttributeValue.builder().bs(uuidByteSets).build());\n    }\n\n    if (remoteConfig.getDefaultValue() != null) {\n      item.put(ATTR_DEFAULT_VALUE, AttributeValues.fromString(remoteConfig.getDefaultValue()));\n    }\n\n    if (remoteConfig.getValue() != null) {\n      item.put(ATTR_VALUE, AttributeValues.fromString(remoteConfig.getValue()));\n    }\n\n    if (remoteConfig.getHashKey() != null) {\n      item.put(ATTR_HASH_KEY, AttributeValues.fromString(remoteConfig.getHashKey()));\n    }\n\n    dynamoDbClient.putItem(PutItemRequest.builder()\n        .tableName(tableName)\n        .item(item)\n        .build());\n  }\n\n  public List<RemoteConfig> getAll() {\n    return dynamoDbClient.scanPaginator(ScanRequest.builder()\n            .tableName(tableName)\n            .consistentRead(true)\n            .build())\n        .items()\n        .stream()\n        .map(item -> {\n          final String name = AttributeValues.getString(item, KEY_NAME, null);\n          final int percentage = AttributeValues.getInt(item, ATTR_PERCENTAGE, 0);\n          final String defaultValue = AttributeValues.getString(item, ATTR_DEFAULT_VALUE, null);\n          final String value = AttributeValues.getString(item, ATTR_VALUE, null);\n          final String hashKey = AttributeValues.getString(item, ATTR_HASH_KEY, null);\n\n          final Set<UUID> uuids;\n\n          if (item.containsKey(ATTR_UUIDS)) {\n            uuids = item.get(ATTR_UUIDS).bs().stream()\n                .map(sdkBytes -> UUIDUtil.fromByteBuffer(sdkBytes.asByteBuffer()))\n                .collect(Collectors.toSet());\n          } else {\n            uuids = Collections.emptySet();\n          }\n\n          return new RemoteConfig(name, percentage, uuids, defaultValue, value, hashKey);\n        })\n        .collect(Collectors.toList());\n  }\n\n  public void delete(final String name) {\n    dynamoDbClient.deleteItem(DeleteItemRequest.builder()\n        .tableName(tableName)\n        .key(Map.of(KEY_NAME, AttributeValues.fromString(name)))\n        .build());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManager.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.google.common.base.Suppliers;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Supplier;\n\npublic class RemoteConfigsManager {\n\n  private final Supplier<List<RemoteConfig>> remoteConfigSupplier;\n\n  public RemoteConfigsManager(RemoteConfigs remoteConfigs) {\n    remoteConfigSupplier =\n        Suppliers.memoizeWithExpiration(remoteConfigs::getAll, 10, TimeUnit.SECONDS);\n  }\n\n  public List<RemoteConfig> getAll() {\n    return remoteConfigSupplier.get();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RemovedMessage.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\n\npublic record RemovedMessage(Optional<ServiceIdentifier> sourceServiceId, ServiceIdentifier destinationServiceId,\n                             @VisibleForTesting UUID serverGuid, long serverTimestamp, long clientTimestamp,\n                             MessageProtos.Envelope.Type envelopeType) {\n\n  public static RemovedMessage fromEnvelope(MessageProtos.Envelope envelope) {\n    return new RemovedMessage(\n        envelope.hasSourceServiceId()\n            ? Optional.of(ServiceIdentifier.valueOf(envelope.getSourceServiceId()))\n            : Optional.empty(),\n        ServiceIdentifier.valueOf(envelope.getDestinationServiceId()),\n        UUID.fromString(envelope.getServerGuid()),\n        envelope.getServerTimestamp(),\n        envelope.getClientTimestamp(),\n        envelope.getType()\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RepeatedUseECSignedPreKeyStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.Map;\nimport java.util.UUID;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\n\npublic class RepeatedUseECSignedPreKeyStore extends RepeatedUseSignedPreKeyStore<ECSignedPreKey> {\n\n  public RepeatedUseECSignedPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {\n    super(dynamoDbAsyncClient, tableName);\n  }\n\n  @Override\n  protected Map<String, AttributeValue> getItemFromPreKey(final UUID accountUuid, final byte deviceId, final ECSignedPreKey signedPreKey) {\n\n    return Map.of(\n        KEY_ACCOUNT_UUID, getPartitionKey(accountUuid),\n        KEY_DEVICE_ID, getSortKey(deviceId),\n        ATTR_KEY_ID, AttributeValues.fromLong(signedPreKey.keyId()),\n        ATTR_PUBLIC_KEY, AttributeValues.fromByteArray(signedPreKey.serializedPublicKey()),\n        ATTR_SIGNATURE, AttributeValues.fromByteArray(signedPreKey.signature()));\n  }\n\n  @Override\n  protected ECSignedPreKey getPreKeyFromItem(final Map<String, AttributeValue> item) {\n    try {\n      return new ECSignedPreKey(\n          Long.parseLong(item.get(ATTR_KEY_ID).n()),\n          new ECPublicKey(item.get(ATTR_PUBLIC_KEY).b().asByteArray()),\n          item.get(ATTR_SIGNATURE).b().asByteArray());\n    } catch (final InvalidKeyException e) {\n      // This should never happen since we're serializing keys directly from `ECPublicKey` instances on the way in\n      throw new IllegalArgumentException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RepeatedUseKEMSignedPreKeyStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class RepeatedUseKEMSignedPreKeyStore extends RepeatedUseSignedPreKeyStore<KEMSignedPreKey> {\n\n  public RepeatedUseKEMSignedPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {\n    super(dynamoDbAsyncClient, tableName);\n  }\n\n  @Override\n  protected Map<String, AttributeValue> getItemFromPreKey(final UUID accountUuid, final byte deviceId, final KEMSignedPreKey signedPreKey) {\n\n    return Map.of(\n        KEY_ACCOUNT_UUID, getPartitionKey(accountUuid),\n        KEY_DEVICE_ID, getSortKey(deviceId),\n        ATTR_KEY_ID, AttributeValues.fromLong(signedPreKey.keyId()),\n        ATTR_PUBLIC_KEY, AttributeValues.fromByteArray(signedPreKey.serializedPublicKey()),\n        ATTR_SIGNATURE, AttributeValues.fromByteArray(signedPreKey.signature()));\n  }\n\n  @Override\n  protected KEMSignedPreKey getPreKeyFromItem(final Map<String, AttributeValue> item) {\n    try {\n      return new KEMSignedPreKey(\n          Long.parseLong(item.get(ATTR_KEY_ID).n()),\n          new KEMPublicKey(item.get(ATTR_PUBLIC_KEY).b().asByteArray()),\n          item.get(ATTR_SIGNATURE).b().asByteArray());\n    } catch (final InvalidKeyException e) {\n      // This should never happen since we're serializing keys directly from `KEMPublicKey` instances on the way in\n      throw new IllegalArgumentException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/RepeatedUseSignedPreKeyStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.whispersystems.textsecuregcm.entities.SignedPreKey;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.Delete;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.Put;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\n\n/**\n * A repeated-use signed pre-key store manages storage for pre-keys that may be used more than once. Generally, these\n * are considered \"last resort\" keys and should only be used when a device's supply of single-use pre-keys has been\n * exhausted.\n * <p/>\n * Each {@link Account} may have one or more {@link Device devices}. Each \"active\" (i.e. those that have completed\n * provisioning and are capable of sending and receiving messages) must have exactly one \"last resort\" pre-key.\n */\npublic abstract class RepeatedUseSignedPreKeyStore<K extends SignedPreKey<?>> {\n\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n\n  static final String KEY_ACCOUNT_UUID = \"U\";\n  static final String KEY_DEVICE_ID = \"D\";\n  static final String ATTR_KEY_ID = \"I\";\n  static final String ATTR_PUBLIC_KEY = \"P\";\n  static final String ATTR_SIGNATURE = \"S\";\n\n  private final Timer storeSingleKeyTimer = Metrics.timer(MetricsUtil.name(getClass(), \"storeSingleKey\"));\n\n  private final String findKeyTimerName = MetricsUtil.name(getClass(), \"findKey\");\n\n  public RepeatedUseSignedPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n  }\n\n  /**\n   * Stores a repeated-use pre-key for a specific device, displacing any previously-stored repeated-use pre-key for that\n   * device.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId the identifier for the device within the given account/identity\n   * @param signedPreKey the key to store for the target device\n   *\n   * @return a future that completes once the key has been stored\n   */\n  public CompletableFuture<Void> store(final UUID identifier, final byte deviceId, final K signedPreKey) {\n    final Timer.Sample sample = Timer.start();\n\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .item(getItemFromPreKey(identifier, deviceId, signedPreKey))\n            .build())\n        .thenRun(() -> sample.stop(storeSingleKeyTimer));\n  }\n\n  TransactWriteItem buildTransactWriteItemForInsertion(final UUID identifier, final byte deviceId, final K preKey) {\n    return TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(tableName)\n            .item(getItemFromPreKey(identifier, deviceId, preKey))\n            .build())\n        .build();\n  }\n\n  public TransactWriteItem buildTransactWriteItemForDeletion(final UUID identifier, final byte deviceId) {\n    return TransactWriteItem.builder()\n        .delete(Delete.builder()\n            .tableName(tableName)\n            .key(getPrimaryKey(identifier, deviceId))\n            .build())\n        .build();\n  }\n\n  /**\n   * Finds a repeated-use pre-key for a specific device.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId the identifier for the device within the given account/identity\n   *\n   * @return a future that yields an optional signed pre-key if one is available for the target device or empty if no\n   * key could be found for the target device\n   */\n  public CompletableFuture<Optional<K>> find(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n\n    final CompletableFuture<Optional<K>> findFuture = dynamoDbAsyncClient.getItem(GetItemRequest.builder()\n            .tableName(tableName)\n            .key(getPrimaryKey(identifier, deviceId))\n            .consistentRead(true)\n            .build())\n        .thenApply(response -> response.hasItem() ? Optional.of(getPreKeyFromItem(response.item())) : Optional.empty());\n\n    return findFuture.whenComplete((maybeSignedPreKey, throwable) -> {\n      if (throwable == null && maybeSignedPreKey.map(k -> !KeyIdUtil.keyIdValid(k.keyId())).orElse(false)) {\n        throw new IllegalStateException(\"Encountered an impossible invalid repeated use pre-key id of \" + maybeSignedPreKey.get().keyId());\n      }\n\n      sample.stop(Metrics.timer(findKeyTimerName,\n          \"keyPresent\", String.valueOf(maybeSignedPreKey != null && maybeSignedPreKey.isPresent())));\n    });\n  }\n\n  protected static Map<String, AttributeValue> getPrimaryKey(final UUID identifier, final byte deviceId) {\n    return Map.of(\n        KEY_ACCOUNT_UUID, getPartitionKey(identifier),\n        KEY_DEVICE_ID, getSortKey(deviceId));\n  }\n\n  protected static AttributeValue getPartitionKey(final UUID accountUuid) {\n    return AttributeValues.fromUUID(accountUuid);\n  }\n\n  protected static AttributeValue getSortKey(final byte deviceId) {\n    return AttributeValues.fromInt(deviceId);\n  }\n\n  protected abstract Map<String, AttributeValue> getItemFromPreKey(final UUID accountUuid, final byte deviceId,\n      final K signedPreKey);\n\n  protected abstract K getPreKeyFromItem(final Map<String, AttributeValue> item);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDb.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\npublic class ReportMessageDynamoDb {\n\n  static final String KEY_HASH = \"H\";\n  static final String ATTR_TTL = \"E\";\n\n  private final DynamoDbClient db;\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n  private final Duration ttl;\n\n  private static final String REMOVED_MESSAGE_COUNTER_NAME = name(ReportMessageDynamoDb.class, \"removed\");\n  private static final Timer REMOVED_MESSAGE_AGE_TIMER = Timer\n      .builder(name(ReportMessageDynamoDb.class, \"removedMessageAge\"))\n      .distributionStatisticExpiry(Duration.ofDays(1))\n      .register(Metrics.globalRegistry);\n\n  public ReportMessageDynamoDb(final DynamoDbClient dynamoDB,\n      final DynamoDbAsyncClient dynamoDbAsyncClient,\n      final String tableName,\n      final Duration ttl) {\n\n    this.db = dynamoDB;\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n    this.ttl = ttl;\n  }\n\n  public CompletableFuture<Void> store(byte[] hash) {\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n        .tableName(tableName)\n        .item(Map.of(\n            KEY_HASH, AttributeValues.fromByteArray(hash),\n            ATTR_TTL, AttributeValues.fromLong(Instant.now().plus(ttl).getEpochSecond())\n        ))\n        .build())\n        .thenRun(Util.NOOP);\n  }\n\n  public boolean remove(byte[] hash) {\n    final DeleteItemResponse deleteItemResponse = db.deleteItem(DeleteItemRequest.builder()\n        .tableName(tableName)\n        .key(Map.of(KEY_HASH, AttributeValues.fromByteArray(hash)))\n        .returnValues(ReturnValue.ALL_OLD)\n        .build());\n\n    final boolean found = !deleteItemResponse.attributes().isEmpty();\n\n    if (found) {\n      if (deleteItemResponse.attributes().containsKey(ATTR_TTL)) {\n        final Instant expiration =\n            Instant.ofEpochSecond(Long.parseLong(deleteItemResponse.attributes().get(ATTR_TTL).n()));\n\n        final Duration approximateAge = ttl.minus(Duration.between(Instant.now(), expiration));\n\n        REMOVED_MESSAGE_AGE_TIMER.record(approximateAge);\n      }\n    }\n\n    Metrics.counter(REMOVED_MESSAGE_COUNTER_NAME, \"found\", String.valueOf(found)).increment();\n\n    return found;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportMessageManager.java",
    "content": "/*\n * Copyright 2021-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport io.lettuce.core.RedisException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class ReportMessageManager {\n\n  private final ReportMessageDynamoDb reportMessageDynamoDb;\n  private final FaultTolerantRedisClusterClient rateLimitCluster;\n\n  private final Duration counterTtl;\n\n  private final List<ReportedMessageListener> reportedMessageListeners = new ArrayList<>();\n\n  private static final String REPORT_MESSAGE_COUNTER_NAME = MetricsUtil.name(ReportMessageManager.class, \"reportMessage\");\n  private static final String FOUND_MESSAGE_TAG = \"foundMessage\";\n  private static final String TOKEN_PRESENT_TAG = \"hasReportSpamToken\";\n\n  private static final Logger logger = LoggerFactory.getLogger(ReportMessageManager.class);\n\n  public ReportMessageManager(final ReportMessageDynamoDb reportMessageDynamoDb,\n      final FaultTolerantRedisClusterClient rateLimitCluster,\n      final Duration counterTtl) {\n\n    this.reportMessageDynamoDb = reportMessageDynamoDb;\n    this.rateLimitCluster = rateLimitCluster;\n\n    this.counterTtl = counterTtl;\n  }\n\n  public void addListener(final ReportedMessageListener listener) {\n    this.reportedMessageListeners.add(listener);\n  }\n\n  public void store(String sourceAci, UUID messageGuid) {\n    try {\n      reportMessageDynamoDb.store(hash(messageGuid, Objects.requireNonNull(sourceAci)));\n    } catch (final Exception e) {\n      logger.warn(\"Failed to store hash\", e);\n    }\n  }\n\n  public void report(final Optional<String> sourceNumber,\n      final Optional<UUID> sourceAci,\n      final Optional<UUID> sourcePni,\n      final UUID messageGuid,\n      final UUID reporterUuid,\n      final Optional<byte[]> reportSpamToken,\n      final String reporterUserAgent) {\n\n    final boolean found = sourceAci.map(uuid -> reportMessageDynamoDb.remove(hash(messageGuid, uuid.toString())))\n        .orElse(false);\n\n    Metrics.counter(REPORT_MESSAGE_COUNTER_NAME,\n            Tags.of(FOUND_MESSAGE_TAG, String.valueOf(found),\n                    TOKEN_PRESENT_TAG, String.valueOf(reportSpamToken.isPresent()))\n                .and(UserAgentTagUtil.getPlatformTag(reporterUserAgent)))\n        .increment();\n\n    if (found) {\n      rateLimitCluster.useCluster(connection -> {\n        sourcePni.ifPresent(pni -> {\n          final String reportedSenderKey = getReportedSenderPniKey(pni);\n          connection.sync().pfadd(reportedSenderKey, reporterUuid.toString());\n          connection.sync().expire(reportedSenderKey, counterTtl.toSeconds());\n        });\n\n        sourceAci.ifPresent(aci -> {\n          final String reportedSenderKey = getReportedSenderAciKey(aci);\n          connection.sync().pfadd(reportedSenderKey, reporterUuid.toString());\n          connection.sync().expire(reportedSenderKey, counterTtl.toSeconds());\n        });\n      });\n\n      sourceNumber.ifPresent(number ->\n          reportedMessageListeners.forEach(listener -> {\n            try {\n              listener.handleMessageReported(number, messageGuid, reporterUuid, reportSpamToken);\n            } catch (final Exception e) {\n              logger.error(\"Failed to notify listener of reported message\", e);\n            }\n          }));\n    }\n  }\n\n  /**\n   * Returns the number of times messages from the given account have been reported by recipients as spam. Note that\n   * this method makes a call to an external service, and callers should take care to memoize calls where possible and\n   * avoid unnecessary calls.\n   *\n   * @param account the account to check for recent reports\n   * @return the number of times the given number has been reported recently\n   */\n  public int getRecentReportCount(final Account account) {\n    try {\n      return rateLimitCluster.withCluster(\n          connection ->\n              Math.max(\n                  connection.sync().pfcount(getReportedSenderPniKey(account.getPhoneNumberIdentifier())).intValue(),\n                  connection.sync().pfcount(getReportedSenderAciKey(account.getUuid())).intValue()));\n    } catch (final RedisException e) {\n      return 0;\n    }\n  }\n\n  private byte[] hash(UUID messageGuid, String otherId) {\n    final MessageDigest sha256;\n    try {\n      sha256 = MessageDigest.getInstance(\"SHA-256\");\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n\n    sha256.update(UUIDUtil.toBytes(messageGuid));\n    sha256.update(otherId.getBytes(StandardCharsets.UTF_8));\n\n    return sha256.digest();\n  }\n\n  private static String getReportedSenderAciKey(final UUID aci) {\n    return \"reported_account::\" + aci.toString();\n  }\n\n  private static String getReportedSenderPniKey(final UUID pni) {\n    return \"reported_pni::\" + pni.toString();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/ReportedMessageListener.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.Optional;\nimport java.util.UUID;\n\npublic interface ReportedMessageListener {\n\n  void handleMessageReported(String sourceNumber, UUID messageGuid, UUID reporterUuid, Optional<byte[]> reportSpamToken);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.annotations.VisibleForTesting;\nimport java.lang.reflect.ParameterizedType;\nimport java.lang.reflect.Type;\nimport java.time.Clock;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Consumer;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\n\npublic abstract class SerializedExpireableJsonDynamoStore<T> {\n\n  public interface Expireable {\n\n    @JsonIgnore\n    long getExpirationEpochSeconds();\n  }\n\n  private final DynamoDbAsyncClient dynamoDbClient;\n  private final String tableName;\n  private final Clock clock;\n  private final Class<T> deserializationTargetClass;\n\n  @VisibleForTesting\n  static final String KEY_KEY = \"K\";\n\n  private static final String ATTR_SERIALIZED_VALUE = \"V\";\n  private static final String ATTR_TTL = \"E\";\n\n  private final Logger log = LoggerFactory.getLogger(getClass());\n\n  public SerializedExpireableJsonDynamoStore(final DynamoDbAsyncClient dynamoDbClient, final String tableName,\n      final Clock clock) {\n    this.dynamoDbClient = dynamoDbClient;\n    this.tableName = tableName;\n    this.clock = clock;\n\n    if (getClass().getGenericSuperclass() instanceof ParameterizedType pt) {\n      // Extract the parameterized class declared by concrete implementations, so that it can\n      // be passed to future deserialization calls\n      final Type[] actualTypeArguments = pt.getActualTypeArguments();\n      if (actualTypeArguments.length != 1) {\n        throw new RuntimeException(\"Unexpected number of type arguments: \" + actualTypeArguments.length);\n      }\n      deserializationTargetClass = (Class<T>) actualTypeArguments[0];\n    } else {\n      throw new RuntimeException(\n          \"Unable to determine target class for deserialization - generic superclass is not a ParameterizedType\");\n    }\n  }\n\n  public CompletableFuture<Void> insert(final String key, final T v) {\n    return put(key, v, builder -> builder.expressionAttributeNames(Map.of(\n        \"#key\", KEY_KEY\n    )).conditionExpression(\"attribute_not_exists(#key)\"));\n  }\n\n  public CompletableFuture<Void> update(final String key, final T v) {\n    return put(key, v, ignored -> {\n    });\n  }\n\n  private CompletableFuture<Void> put(final String key, final T v,\n      final Consumer<PutItemRequest.Builder> putRequestCustomizer) {\n    try {\n      final Map<String, AttributeValue> attributeValueMap = new HashMap<>(Map.of(\n          KEY_KEY, AttributeValues.fromString(key),\n          ATTR_SERIALIZED_VALUE,\n          AttributeValues.fromString(SystemMapper.jsonMapper().writeValueAsString(v))));\n      if (v instanceof Expireable ev) {\n        attributeValueMap.put(ATTR_TTL, AttributeValues.fromLong(getExpirationTimestamp(ev)));\n      }\n      final PutItemRequest.Builder builder = PutItemRequest.builder()\n          .tableName(tableName)\n          .item(attributeValueMap);\n      putRequestCustomizer.accept(builder);\n\n      return dynamoDbClient.putItem(builder.build())\n          .thenRun(() -> {\n          });\n    } catch (final JsonProcessingException e) {\n      // This should never happen when writing directly to a string except in cases of serious misconfiguration, which\n      // would be caught by tests.\n      throw new AssertionError(e);\n    }\n  }\n\n  private long getExpirationTimestamp(final Expireable v) {\n    return v.getExpirationEpochSeconds();\n  }\n\n  public CompletableFuture<Optional<T>> findForKey(final String key) {\n    return dynamoDbClient.getItem(GetItemRequest.builder()\n            .tableName(tableName)\n            .consistentRead(true)\n            .key(Map.of(KEY_KEY, AttributeValues.fromString(key)))\n            .build())\n        .thenApply(response -> {\n          try {\n            return response.hasItem()\n                ? filterMaybeExpiredValue(\n                SystemMapper.jsonMapper()\n                    .readValue(response.item().get(ATTR_SERIALIZED_VALUE).s(), deserializationTargetClass))\n                : Optional.empty();\n          } catch (final JsonProcessingException e) {\n            log.error(\"Failed to parse stored value\", e);\n            return Optional.empty();\n          }\n        });\n  }\n\n  private Optional<T> filterMaybeExpiredValue(T v) {\n    // It's possible for DynamoDB to return items after their expiration time (although it is very unlikely for small\n    // tables)\n    if (v instanceof Expireable ev) {\n      if (getExpirationTimestamp(ev) < clock.instant().getEpochSecond()) {\n        return Optional.empty();\n      }\n    }\n\n    return Optional.of(v);\n  }\n\n  public CompletableFuture<Void> remove(final String key) {\n    return dynamoDbClient.deleteItem(DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(KEY_KEY, AttributeValues.fromString(key)))\n            .build())\n        .thenRun(() -> {\n        });\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/SingleUseECPreKeyStore.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\nimport static org.whispersystems.textsecuregcm.storage.AbstractDynamoDbStore.DYNAMO_DB_MAX_BATCH_SIZE;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Timer;\nimport java.nio.ByteBuffer;\nimport java.time.Duration;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\n\n/**\n * A single-use EC pre-key store stores single-use EC prekeys. Keys returned by a single-use pre-key\n * store's {@link #take(UUID, byte)} method are guaranteed to be returned exactly once, and repeated calls will never\n * yield the same key.\n * <p/>\n * Each {@link Account} may have one or more {@link Device devices}. Clients <em>should</em> regularly check their\n * supply of single-use pre-keys (see {@link #getCount(UUID, byte)}) and upload new keys when their supply runs low. In\n * the event that a party wants to begin a session with a device that has no single-use pre-keys remaining, that party\n * may fall back to using the device's repeated-use (\"last-resort\") signed pre-key instead.\n */\npublic class SingleUseECPreKeyStore {\n  private final DynamoDbAsyncClient dynamoDbAsyncClient;\n  private final String tableName;\n\n  private final Timer getKeyCountTimer = Metrics.timer(name(getClass(), \"getCount\"));\n  private final Timer storeKeyTimer = Metrics.timer(name(getClass(), \"storeKey\"));\n  private final Timer storeKeyBatchTimer = Metrics.timer(name(getClass(), \"storeKeyBatch\"));\n  private final Timer deleteForDeviceTimer = Metrics.timer(name(getClass(), \"deleteForDevice\"));\n  private final Timer deleteForAccountTimer = Metrics.timer(name(getClass(), \"deleteForAccount\"));\n\n  private final Counter noKeyCountAvailableCounter = Metrics.counter(name(getClass(), \"noKeyCountAvailable\"));\n  private final Counter outOfRangeKeysDiscarded =\n      Metrics.counter(name(getClass(), \"outOfRangeKeysDiscarded\"));\n  final DistributionSummary keysConsideredForTakeDistributionSummary = DistributionSummary\n      .builder(name(getClass(), \"keysConsideredForTake\"))\n      .distributionStatisticExpiry(Duration.ofMinutes(10))\n      .register(Metrics.globalRegistry);\n\n\n  final DistributionSummary availableKeyCountDistributionSummary = DistributionSummary\n      .builder(name(getClass(), \"availableKeyCount\"))\n      .distributionStatisticExpiry(Duration.ofMinutes(10))\n      .register(Metrics.globalRegistry);\n\n  private static final String PARSE_BYTE_ARRAY_COUNTER_NAME = name(SingleUseECPreKeyStore.class, \"parseByteArray\");\n\n  private final String takeKeyTimerName = name(getClass(), \"takeKey\");\n  private static final String KEY_PRESENT_TAG_NAME = \"keyPresent\";\n\n  static final String KEY_ACCOUNT_UUID = \"U\";\n  static final String KEY_DEVICE_ID_KEY_ID = \"DK\";\n  static final String ATTR_PUBLIC_KEY = \"P\";\n  static final String ATTR_REMAINING_KEYS = \"R\";\n\n  public SingleUseECPreKeyStore(final DynamoDbAsyncClient dynamoDbAsyncClient, final String tableName) {\n    this.dynamoDbAsyncClient = dynamoDbAsyncClient;\n    this.tableName = tableName;\n  }\n\n  /**\n   * Stores a batch of single-use pre-keys for a specific device. All previously-stored keys for the device are cleared\n   * before storing new keys.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId the identifier for the device within the given account/identity\n   * @param preKeys a collection of single-use pre-keys to store for the target device\n   *\n   * @return a future that completes when all previously-stored keys have been removed and the given collection of\n   * pre-keys has been stored in its place\n   */\n  public CompletableFuture<Void> store(final UUID identifier, final byte deviceId, final List<ECPreKey> preKeys) {\n    final Timer.Sample sample = Timer.start();\n\n    return Mono.fromFuture(() -> delete(identifier, deviceId))\n        .thenMany(\n            Flux.fromIterable(preKeys)\n                .sort(Comparator.comparing(preKey -> preKey.keyId()))\n                .zipWith(Flux.range(0, preKeys.size()).map(i -> preKeys.size() - i))\n                .flatMap(preKeyAndRemainingCount -> Mono.fromFuture(() ->\n                        store(identifier, deviceId, preKeyAndRemainingCount.getT1(), preKeyAndRemainingCount.getT2())),\n                    DYNAMO_DB_MAX_BATCH_SIZE))\n        .then()\n        .toFuture()\n        .thenRun(() -> sample.stop(storeKeyBatchTimer));\n  }\n\n  private CompletableFuture<Void> store(final UUID identifier, final byte deviceId, final ECPreKey preKey, final int remainingKeys) {\n    final Timer.Sample sample = Timer.start();\n\n    return dynamoDbAsyncClient.putItem(PutItemRequest.builder()\n            .tableName(tableName)\n            .item(getItemFromPreKey(identifier, deviceId, preKey, remainingKeys))\n            .build())\n        .thenRun(() -> sample.stop(storeKeyTimer));\n  }\n\n  /**\n   * Attempts to retrieve a single-use pre-key for a specific device. Keys may only be returned by this method at most\n   * once; once the key is returned, it is removed from the key store and subsequent calls to this method will never\n   * return the same key.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId the identifier for the device within the given account/identity\n   *\n   * @return a future that yields a single-use pre-key if one is available or empty if no single-use pre-keys are\n   * available for the target device\n   */\n  public CompletableFuture<Optional<ECPreKey>> take(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n    final AttributeValue partitionKey = getPartitionKey(identifier);\n    final AtomicInteger deletionAttempts = new AtomicInteger(0);\n\n    return Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n                .tableName(tableName)\n                .keyConditionExpression(\"#uuid = :uuid AND begins_with (#sort, :sortprefix)\")\n                .expressionAttributeNames(Map.of(\"#uuid\", KEY_ACCOUNT_UUID, \"#sort\", KEY_DEVICE_ID_KEY_ID))\n                .expressionAttributeValues(Map.of(\n                    \":uuid\", partitionKey,\n                    \":sortprefix\", getSortKeyPrefix(deviceId)))\n                .projectionExpression(KEY_DEVICE_ID_KEY_ID)\n                .consistentRead(false)\n                .limit(1)\n                .build())\n            .items())\n        .map(item -> DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_ACCOUNT_UUID, partitionKey,\n                KEY_DEVICE_ID_KEY_ID, item.get(KEY_DEVICE_ID_KEY_ID)))\n            .returnValues(ReturnValue.ALL_OLD)\n            .build())\n        .concatMap(deleteItemRequest -> Mono.fromFuture(() -> dynamoDbAsyncClient.deleteItem(deleteItemRequest)))\n        .doOnNext(_ -> deletionAttempts.incrementAndGet())\n        .filter(DeleteItemResponse::hasAttributes)\n        .filter(item -> {\n          final long keyId = getKeyIdFromItem(item.attributes());\n          final boolean keyIdValid = KeyIdUtil.keyIdValid(keyId);\n          if (!keyIdValid) {\n            outOfRangeKeysDiscarded.increment();\n          }\n          // At some point we did not validate that keyIds fit in an unsigned 32-bit integer, which clients require.\n          // If this keyId is invalid, we'll skip it and fetch the next key\n          return keyIdValid;\n        })\n        .next()\n        .map(deleteItemResponse -> getPreKeyFromItem(deleteItemResponse.attributes()))\n        .toFuture()\n        .thenApply(Optional::ofNullable)\n        .whenComplete((maybeKey, throwable) -> {\n          sample.stop(Metrics.timer(takeKeyTimerName, KEY_PRESENT_TAG_NAME, String.valueOf(maybeKey != null && maybeKey.isPresent())));\n          keysConsideredForTakeDistributionSummary.record(deletionAttempts.get());\n        });\n  }\n\n  /**\n   * Estimates the number of single-use pre-keys available for a given device.\n\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId the identifier for the device within the given account/identity\n\n   * @return a future that yields the approximate number of single-use pre-keys currently available for the target\n   * device\n   */\n  public CompletableFuture<Integer> getCount(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n\n    return dynamoDbAsyncClient.query(QueryRequest.builder()\n            .tableName(tableName)\n            .consistentRead(false)\n            .keyConditionExpression(\"#uuid = :uuid AND begins_with (#sort, :sortprefix)\")\n            .expressionAttributeNames(Map.of(\"#uuid\", KEY_ACCOUNT_UUID, \"#sort\", KEY_DEVICE_ID_KEY_ID))\n            .expressionAttributeValues(Map.of(\n                \":uuid\", getPartitionKey(identifier),\n                \":sortprefix\", getSortKeyPrefix(deviceId)))\n            .projectionExpression(ATTR_REMAINING_KEYS)\n            .limit(1)\n            .build())\n        .thenApply(response -> {\n          if (response.count() > 0) {\n            final Map<String, AttributeValue> item = response.items().getFirst();\n\n            if (item.containsKey(ATTR_REMAINING_KEYS)) {\n              return Integer.parseInt(item.get(ATTR_REMAINING_KEYS).n());\n            } else {\n              // Some legacy keys sets may not have pre-counted keys; in that case, we'll tell the owners of those key\n              // sets that they have none remaining, prompting an upload of a fresh set that we'll pre-count. This has\n              // no effect on consumers of keys, which will still be able to take keys if any are actually present.\n              noKeyCountAvailableCounter.increment();\n              return 0;\n            }\n          } else {\n            return 0;\n          }\n        })\n        .whenComplete((keyCount, throwable) -> {\n          sample.stop(getKeyCountTimer);\n\n          if (throwable == null && keyCount != null) {\n            availableKeyCountDistributionSummary.record(keyCount);\n          }\n        });\n  }\n\n  /**\n   * Removes all single-use pre-keys for all devices associated with the given account/identity.\n   *\n   * @param identifier the identifier for the account/identity for which to remove single-use pre-keys\n   *\n   * @return a future that completes when all single-use pre-keys have been removed for all devices associated with the\n   * given account/identity\n   */\n  public CompletableFuture<Void> delete(final UUID identifier) {\n    final Timer.Sample sample = Timer.start();\n\n    return deleteItems(getPartitionKey(identifier), Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n            .tableName(tableName)\n            .keyConditionExpression(\"#uuid = :uuid\")\n            .expressionAttributeNames(Map.of(\"#uuid\", KEY_ACCOUNT_UUID))\n            .expressionAttributeValues(Map.of(\":uuid\", getPartitionKey(identifier)))\n            .projectionExpression(KEY_DEVICE_ID_KEY_ID)\n            .consistentRead(true)\n            .build())\n        .items()))\n        .thenRun(() -> sample.stop(deleteForAccountTimer));\n  }\n\n  /**\n   * Removes all single-use pre-keys for a specific device.\n   *\n   * @param identifier the identifier for the account/identity with which the target device is associated\n   * @param deviceId the identifier for the device within the given account/identity\n\n   * @return a future that completes when all single-use pre-keys have been removed for the target device\n   */\n  public CompletableFuture<Void> delete(final UUID identifier, final byte deviceId) {\n    final Timer.Sample sample = Timer.start();\n\n    return deleteItems(getPartitionKey(identifier), Flux.from(dynamoDbAsyncClient.queryPaginator(QueryRequest.builder()\n            .tableName(tableName)\n            .keyConditionExpression(\"#uuid = :uuid AND begins_with (#sort, :sortprefix)\")\n            .expressionAttributeNames(Map.of(\"#uuid\", KEY_ACCOUNT_UUID, \"#sort\", KEY_DEVICE_ID_KEY_ID))\n            .expressionAttributeValues(Map.of(\n                \":uuid\", getPartitionKey(identifier),\n                \":sortprefix\", getSortKeyPrefix(deviceId)))\n            .projectionExpression(KEY_DEVICE_ID_KEY_ID)\n            .consistentRead(true)\n            .build())\n        .items()))\n        .thenRun(() -> sample.stop(deleteForDeviceTimer));\n  }\n\n  private CompletableFuture<Void> deleteItems(final AttributeValue partitionKey, final Flux<Map<String, AttributeValue>> items) {\n    return items\n        .map(item -> DeleteItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(\n                KEY_ACCOUNT_UUID, partitionKey,\n                KEY_DEVICE_ID_KEY_ID, item.get(KEY_DEVICE_ID_KEY_ID)\n            ))\n            .build())\n        .flatMap(deleteItemRequest -> Mono.fromFuture(() -> dynamoDbAsyncClient.deleteItem(deleteItemRequest)), DYNAMO_DB_MAX_BATCH_SIZE)\n        .then()\n        .toFuture()\n        .thenRun(Util.NOOP);\n  }\n\n  protected static AttributeValue getPartitionKey(final UUID accountUuid) {\n    return AttributeValues.fromUUID(accountUuid);\n  }\n\n  protected static AttributeValue getSortKey(final byte deviceId, final long keyId) {\n    final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);\n    byteBuffer.putLong(deviceId);\n    byteBuffer.putLong(keyId);\n    return AttributeValues.fromByteBuffer(byteBuffer.flip());\n  }\n\n  private static AttributeValue getSortKeyPrefix(final byte deviceId) {\n    final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]);\n    byteBuffer.putLong(deviceId);\n    return AttributeValues.fromByteBuffer(byteBuffer.flip());\n  }\n\n  private Map<String, AttributeValue> getItemFromPreKey(final UUID identifier,\n      final byte deviceId,\n      final ECPreKey preKey,\n      final int remainingKeys) {\n    return Map.of(\n        KEY_ACCOUNT_UUID, getPartitionKey(identifier),\n        KEY_DEVICE_ID_KEY_ID, getSortKey(deviceId, preKey.keyId()),\n        ATTR_PUBLIC_KEY, AttributeValues.fromByteArray(preKey.serializedPublicKey()),\n        ATTR_REMAINING_KEYS, AttributeValues.fromInt(remainingKeys));\n  }\n\n  private ECPreKey getPreKeyFromItem(final Map<String, AttributeValue> item) {\n    final long keyId = getKeyIdFromItem(item);\n    final byte[] publicKey = AttributeValues.extractByteArray(item.get(ATTR_PUBLIC_KEY), PARSE_BYTE_ARRAY_COUNTER_NAME);\n\n    try {\n      return new ECPreKey(keyId, new ECPublicKey(publicKey));\n    } catch (final InvalidKeyException e) {\n      // This should never happen since we're serializing keys directly from `ECPublicKey` instances on the way in\n      throw new IllegalArgumentException(e);\n    }\n  }\n\n  private static long getKeyIdFromItem(final Map<String, AttributeValue> item) {\n    return item.get(KEY_DEVICE_ID_KEY_ID).b().asByteBuffer().getLong(8);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriberCredentials.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport jakarta.ws.rs.InternalServerErrorException;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.Optional;\nimport javax.annotation.Nonnull;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;\n\npublic record SubscriberCredentials(@Nonnull byte[] subscriberBytes,\n                             @Nonnull byte[] subscriberUser,\n                             @Nonnull byte[] subscriberKey,\n                             @Nonnull byte[] hmac,\n                             @Nonnull Instant now) {\n\n  public static SubscriberCredentials process(\n      Optional<AuthenticatedDevice> authenticatedAccount,\n      String subscriberId,\n      Clock clock) throws SubscriptionException {\n    Instant now = clock.instant();\n    if (authenticatedAccount.isPresent()) {\n      throw new SubscriptionForbiddenException(\"must not use authenticated connection for subscriber operations\");\n    }\n    byte[] subscriberBytes = convertSubscriberIdStringToBytes(subscriberId);\n    byte[] subscriberUser = getUser(subscriberBytes);\n    byte[] subscriberKey = getKey(subscriberBytes);\n    byte[] hmac = computeHmac(subscriberUser, subscriberKey);\n    return new SubscriberCredentials(subscriberBytes, subscriberUser, subscriberKey, hmac, now);\n  }\n\n  private static byte[] convertSubscriberIdStringToBytes(String subscriberId) throws SubscriptionNotFoundException {\n    try {\n      byte[] bytes = Base64.getUrlDecoder().decode(subscriberId);\n      if (bytes.length != 32) {\n        throw new SubscriptionNotFoundException();\n      }\n      return bytes;\n    } catch (IllegalArgumentException e) {\n      throw new SubscriptionNotFoundException(e);\n    }\n  }\n\n  private static byte[] getUser(byte[] subscriberBytes) {\n    byte[] user = new byte[16];\n    System.arraycopy(subscriberBytes, 0, user, 0, user.length);\n    return user;\n  }\n\n  private static byte[] getKey(byte[] subscriberBytes) {\n    byte[] key = new byte[16];\n    System.arraycopy(subscriberBytes, 16, key, 0, key.length);\n    return key;\n  }\n\n  private static byte[] computeHmac(byte[] subscriberUser, byte[] subscriberKey) {\n    try {\n      Mac mac = Mac.getInstance(\"HmacSHA256\");\n      mac.init(new SecretKeySpec(subscriberKey, \"HmacSHA256\"));\n      return mac.doFinal(subscriberUser);\n    } catch (NoSuchAlgorithmException | InvalidKeyException e) {\n      throw new InternalServerErrorException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/SubscriptionManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.time.Instant;\nimport java.util.EnumMap;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.function.BiFunction;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nonnull;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.controllers.SubscriptionController;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;\nimport org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;\nimport org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInformation;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionForbiddenException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidLevelException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\n/**\n * Manages updates to the Subscriptions table and the upstream subscription payment providers.\n * <p>\n * This handles a number of common subscription management operations like adding/removing subscribers and creating ZK\n * receipt credentials for a subscriber's active subscription. Some subscription management operations only apply to\n * certain payment providers. In those cases, the operation will take the payment provider that implements the specific\n * functionality as an argument to the method.\n */\npublic class SubscriptionManager {\n\n  private final Subscriptions subscriptions;\n  private final EnumMap<PaymentProvider, SubscriptionPaymentProcessor> processors;\n  private final ServerZkReceiptOperations zkReceiptOperations;\n  private final IssuedReceiptsManager issuedReceiptsManager;\n\n  public SubscriptionManager(\n      @Nonnull Subscriptions subscriptions,\n      @Nonnull List<SubscriptionPaymentProcessor> processors,\n      @Nonnull ServerZkReceiptOperations zkReceiptOperations,\n      @Nonnull IssuedReceiptsManager issuedReceiptsManager) {\n    this.subscriptions = Objects.requireNonNull(subscriptions);\n    this.processors = new EnumMap<>(processors.stream()\n        .collect(Collectors.toMap(SubscriptionPaymentProcessor::getProvider, Function.identity())));\n    this.zkReceiptOperations = Objects.requireNonNull(zkReceiptOperations);\n    this.issuedReceiptsManager = Objects.requireNonNull(issuedReceiptsManager);\n  }\n\n  /**\n   * Cancel a subscription with the upstream payment provider and remove the subscription from the table\n   *\n   * @param subscriberCredentials Subscriber credentials derived from the subscriberId\n   * @throws RateLimitExceededException            if rate-limited\n   * @throws SubscriptionNotFoundException         if the provided credentials are incorrect or the subscriber does not\n   *                                               exist\n   * @throws SubscriptionInvalidArgumentsException if a precondition for cancellation was not met\n   */\n  public void deleteSubscriber(final SubscriberCredentials subscriberCredentials)\n      throws SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, RateLimitExceededException {\n    final Subscriptions.GetResult getResult =\n        subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join();\n    if (getResult == Subscriptions.GetResult.NOT_STORED\n        || getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {\n      throw new SubscriptionNotFoundException();\n    }\n\n    // a missing customer ID is OK; it means the subscriber never started to add a payment method, so we can skip cancelling\n    if (getResult.record.getProcessorCustomer().isPresent()) {\n      final ProcessorCustomer processorCustomer = getResult.record.getProcessorCustomer().get();\n      getProcessor(processorCustomer.processor()).cancelAllActiveSubscriptions(processorCustomer.customerId());\n    }\n    subscriptions.setCanceledAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()).join();\n  }\n\n  /**\n   * Create or update a subscriber in the subscriptions table\n   * <p>\n   * If the subscriber does not exist, a subscriber with the provided credentials will be created. If the subscriber\n   * already exists, its last access time will be updated.\n   *\n   * @param subscriberCredentials Subscriber credentials derived from the subscriberId\n   * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect\n   */\n  public void updateSubscriber(final SubscriberCredentials subscriberCredentials)\n      throws SubscriptionForbiddenException {\n    final Subscriptions.GetResult getResult =\n        subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join();\n\n    if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {\n      throw new SubscriptionForbiddenException(\"subscriberId mismatch\");\n    } else if (getResult == Subscriptions.GetResult.NOT_STORED) {\n      // create a customer and write it to ddb\n      final Subscriptions.Record updatedRecord = subscriptions.create(subscriberCredentials.subscriberUser(),\n          subscriberCredentials.hmac(),\n          subscriberCredentials.now()).join();\n      if (updatedRecord == null) {\n        throw new SubscriptionForbiddenException(\"subscriberId mismatch\");\n      }\n    } else {\n      // already exists so just touch access time and return\n      subscriptions.accessedAt(subscriberCredentials.subscriberUser(), subscriberCredentials.now()).join();\n    }\n  }\n\n  public Optional<SubscriptionInformation> getSubscriptionInformation(\n      final SubscriberCredentials subscriberCredentials)\n      throws SubscriptionForbiddenException, SubscriptionNotFoundException, RateLimitExceededException {\n    final Subscriptions.Record record = getSubscriber(subscriberCredentials);\n    if (record.subscriptionId == null) {\n      return Optional.empty();\n    }\n    final SubscriptionPaymentProcessor manager = getProcessor(record.processorCustomer.processor());\n    return Optional.of(manager.getSubscriptionInformation(record.subscriptionId));\n  }\n\n  /**\n   * Get the subscriber record\n   *\n   * @param subscriberCredentials Subscriber credentials derived from the subscriberId\n   * @throws SubscriptionForbiddenException if the subscriber credentials were incorrect\n   * @throws SubscriptionNotFoundException  if the subscriber did not exist\n   */\n  public Subscriptions.Record getSubscriber(final SubscriberCredentials subscriberCredentials)\n      throws SubscriptionForbiddenException, SubscriptionNotFoundException {\n    final Subscriptions.GetResult getResult =\n        subscriptions.get(subscriberCredentials.subscriberUser(), subscriberCredentials.hmac()).join();\n    if (getResult == Subscriptions.GetResult.PASSWORD_MISMATCH) {\n      throw new SubscriptionForbiddenException(\"subscriberId mismatch\");\n    } else if (getResult == Subscriptions.GetResult.NOT_STORED) {\n      throw new SubscriptionNotFoundException();\n    } else {\n      return getResult.record;\n    }\n  }\n\n  public record ReceiptResult(\n      ReceiptCredentialResponse receiptCredentialResponse,\n      CustomerAwareSubscriptionPaymentProcessor.ReceiptItem receiptItem,\n      PaymentProvider paymentProvider) {}\n\n  /**\n   * Create a ZK receipt credential for a subscription that can be used to obtain the user entitlement\n   *\n   * @param subscriberCredentials Subscriber credentials derived from the subscriberId\n   * @param request               The ZK Receipt credential request\n   * @param expiration            A function that takes a {@link CustomerAwareSubscriptionPaymentProcessor.ReceiptItem}\n   *                              and returns the expiration time of the receipt\n   * @return the requested ZK receipt credential\n   * @throws SubscriptionForbiddenException                      if the subscriber credentials were incorrect\n   * @throws SubscriptionNotFoundException                       if the subscriber did not exist or did not have a\n   *                                                             subscription attached\n   * @throws SubscriptionInvalidArgumentsException               if the receipt credential request failed verification\n   * @throws SubscriptionPaymentRequiredException                if the subscription is in a state does not grant the\n   *                                                             user an entitlement\n   * @throws SubscriptionReceiptRequestedForOpenPaymentException if a receipt was requested while a payment transaction\n   *                                                             was still open\n   * @throws RateLimitExceededException                          if rate-limited\n   */\n  public ReceiptResult createReceiptCredentials(\n      final SubscriberCredentials subscriberCredentials,\n      final SubscriptionController.GetReceiptCredentialsRequest request,\n      final Function<CustomerAwareSubscriptionPaymentProcessor.ReceiptItem, Instant> expiration)\n      throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException, SubscriptionReceiptRequestedForOpenPaymentException {\n    final Subscriptions.Record record = getSubscriber(subscriberCredentials);\n    if (record.subscriptionId == null) {\n      throw new SubscriptionNotFoundException();\n    }\n\n    ReceiptCredentialRequest receiptCredentialRequest;\n    try {\n      receiptCredentialRequest = new ReceiptCredentialRequest(request.receiptCredentialRequest());\n    } catch (InvalidInputException e) {\n      throw new SubscriptionInvalidArgumentsException(\"invalid receipt credential request\", e);\n    }\n\n    final PaymentProvider processor = record.getProcessorCustomer().orElseThrow().processor();\n    final SubscriptionPaymentProcessor manager = getProcessor(processor);\n    final SubscriptionPaymentProcessor.ReceiptItem receipt = manager.getReceiptItem(record.subscriptionId);\n    issuedReceiptsManager\n        .recordIssuance(receipt.itemId(), manager.getProvider(), receiptCredentialRequest, subscriberCredentials.now())\n        .join();\n    ReceiptCredentialResponse receiptCredentialResponse;\n    try {\n      receiptCredentialResponse = zkReceiptOperations.issueReceiptCredential(\n          receiptCredentialRequest,\n          expiration.apply(receipt).getEpochSecond(),\n          receipt.level());\n    } catch (VerificationFailedException e) {\n      throw new SubscriptionInvalidArgumentsException(\"receipt credential request failed verification\", e);\n    }\n    return new ReceiptResult(receiptCredentialResponse, receipt, processor);\n  }\n\n  /**\n   * Add a payment method to a customer in a payment processor and update the table.\n   * <p>\n   * If the customer does not exist in the table, a customer is created via the subscriptionPaymentProcessor and added\n   * to the table. Not all payment processors support server-managed customers, so a payment processor that implements\n   * {@link CustomerAwareSubscriptionPaymentProcessor} must be passed in.\n   *\n   * @param subscriberCredentials        Subscriber credentials derived from the subscriberId\n   * @param subscriptionPaymentProcessor A customer-aware payment processor to use. If the subscriber already has a\n   *                                     payment processor, it must match the existing one.\n   * @param clientPlatform               The platform of the client making the request\n   * @param paymentSetupFunction         A function that takes the payment processor and the customer ID and begins\n   *                                     adding a payment method. The function should return something that allows the\n   *                                     client to configure the newly added payment method like a payment method setup\n   *                                     token.\n   * @param <T>                          A payment processor that has a notion of server-managed customers\n   * @param <R>                          The return type of the paymentSetupFunction, which should be used by a client\n   *                                     to configure the newly created payment method\n   * @return The return value of the paymentSetupFunction\n   * @throws SubscriptionForbiddenException         if the subscriber credentials were incorrect\n   * @throws SubscriptionNotFoundException          if the subscriber did not exist or did not have a subscription\n   *                                                attached\n   * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with\n   *                                                the subscriberId\n   */\n  public <T extends CustomerAwareSubscriptionPaymentProcessor, R> R addPaymentMethodToCustomer(\n      final SubscriberCredentials subscriberCredentials,\n      final T subscriptionPaymentProcessor,\n      final ClientPlatform clientPlatform,\n      final BiFunction<T, String, R> paymentSetupFunction)\n      throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionProcessorConflictException {\n\n    Subscriptions.Record record = this.getSubscriber(subscriberCredentials);\n    if (record.getProcessorCustomer().isEmpty()) {\n      final ProcessorCustomer pc = subscriptionPaymentProcessor\n          .createCustomer(subscriberCredentials.subscriberUser(), clientPlatform);\n      record = subscriptions.setProcessorAndCustomerId(record,\n          new ProcessorCustomer(pc.customerId(), subscriptionPaymentProcessor.getProvider()),\n          Instant.now()).join();\n    }\n    final ProcessorCustomer processorCustomer = record.getProcessorCustomer()\n        .orElseThrow(() -> new UncheckedIOException(new IOException(\"processor must now exist\")));\n\n    if (processorCustomer.processor() != subscriptionPaymentProcessor.getProvider()) {\n      throw new SubscriptionProcessorConflictException(\"existing processor does not match\");\n    }\n    return paymentSetupFunction.apply(subscriptionPaymentProcessor, processorCustomer.customerId());\n  }\n\n  public interface LevelTransitionValidator {\n\n    /**\n     * Check is a level update is valid\n     *\n     * @param oldLevel The current level of the subscription\n     * @param newLevel The proposed updated level of the subscription\n     * @return true if the subscription can be changed from oldLevel to newLevel, otherwise false\n     */\n    boolean isTransitionValid(long oldLevel, long newLevel);\n  }\n\n  /**\n   * Update the subscription level in the payment processor and update the table.\n   * <p>\n   * If we don't have an existing subscription, create one in the payment processor and then update the table. If we do\n   * already have a subscription, and it does not match the requested subscription, update it in the payment processor\n   * and then update the table. When an update occurs, this is where a user's recurring charge to a payment method is\n   * created or modified.\n   *\n   * @param subscriberCredentials  Subscriber credentials derived from the subscriberId\n   * @param record                 A subscription record previous read with {@link #getSubscriber}\n   * @param processor              A subscription payment processor with a notion of server-managed customers\n   * @param level                  The desired subscription level\n   * @param currency               The desired currency type for the subscription\n   * @param idempotencyKey         An idempotencyKey that can be used to deduplicate requests within the payment\n   *                               processor\n   * @param subscriptionTemplateId Specifies the product associated with the provided level within the payment\n   *                               processor\n   * @param transitionValidator    A function that checks if the level update is valid\n   * @throws SubscriptionInvalidArgumentsException  if the transitionValidator failed for the level transition, or the\n   *                                                subscription could not be created because the payment provider\n   *                                                requires additional action, or there was a failure because an\n   *                                                idempotency key was reused on a * modified request\n   * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with\n   *                                                the subscriber\n   * @throws SubscriptionProcessorException         if there was no payment method on the customer\n   */\n  public void updateSubscriptionLevelForCustomer(\n      final SubscriberCredentials subscriberCredentials,\n      final Subscriptions.Record record,\n      final CustomerAwareSubscriptionPaymentProcessor processor,\n      final long level,\n      final String currency,\n      final String idempotencyKey,\n      final String subscriptionTemplateId,\n      final LevelTransitionValidator transitionValidator)\n      throws SubscriptionInvalidArgumentsException, SubscriptionProcessorConflictException, SubscriptionProcessorException {\n\n    if (record.subscriptionId != null) {\n      // we already have a subscription in our records so let's check the level and currency,\n      // and only change it if needed\n      final Object subscription = processor.getSubscription(record.subscriptionId);\n      final CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency existingLevelAndCurrency =\n          processor.getLevelAndCurrencyForSubscription(subscription);\n      final CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency desiredLevelAndCurrency =\n          new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(level, currency.toLowerCase(Locale.ROOT));\n      if (existingLevelAndCurrency.equals(desiredLevelAndCurrency)) {\n        return;\n      }\n      if (!transitionValidator.isTransitionValid(existingLevelAndCurrency.level(), level)) {\n        throw new SubscriptionInvalidLevelException();\n      }\n      final CustomerAwareSubscriptionPaymentProcessor.SubscriptionId updatedSubscriptionId =\n          processor.updateSubscription(subscription, subscriptionTemplateId, level, idempotencyKey);\n\n      subscriptions.subscriptionLevelChanged(subscriberCredentials.subscriberUser(),\n          subscriberCredentials.now(),\n          level,\n          updatedSubscriptionId.id()).join();\n    } else {\n      // Otherwise, we don't have a subscription yet so create it and then record the subscription id\n      long lastSubscriptionCreatedAt = record.subscriptionCreatedAt != null\n          ? record.subscriptionCreatedAt.getEpochSecond()\n          : 0;\n\n      final CustomerAwareSubscriptionPaymentProcessor.SubscriptionId subscription = processor.createSubscription(\n          record.processorCustomer.customerId(),\n          subscriptionTemplateId,\n          level,\n          lastSubscriptionCreatedAt);\n      subscriptions.subscriptionCreated(\n          subscriberCredentials.subscriberUser(), subscription.id(), subscriberCredentials.now(), level);\n\n    }\n  }\n\n  /**\n   * Check the provided play billing purchase token and write it the subscriptions table if is valid.\n   *\n   * @param subscriberCredentials    Subscriber credentials derived from the subscriberId\n   * @param googlePlayBillingManager Performs play billing API operations\n   * @param purchaseToken            The client provided purchaseToken that represents a purchased subscription in the\n   *                                 play store\n   * @return the subscription level for the accepted subscription\n   * @throws SubscriptionForbiddenException         if the subscriber credentials were incorrect\n   * @throws SubscriptionNotFoundException          if the subscriber did not exist or did not have a subscription\n   *                                                attached\n   * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with\n   *                                                the subscriberId\n   * @throws SubscriptionPaymentRequiredException   if the subscription is not in a state that grants the user an\n   *                                                entitlement\n   * @throws RateLimitExceededException             if rate-limited\n   */\n  public long updatePlayBillingPurchaseToken(\n      final SubscriberCredentials subscriberCredentials,\n      final GooglePlayBillingManager googlePlayBillingManager,\n      final String purchaseToken)\n      throws SubscriptionProcessorConflictException, SubscriptionForbiddenException, SubscriptionNotFoundException, RateLimitExceededException, SubscriptionPaymentRequiredException {\n\n    // For IAP providers, the subscriptionId and the customerId are both just the purchaseToken. Changes to the\n    // subscription always just result in a new purchaseToken\n    final ProcessorCustomer pc = new ProcessorCustomer(purchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING);\n\n    final Subscriptions.Record record = getSubscriber(subscriberCredentials);\n\n    // Check the record for an existing subscription\n    if (record.processorCustomer != null\n        && record.processorCustomer.processor() != PaymentProvider.GOOGLE_PLAY_BILLING) {\n      throw new SubscriptionProcessorConflictException(\"existing processor does not match\");\n    }\n\n    // If we're replacing an existing purchaseToken, cancel it first\n    if (record.processorCustomer != null && !purchaseToken.equals(record.processorCustomer.customerId())) {\n      googlePlayBillingManager.cancelAllActiveSubscriptions(record.processorCustomer.customerId());\n    }\n\n    // Validating ensures we don't allow a user-determined token that's totally bunk into the subscription manager,\n    // but we don't want to acknowledge it until it's successfully persisted.\n    final GooglePlayBillingManager.ValidatedToken validatedToken = googlePlayBillingManager.validateToken(purchaseToken);\n\n    // Store the valid purchaseToken with the subscriber\n    subscriptions.setIapPurchase(record, pc, purchaseToken, validatedToken.getLevel(), subscriberCredentials.now());\n\n    // Now that the purchaseToken is durable, we can acknowledge it\n    validatedToken.acknowledgePurchase();\n\n    return validatedToken.getLevel();\n  }\n\n  /**\n   * Check the provided app store transactionId and write it the subscriptions table if is valid.\n   *\n   * @param subscriberCredentials Subscriber credentials derived from the subscriberId\n   * @param appleAppStoreManager  Performs app store API operations\n   * @param originalTransactionId The client provided originalTransactionId that represents a purchased subscription in\n   *                              the app store\n   * @return the subscription level for the accepted subscription\n   * @throws SubscriptionForbiddenException         if the subscriber credentials are incorrect\n   * @throws SubscriptionNotFoundException          if the originalTransactionId does not exist\n   * @throws SubscriptionProcessorConflictException if the new payment processor the existing processor associated with\n   *                                                the subscriber\n   * @throws SubscriptionInvalidArgumentsException  if the originalTransactionId is malformed or does not represent a\n   *                                                valid subscription\n   * @throws SubscriptionPaymentRequiredException   if the subscription is not in a state that grants the user an\n   *                                                entitlement\n   * @throws RateLimitExceededException             if rate-limited\n   */\n  public long updateAppStoreTransactionId(\n      final SubscriberCredentials subscriberCredentials,\n      final AppleAppStoreManager appleAppStoreManager,\n      final String originalTransactionId)\n      throws SubscriptionForbiddenException, SubscriptionNotFoundException, SubscriptionProcessorConflictException, SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException {\n\n    final Subscriptions.Record record = getSubscriber(subscriberCredentials);\n    if (record.processorCustomer != null\n        && record.processorCustomer.processor() != PaymentProvider.APPLE_APP_STORE) {\n      throw new SubscriptionProcessorConflictException(\"existing processor does not match\");\n    }\n\n    // For IAP providers, the subscriptionId and the customerId are both just the identifier for the subscription in\n    // the provider (in this case, the originalTransactionId). Changes to the subscription always just result in a new\n    // originalTransactionId\n    final ProcessorCustomer pc = new ProcessorCustomer(originalTransactionId, PaymentProvider.APPLE_APP_STORE);\n\n    final Long level = appleAppStoreManager.validateTransaction(originalTransactionId);\n    subscriptions.setIapPurchase(record, pc, originalTransactionId, level, subscriberCredentials.now()).join();\n    return level;\n  }\n\n  private SubscriptionPaymentProcessor getProcessor(PaymentProvider provider) {\n    return processors.get(provider);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/Subscriptions.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.b;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.n;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.s;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.base.Throwables;\nimport jakarta.ws.rs.ClientErrorException;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValue;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\npublic class Subscriptions {\n\n  private static final Logger logger = LoggerFactory.getLogger(Subscriptions.class);\n\n  private static final int USER_LENGTH = 16;\n  private static final byte[] EMPTY_PROCESSOR = new byte[0];\n\n  public static final String KEY_USER = \"U\";  // B  (Hash Key)\n  public static final String KEY_PASSWORD = \"P\";  // B\n  public static final String KEY_PROCESSOR_ID_CUSTOMER_ID = \"PC\"; // B (GSI Hash Key of `pc_to_u` index)\n  public static final String KEY_CREATED_AT = \"R\";  // N\n  public static final String KEY_SUBSCRIPTION_ID = \"S\";  // S\n  public static final String KEY_SUBSCRIPTION_CREATED_AT = \"T\";  // N\n  public static final String KEY_SUBSCRIPTION_LEVEL = \"L\";\n  public static final String KEY_SUBSCRIPTION_LEVEL_CHANGED_AT = \"V\";  // N\n  public static final String KEY_ACCESSED_AT = \"A\";  // N\n  public static final String KEY_CANCELED_AT = \"B\";  // N\n  public static final String KEY_CURRENT_PERIOD_ENDS_AT = \"D\";  // N\n\n  public static final String INDEX_NAME = \"pc_to_u\";  // Hash Key \"PC\"\n\n  public static class Record {\n\n    public final byte[] user;\n    public final byte[] password;\n    public final Instant createdAt;\n    @VisibleForTesting\n    @Nullable\n    ProcessorCustomer processorCustomer;\n    @Nullable\n    public String subscriptionId;\n    public Instant subscriptionCreatedAt;\n    public Long subscriptionLevel;\n    public Instant subscriptionLevelChangedAt;\n    public Instant accessedAt;\n    public Instant canceledAt;\n    public Instant currentPeriodEndsAt;\n\n    private Record(byte[] user, byte[] password, Instant createdAt) {\n      this.user = checkUserLength(user);\n      this.password = Objects.requireNonNull(password);\n      this.createdAt = Objects.requireNonNull(createdAt);\n    }\n\n    public static Record from(byte[] user, Map<String, AttributeValue> item) {\n      Record record = new Record(\n          user,\n          item.get(KEY_PASSWORD).b().asByteArray(),\n          getInstant(item, KEY_CREATED_AT));\n\n      final Pair<PaymentProvider, String> processorCustomerId = getProcessorAndCustomer(item);\n      if (processorCustomerId != null) {\n        record.processorCustomer = new ProcessorCustomer(processorCustomerId.second(), processorCustomerId.first());\n      }\n      record.subscriptionId = getString(item, KEY_SUBSCRIPTION_ID);\n      record.subscriptionCreatedAt = getInstant(item, KEY_SUBSCRIPTION_CREATED_AT);\n      record.subscriptionLevel = getLong(item, KEY_SUBSCRIPTION_LEVEL);\n      record.subscriptionLevelChangedAt = getInstant(item, KEY_SUBSCRIPTION_LEVEL_CHANGED_AT);\n      record.accessedAt = getInstant(item, KEY_ACCESSED_AT);\n      record.canceledAt = getInstant(item, KEY_CANCELED_AT);\n      record.currentPeriodEndsAt = getInstant(item, KEY_CURRENT_PERIOD_ENDS_AT);\n      return record;\n    }\n\n    public Optional<ProcessorCustomer> getProcessorCustomer() {\n      return Optional.ofNullable(processorCustomer);\n    }\n\n    /**\n     * Extracts the active processor and customer from a single attribute value in the given item.\n     * <p>\n     * Until existing data is migrated, this may return {@code null}.\n     */\n    @Nullable\n    private static Pair<PaymentProvider, String> getProcessorAndCustomer(Map<String, AttributeValue> item) {\n\n      final AttributeValue attributeValue = item.get(KEY_PROCESSOR_ID_CUSTOMER_ID);\n\n      if (attributeValue == null) {\n        // temporarily allow null values\n        return null;\n      }\n\n      final byte[] processorAndCustomerId = attributeValue.b().asByteArray();\n      final byte processorId = processorAndCustomerId[0];\n\n      final PaymentProvider processor = PaymentProvider.forId(processorId);\n      if (processor == null) {\n        throw new IllegalStateException(\"unknown processor id: \" + processorId);\n      }\n\n      final String customerId = new String(processorAndCustomerId, 1, processorAndCustomerId.length - 1,\n          StandardCharsets.UTF_8);\n\n      return new Pair<>(processor, customerId);\n    }\n\n    private static String getString(Map<String, AttributeValue> item, String key) {\n      AttributeValue attributeValue = item.get(key);\n      if (attributeValue == null) {\n        return null;\n      }\n      return attributeValue.s();\n    }\n\n    private static Long getLong(Map<String, AttributeValue> item, String key) {\n      AttributeValue attributeValue = item.get(key);\n      if (attributeValue == null || attributeValue.n() == null) {\n        return null;\n      }\n      return Long.valueOf(attributeValue.n());\n    }\n\n    private static Instant getInstant(Map<String, AttributeValue> item, String key) {\n      AttributeValue attributeValue = item.get(key);\n      if (attributeValue == null || attributeValue.n() == null) {\n        return null;\n      }\n      return Instant.ofEpochSecond(Long.parseLong(attributeValue.n()));\n    }\n  }\n\n  private final String table;\n  private final DynamoDbAsyncClient client;\n\n  public Subscriptions(\n      @Nonnull String table,\n      @Nonnull DynamoDbAsyncClient client) {\n    this.table = Objects.requireNonNull(table);\n    this.client = Objects.requireNonNull(client);\n  }\n\n  /**\n   * Looks in the GSI for a record with the given customer id and returns the user id.\n   */\n  public CompletableFuture<byte[]> getSubscriberUserByProcessorCustomer(ProcessorCustomer processorCustomer) {\n    QueryRequest query = QueryRequest.builder()\n        .tableName(table)\n        .indexName(INDEX_NAME)\n        .keyConditionExpression(\"#processor_customer_id = :processor_customer_id\")\n        .projectionExpression(\"#user\")\n        .expressionAttributeNames(Map.of(\n            \"#processor_customer_id\", KEY_PROCESSOR_ID_CUSTOMER_ID,\n            \"#user\", KEY_USER))\n        .expressionAttributeValues(Map.of(\n            \":processor_customer_id\", b(processorCustomer.toDynamoBytes())))\n        .build();\n    return client.query(query).thenApply(queryResponse -> {\n      int count = queryResponse.count();\n      if (count == 0) {\n        return null;\n      } else if (count > 1) {\n        logger.error(\"expected invariant of 1-1 subscriber-customer violated for customer {} ({})\",\n            processorCustomer.customerId(), processorCustomer.processor());\n        throw new IllegalStateException(\n            \"expected invariant of 1-1 subscriber-customer violated for customer \" + processorCustomer);\n      } else {\n        Map<String, AttributeValue> result = queryResponse.items().getFirst();\n        return result.get(KEY_USER).b().asByteArray();\n      }\n    });\n  }\n\n  public static class GetResult {\n\n    public static final GetResult NOT_STORED = new GetResult(Type.NOT_STORED, null);\n    public static final GetResult PASSWORD_MISMATCH = new GetResult(Type.PASSWORD_MISMATCH, null);\n\n    public enum Type {\n      NOT_STORED,\n      PASSWORD_MISMATCH,\n      FOUND\n    }\n\n    public final Type type;\n    public final Record record;\n\n    private GetResult(Type type, Record record) {\n      this.type = type;\n      this.record = record;\n    }\n\n    public static GetResult found(Record record) {\n      return new GetResult(Type.FOUND, record);\n    }\n  }\n\n  /**\n   * Looks up a record with the given {@code user} and validates the {@code hmac} before returning it.\n   */\n  public CompletableFuture<GetResult> get(byte[] user, byte[] hmac) {\n    return getUser(user).thenApply(getItemResponse -> {\n      if (!getItemResponse.hasItem()) {\n        return GetResult.NOT_STORED;\n      }\n\n      Record record = Record.from(user, getItemResponse.item());\n      if (!MessageDigest.isEqual(hmac, record.password)) {\n        return GetResult.PASSWORD_MISMATCH;\n      }\n      return GetResult.found(record);\n    });\n  }\n\n  private CompletableFuture<GetItemResponse> getUser(byte[] user) {\n    checkUserLength(user);\n\n    GetItemRequest request = GetItemRequest.builder()\n        .consistentRead(Boolean.TRUE)\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(user)))\n        .build();\n\n    return client.getItem(request);\n  }\n\n  public CompletableFuture<Record> create(byte[] user, byte[] password, Instant createdAt) {\n    checkUserLength(user);\n\n    UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(user)))\n        .returnValues(ReturnValue.ALL_NEW)\n        .conditionExpression(\"attribute_not_exists(#user) OR #password = :password\")\n        .updateExpression(\"SET \"\n            + \"#password = if_not_exists(#password, :password), \"\n            + \"#created_at = if_not_exists(#created_at, :created_at), \"\n            + \"#accessed_at = if_not_exists(#accessed_at, :accessed_at)\"\n        )\n        .expressionAttributeNames(Map.of(\n            \"#user\", KEY_USER,\n            \"#password\", KEY_PASSWORD,\n            \"#created_at\", KEY_CREATED_AT,\n            \"#accessed_at\", KEY_ACCESSED_AT)\n        )\n        .expressionAttributeValues(Map.of(\n            \":password\", b(password),\n            \":created_at\", n(createdAt.getEpochSecond()),\n            \":accessed_at\", n(createdAt.getEpochSecond()))\n        )\n        .build();\n    return client.updateItem(request).handle((updateItemResponse, throwable) -> {\n      if (throwable != null) {\n        if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {\n          return null;\n        }\n        Throwables.throwIfUnchecked(throwable);\n        throw new CompletionException(throwable);\n      }\n\n      return Record.from(user, updateItemResponse.attributes());\n    });\n  }\n\n  /**\n   * Sets the processor and customer ID for the given user record.\n   *\n   * @return the user record.\n   */\n  public CompletableFuture<Record> setProcessorAndCustomerId(Record userRecord,\n      ProcessorCustomer activeProcessorCustomer, Instant updatedAt) {\n\n    UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(userRecord.user)))\n        .returnValues(ReturnValue.ALL_NEW)\n        .conditionExpression(\"attribute_not_exists(#processor_customer_id)\")\n        .updateExpression(\"SET \"\n            + \"#processor_customer_id = :processor_customer_id, \"\n            + \"#accessed_at = :accessed_at\"\n        )\n        .expressionAttributeNames(Map.of(\n            \"#accessed_at\", KEY_ACCESSED_AT,\n            \"#processor_customer_id\", KEY_PROCESSOR_ID_CUSTOMER_ID\n        ))\n        .expressionAttributeValues(Map.of(\n            \":accessed_at\", n(updatedAt.getEpochSecond()),\n            \":processor_customer_id\", b(activeProcessorCustomer.toDynamoBytes())\n        )).build();\n\n    return client.updateItem(request)\n        .thenApply(updateItemResponse -> Record.from(userRecord.user, updateItemResponse.attributes()))\n        .exceptionallyCompose(throwable -> {\n          if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {\n            throw new ClientErrorException(Response.Status.CONFLICT);\n          }\n          Throwables.throwIfUnchecked(throwable);\n          throw new CompletionException(throwable);\n        });\n  }\n\n  /**\n   * Associate an IAP subscription with a subscriberId.\n   * <p>\n   * IAP subscriptions do not have a distinction between customerId and subscriptionId, so they should both be set\n   * simultaneously with this method instead of calling {@link #setProcessorAndCustomerId},\n   * {@link #subscriptionCreated}, and {@link #subscriptionLevelChanged}.\n   *\n   * @param record            The record to update\n   * @param processorCustomer The processorCustomer. The processor component must match the existing processor, if the\n   *                          record already has one.\n   * @param subscriptionId    The subscriptionId. For IAP subscriptions, the subscriptionId should match the\n   *                          customerId.\n   * @param level             The corresponding level for this subscription\n   * @param updatedAt         The time of this update\n   * @return A stage that completes once the record has been updated\n   */\n  public CompletableFuture<Void> setIapPurchase(\n      final Record record,\n      final ProcessorCustomer processorCustomer,\n      final String subscriptionId,\n      final long level,\n      final Instant updatedAt) {\n    if (record.processorCustomer != null && record.processorCustomer.processor() != processorCustomer.processor()) {\n      throw new IllegalArgumentException(\"cannot change processor on existing subscription\");\n    }\n    final byte[] oldProcessorCustomerBytes = record.processorCustomer != null\n        ? record.processorCustomer.toDynamoBytes()\n        : EMPTY_PROCESSOR;\n\n    final UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(record.user)))\n        .returnValues(ReturnValue.ALL_NEW)\n        .conditionExpression(\n            \"attribute_not_exists(#processor_customer_id) OR #processor_customer_id = :old_processor_customer_id\")\n        .updateExpression(\"SET \"\n            + \"#processor_customer_id = :processor_customer_id, \"\n            + \"#accessed_at = :accessed_at, \"\n            + \"#subscription_id = :subscription_id, \"\n            + \"#subscription_level = :subscription_level, \"\n            + \"#subscription_created_at = if_not_exists(#subscription_created_at, :subscription_created_at), \"\n            + \"#subscription_level_changed_at = :subscription_level_changed_at \"\n            + \"REMOVE #canceled_at\")\n        .expressionAttributeNames(Map.of(\n            \"#processor_customer_id\", KEY_PROCESSOR_ID_CUSTOMER_ID,\n            \"#accessed_at\", KEY_ACCESSED_AT,\n            \"#subscription_id\", KEY_SUBSCRIPTION_ID,\n            \"#subscription_level\", KEY_SUBSCRIPTION_LEVEL,\n            \"#subscription_created_at\", KEY_SUBSCRIPTION_CREATED_AT,\n            \"#subscription_level_changed_at\", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT,\n            \"#canceled_at\", KEY_CANCELED_AT))\n        .expressionAttributeValues(Map.of(\n            \":accessed_at\", n(updatedAt.getEpochSecond()),\n            \":processor_customer_id\", b(processorCustomer.toDynamoBytes()),\n            \":old_processor_customer_id\", b(oldProcessorCustomerBytes),\n            \":subscription_id\", s(subscriptionId),\n            \":subscription_level\", n(level),\n            \":subscription_created_at\", n(updatedAt.getEpochSecond()),\n            \":subscription_level_changed_at\", n(updatedAt.getEpochSecond())))\n        .build();\n\n    return client.updateItem(request)\n        .exceptionallyCompose(throwable -> {\n          if (Throwables.getRootCause(throwable) instanceof ConditionalCheckFailedException) {\n            throw new ClientErrorException(Response.Status.CONFLICT);\n          }\n          Throwables.throwIfUnchecked(throwable);\n          throw new CompletionException(throwable);\n        })\n        .thenRun(Util.NOOP);\n  }\n\n  public CompletableFuture<Void> accessedAt(byte[] user, Instant accessedAt) {\n    checkUserLength(user);\n\n    UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(user)))\n        .returnValues(ReturnValue.NONE)\n        .updateExpression(\"SET #accessed_at = :accessed_at\")\n        .expressionAttributeNames(Map.of(\"#accessed_at\", KEY_ACCESSED_AT))\n        .expressionAttributeValues(Map.of(\":accessed_at\", n(accessedAt.getEpochSecond())))\n        .build();\n    return client.updateItem(request).thenApply(updateItemResponse -> null);\n  }\n\n  public CompletableFuture<Void> setCanceledAt(byte[] user, Instant canceledAt) {\n    checkUserLength(user);\n\n    UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(user)))\n        .returnValues(ReturnValue.NONE)\n        .updateExpression(\"SET \"\n            + \"#accessed_at = :accessed_at, \"\n            + \"#canceled_at = :canceled_at \"\n            + \"REMOVE #subscription_id\")\n        .expressionAttributeNames(Map.of(\n            \"#accessed_at\", KEY_ACCESSED_AT,\n            \"#canceled_at\", KEY_CANCELED_AT,\n            \"#subscription_id\", KEY_SUBSCRIPTION_ID))\n        .expressionAttributeValues(Map.of(\n            \":accessed_at\", n(canceledAt.getEpochSecond()),\n            \":canceled_at\", n(canceledAt.getEpochSecond())))\n        .build();\n    return client.updateItem(request).thenApply(updateItemResponse -> null);\n  }\n\n  public CompletableFuture<Void> subscriptionCreated(\n      byte[] user, String subscriptionId, Instant subscriptionCreatedAt, long level) {\n    checkUserLength(user);\n\n    UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(user)))\n        .returnValues(ReturnValue.NONE)\n        .updateExpression(\"SET \"\n            + \"#accessed_at = :accessed_at, \"\n            + \"#subscription_id = :subscription_id, \"\n            + \"#subscription_created_at = :subscription_created_at, \"\n            + \"#subscription_level = :subscription_level, \"\n            + \"#subscription_level_changed_at = :subscription_level_changed_at \"\n            + \"REMOVE #canceled_at\")\n        .expressionAttributeNames(Map.of(\n            \"#accessed_at\", KEY_ACCESSED_AT,\n            \"#subscription_id\", KEY_SUBSCRIPTION_ID,\n            \"#subscription_created_at\", KEY_SUBSCRIPTION_CREATED_AT,\n            \"#subscription_level\", KEY_SUBSCRIPTION_LEVEL,\n            \"#subscription_level_changed_at\", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT,\n            \"#canceled_at\", KEY_CANCELED_AT))\n        .expressionAttributeValues(Map.of(\n            \":accessed_at\", n(subscriptionCreatedAt.getEpochSecond()),\n            \":subscription_id\", s(subscriptionId),\n            \":subscription_created_at\", n(subscriptionCreatedAt.getEpochSecond()),\n            \":subscription_level\", n(level),\n            \":subscription_level_changed_at\", n(subscriptionCreatedAt.getEpochSecond())))\n        .build();\n    return client.updateItem(request).thenApply(updateItemResponse -> null);\n  }\n\n  public CompletableFuture<Void> subscriptionLevelChanged(\n      byte[] user, Instant subscriptionLevelChangedAt, long level, String subscriptionId) {\n    checkUserLength(user);\n\n    UpdateItemRequest request = UpdateItemRequest.builder()\n        .tableName(table)\n        .key(Map.of(KEY_USER, b(user)))\n        .returnValues(ReturnValue.NONE)\n        .updateExpression(\"SET \"\n            + \"#accessed_at = :accessed_at, \"\n            + \"#subscription_id = :subscription_id, \"\n            + \"#subscription_level = :subscription_level, \"\n            + \"#subscription_level_changed_at = :subscription_level_changed_at \"\n            + \"REMOVE #canceled_at\")\n        .expressionAttributeNames(Map.of(\n            \"#accessed_at\", KEY_ACCESSED_AT,\n            \"#subscription_id\", KEY_SUBSCRIPTION_ID,\n            \"#subscription_level\", KEY_SUBSCRIPTION_LEVEL,\n            \"#subscription_level_changed_at\", KEY_SUBSCRIPTION_LEVEL_CHANGED_AT,\n            \"#canceled_at\", KEY_CANCELED_AT))\n        .expressionAttributeValues(Map.of(\n            \":accessed_at\", n(subscriptionLevelChangedAt.getEpochSecond()),\n            \":subscription_id\", s(subscriptionId),\n            \":subscription_level\", n(level),\n            \":subscription_level_changed_at\", n(subscriptionLevelChangedAt.getEpochSecond())))\n        .build();\n    return client.updateItem(request).thenApply(updateItemResponse -> null);\n  }\n\n  private static byte[] checkUserLength(final byte[] user) {\n    if (user.length != USER_LENGTH) {\n      throw new IllegalArgumentException(\"user length is wrong; expected \" + USER_LENGTH + \"; was \" + user.length);\n    }\n    return user;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameHashNotAvailableException.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class UsernameHashNotAvailableException extends Exception {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/UsernameReservationNotFoundException.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class UsernameReservationNotFoundException extends Exception {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessionManager.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\n\npublic class VerificationSessionManager {\n\n  private final VerificationSessions verificationSessions;\n\n  public VerificationSessionManager(final VerificationSessions verificationSessions) {\n    this.verificationSessions = verificationSessions;\n  }\n\n  public CompletableFuture<Void> insert(final VerificationSession verificationSession) {\n    return verificationSessions.insert(verificationSession.sessionId(), verificationSession);\n  }\n\n  public CompletableFuture<Void> update(final VerificationSession verificationSession) {\n    return verificationSessions.update(verificationSession.sessionId(), verificationSession);\n  }\n\n  public CompletableFuture<Optional<VerificationSession>> findForId(final String encodedSessionId) {\n    return verificationSessions.findForKey(encodedSessionId);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/VerificationSessions.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.time.Clock;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\npublic class VerificationSessions extends SerializedExpireableJsonDynamoStore<VerificationSession> {\n\n  public VerificationSessions(final DynamoDbAsyncClient dynamoDbClient, final String tableName, final Clock clock) {\n    super(dynamoDbClient, tableName, clock);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/VersionedProfile.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.ByteArrayBase64WithPaddingAdapter;\n\npublic record VersionedProfile (String version,\n                                @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n                                @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n                                byte[] name,\n\n                                @Nullable\n                                String avatar,\n\n                                @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n                                @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n                                byte[] aboutEmoji,\n\n                                @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n                                @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n                                byte[] about,\n\n                                @JsonSerialize(using = ByteArrayBase64WithPaddingAdapter.Serializing.class)\n                                @JsonDeserialize(using = ByteArrayBase64WithPaddingAdapter.Deserializing.class)\n                                byte[] paymentAddress,\n\n                                @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                                @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                byte[] phoneNumberSharing,\n\n                                @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                                @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                byte[] commitment) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.webauthn4j.appattest.DeviceCheckManager;\nimport com.webauthn4j.appattest.authenticator.DCAppleDevice;\nimport com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;\nimport com.webauthn4j.appattest.data.DCAssertionParameters;\nimport com.webauthn4j.appattest.data.DCAssertionRequest;\nimport com.webauthn4j.appattest.data.DCAttestationData;\nimport com.webauthn4j.appattest.data.DCAttestationParameters;\nimport com.webauthn4j.appattest.data.DCAttestationRequest;\nimport com.webauthn4j.appattest.server.DCServerProperty;\nimport com.webauthn4j.data.attestation.AttestationObject;\nimport com.webauthn4j.data.client.challenge.DefaultChallenge;\nimport com.webauthn4j.verifier.exception.MaliciousCounterValueException;\nimport com.webauthn4j.verifier.exception.VerificationException;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.SetArgs;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.SecureRandom;\nimport java.time.Duration;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\n/**\n * Register Apple DeviceCheck App Attestations and verify the corresponding assertions.\n *\n * @see <a href=\"https://developer.apple.com/documentation/devicecheck/establishing-your-app-s-integrity\">...</a>\n * @see <a\n * href=\"https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server\">...</a>\n */\npublic class AppleDeviceCheckManager {\n\n  private static final Logger logger = LoggerFactory.getLogger(AppleDeviceCheckManager.class);\n\n  private static final SecureRandom SECURE_RANDOM = new SecureRandom();\n  private static final int CHALLENGE_LENGTH = 16;\n\n  // How long issued challenges last in redis\n  @VisibleForTesting\n  static final Duration CHALLENGE_TTL = Duration.ofHours(1);\n\n  // How many distinct device keys we're willing to accept for a single Account\n  @VisibleForTesting\n  static final int MAX_DEVICE_KEYS = 100;\n\n  private final AppleDeviceChecks appleDeviceChecks;\n  private final FaultTolerantRedisClusterClient redisClient;\n  private final DeviceCheckManager deviceCheckManager;\n  private final String teamId;\n  private final String bundleId;\n\n  private static final String RETRY_NAME = ResilienceUtil.name(AppleDeviceCheckManager.class);\n\n  public AppleDeviceCheckManager(\n      AppleDeviceChecks appleDeviceChecks,\n      FaultTolerantRedisClusterClient redisClient,\n      DeviceCheckManager deviceCheckManager,\n      String teamId,\n      String bundleId) {\n    this.appleDeviceChecks = appleDeviceChecks;\n    this.redisClient = redisClient;\n    this.deviceCheckManager = deviceCheckManager;\n    this.teamId = teamId;\n    this.bundleId = bundleId;\n  }\n\n  /**\n   * Attestations and assertions have independent challenges.\n   * <p>\n   * Challenges are tied to their purpose to mitigate replay attacks\n   */\n  public enum ChallengeType {\n    ATTEST,\n    ASSERT_BACKUP_REDEMPTION\n  }\n\n  /**\n   * Register a key and attestation data for an account\n   *\n   * @param account    The account this keyId should be associated with\n   * @param keyId      The device's keyId\n   * @param attestBlob The device's attestation\n   * @throws ChallengeNotFoundException             No issued challenge found for the account\n   * @throws DeviceCheckVerificationFailedException The provided attestation could not be verified\n   * @throws TooManyKeysException                   The account has registered too many unique keyIds\n   * @throws DuplicatePublicKeyException            The keyId has already been used with another account\n   */\n  public void registerAttestation(final Account account, final byte[] keyId, final byte[] attestBlob)\n      throws TooManyKeysException, ChallengeNotFoundException, DeviceCheckVerificationFailedException, DuplicatePublicKeyException {\n\n    final List<byte[]> existingKeys = appleDeviceChecks.keyIds(account);\n    if (existingKeys.stream().anyMatch(x -> MessageDigest.isEqual(x, keyId))) {\n      // We already have the key, so no need to continue\n      return;\n    }\n\n    if (existingKeys.size() >= MAX_DEVICE_KEYS) {\n      // This is best-effort, since we don't check the number of keys transactionally. We just don't want to allow\n      // the keys for an account to grow arbitrarily large\n      throw new TooManyKeysException();\n    }\n\n    final String redisChallengeKey = challengeKey(ChallengeType.ATTEST, account.getUuid());\n\n    @Nullable final String challenge = ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeSupplier(() -> redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey)));\n\n    if (challenge == null) {\n      throw new ChallengeNotFoundException();\n    }\n\n    final byte[] clientDataHash = sha256(challenge.getBytes(StandardCharsets.UTF_8));\n    final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestBlob, clientDataHash);\n    final DCAttestationData dcAttestationData;\n    try {\n      dcAttestationData = deviceCheckManager.validate(dcAttestationRequest,\n          new DCAttestationParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(challenge))));\n    } catch (VerificationException e) {\n      logger.info(\"Failed to verify attestation\", e);\n      throw new DeviceCheckVerificationFailedException(e);\n    }\n    appleDeviceChecks.storeAttestation(account, keyId, createDcAppleDevice(dcAttestationData));\n    removeChallenge(redisChallengeKey);\n  }\n\n  private static DCAppleDeviceImpl createDcAppleDevice(final DCAttestationData dcAttestationData) {\n    final AttestationObject attestationObject = dcAttestationData.getAttestationObject();\n    if (attestationObject == null || attestationObject.getAuthenticatorData().getAttestedCredentialData() == null) {\n      throw new IllegalArgumentException(\"Signed and validated attestation missing expected data\");\n    }\n    return new DCAppleDeviceImpl(\n        attestationObject.getAuthenticatorData().getAttestedCredentialData(),\n        attestationObject.getAttestationStatement(),\n        attestationObject.getAuthenticatorData().getSignCount(),\n        attestationObject.getAuthenticatorData().getExtensions());\n  }\n\n  /**\n   * Validate that a request came from an Apple device signed with a key already registered to the account\n   *\n   * @param account       The requesting account\n   * @param keyId         The key used to generate the assertion\n   * @param challengeType The {@link ChallengeType} of the assertion, which must match the challenge returned by\n   *                      {@link AppleDeviceCheckManager#createChallenge}\n   * @param challenge     A challenge that was embedded in the supplied request\n   * @param request       The request that the client asserted\n   * @param assertion     The assertion from the client\n   * @throws DeviceCheckKeyIdNotFoundException      The provided keyId was never registered with the account\n   * @throws ChallengeNotFoundException             No issued challenge found for the account\n   * @throws DeviceCheckVerificationFailedException The provided assertion could not be verified\n   * @throws RequestReuseException                  The signed counter on the assertion was lower than a previously\n   *                                                received assertion\n   */\n  public void validateAssert(\n      final Account account,\n      final byte[] keyId,\n      final ChallengeType challengeType,\n      final String challenge,\n      final byte[] request,\n      final byte[] assertion)\n      throws ChallengeNotFoundException, DeviceCheckVerificationFailedException, DeviceCheckKeyIdNotFoundException, RequestReuseException {\n\n    final String redisChallengeKey = challengeKey(challengeType, account.getUuid());\n    @Nullable final String storedChallenge = ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n            .executeSupplier(() -> redisClient.withCluster(cluster -> cluster.sync().get(redisChallengeKey)));\n\n    if (storedChallenge == null) {\n      throw new ChallengeNotFoundException();\n    }\n    if (!MessageDigest.isEqual(\n        storedChallenge.getBytes(StandardCharsets.UTF_8),\n        challenge.getBytes(StandardCharsets.UTF_8))) {\n      throw new DeviceCheckVerificationFailedException(\"Provided challenge did not match stored challenge\");\n    }\n\n    final DCAppleDevice appleDevice = appleDeviceChecks.lookup(account, keyId)\n        .orElseThrow(DeviceCheckKeyIdNotFoundException::new);\n    final DCAssertionRequest dcAssertionRequest = new DCAssertionRequest(keyId, assertion, sha256(request));\n    final DCAssertionParameters dcAssertionParameters =\n        new DCAssertionParameters(new DCServerProperty(teamId, bundleId, new DefaultChallenge(request)), appleDevice);\n\n    try {\n      deviceCheckManager.validate(dcAssertionRequest, dcAssertionParameters);\n    } catch (MaliciousCounterValueException e) {\n      // We will only accept assertions that have a sign count greater than the last assertion we saw. Step 5 here:\n      // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-assertion\n      throw new RequestReuseException(\"Sign count from request less than stored sign count\");\n    } catch (VerificationException e) {\n      logger.info(\"Failed to validate DeviceCheck assert\", e);\n      throw new DeviceCheckVerificationFailedException(e);\n    }\n\n    // Store the updated sign count, so we can check the next assertion (step 6)\n    appleDeviceChecks.updateCounter(account, keyId, appleDevice.getCounter());\n    removeChallenge(redisChallengeKey);\n  }\n\n  /**\n   * Create a challenge that can be used in an attestation or assertion\n   *\n   * @param challengeType The type of the challenge\n   * @param account       The account that will use the challenge\n   * @return The challenge to be included as part of an attestation or assertion\n   */\n  public String createChallenge(final ChallengeType challengeType, final Account account) {\n    final UUID accountIdentifier = account.getUuid();\n\n    final String challengeKey = challengeKey(challengeType, accountIdentifier);\n    return ResilienceUtil.getGeneralRedisRetry(RETRY_NAME)\n        .executeSupplier(() -> redisClient.withCluster(cluster -> {\n          final RedisAdvancedClusterCommands<String, String> commands = cluster.sync();\n\n          // Sets the new challenge if and only if there isn't already one stored for the challenge key; returns the existing\n          // challenge if present or null if no challenge was previously set.\n          final String proposedChallenge = generateChallenge();\n          @Nullable final String existingChallenge =\n              commands.setGet(challengeKey, proposedChallenge, SetArgs.Builder.nx().ex(CHALLENGE_TTL));\n\n          if (existingChallenge != null) {\n            // If the key was already set, make sure we extend the TTL. This is racy because the key could disappear or have\n            // been updated since the get returned, but it's fine. In the former case, this is a noop. In the latter\n            // case we may slightly extend the TTL from after it was set, but that's also no big deal.\n            commands.expire(challengeKey, CHALLENGE_TTL);\n          }\n\n          return existingChallenge != null ? existingChallenge : proposedChallenge;\n        }));\n  }\n\n  private void removeChallenge(final String challengeKey) {\n    try {\n      redisClient.useCluster(cluster -> cluster.sync().del(challengeKey));\n    } catch (RedisException e) {\n      logger.debug(\"failed to remove attest challenge from redis, will let it expire via TTL\");\n    }\n  }\n\n  @VisibleForTesting\n  static String challengeKey(final ChallengeType challengeType, final UUID accountIdentifier) {\n    return \"device_check::\" + challengeType.name() + \"::\" + accountIdentifier.toString();\n  }\n\n  private static String generateChallenge() {\n    final byte[] challenge = new byte[CHALLENGE_LENGTH];\n    SECURE_RANDOM.nextBytes(challenge);\n    return Base64.getUrlEncoder().withoutPadding().encodeToString(challenge);\n  }\n\n  private static byte[] sha256(byte[] bytes) {\n    try {\n      return MessageDigest.getInstance(\"SHA-256\").digest(bytes);\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(\"All Java implementations are required to support SHA-256\", e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckTrustAnchor.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\nimport com.webauthn4j.anchor.TrustAnchorRepository;\nimport com.webauthn4j.data.attestation.authenticator.AAGUID;\nimport com.webauthn4j.util.CertificateUtil;\nimport com.webauthn4j.verifier.attestation.trustworthiness.certpath.DefaultCertPathTrustworthinessVerifier;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.UncheckedIOException;\nimport java.security.cert.TrustAnchor;\nimport java.security.cert.X509Certificate;\nimport java.util.Collections;\nimport java.util.Set;\n\n/**\n * A {@link com.webauthn4j.verifier.attestation.trustworthiness.certpath.CertPathTrustworthinessVerifier} for validating\n * x5 certificate chains, pinned with apple's well known static device check root certificate.\n */\npublic class AppleDeviceCheckTrustAnchor extends DefaultCertPathTrustworthinessVerifier {\n\n  // The location of a PEM encoded certificate for Apple's DeviceCheck root certificate\n  // https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem\n  private static String APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME = \"apple_device_check.pem\";\n\n  public AppleDeviceCheckTrustAnchor() {\n    super(new StaticTrustAnchorRepository(loadDeviceCheckRootCert()));\n  }\n\n  private record StaticTrustAnchorRepository(X509Certificate rootCert) implements TrustAnchorRepository {\n\n    @Override\n    public Set<TrustAnchor> find(final AAGUID aaguid) {\n      return Collections.singleton(new TrustAnchor(rootCert, null));\n    }\n\n    @Override\n    public Set<TrustAnchor> find(final byte[] attestationCertificateKeyIdentifier) {\n      return Collections.singleton(new TrustAnchor(rootCert, null));\n    }\n  }\n\n  private static X509Certificate loadDeviceCheckRootCert() {\n    try (InputStream stream = AppleDeviceCheckTrustAnchor.class.getResourceAsStream(\n        APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME)) {\n      if (stream == null) {\n        throw new IllegalArgumentException(\"Resource not found: \" + APPLE_DEVICE_CHECK_ROOT_CERT_RESOURCE_NAME);\n      }\n      return CertificateUtil.generateX509Certificate(stream);\n    } catch (IOException e) {\n      throw new UncheckedIOException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecks.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeInfo;\nimport com.webauthn4j.appattest.authenticator.DCAppleDevice;\nimport com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;\nimport com.webauthn4j.appattest.data.attestation.statement.AppleAppAttestAttestationStatement;\nimport com.webauthn4j.converter.AttestedCredentialDataConverter;\nimport com.webauthn4j.converter.util.ObjectConverter;\nimport com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;\nimport com.webauthn4j.data.attestation.statement.AttestationStatement;\nimport com.webauthn4j.data.extension.authenticator.AuthenticationExtensionsAuthenticatorOutputs;\nimport com.webauthn4j.data.extension.authenticator.RegistrationExtensionAuthenticatorOutput;\nimport java.security.PublicKey;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.CancellationReason;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.Put;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\n/**\n * Store DeviceCheck attestations along with accounts, so they can be retrieved later to validate assertions.\n * <p>\n * Callers associate a keyId and attestation with an account, and then use the corresponding key to make potentially\n * many attested requests (assertions). Each assertion increments the counter associated with the key.\n * <p>\n * Callers can associate more than one keyId/attestation with an account (for example, they may get a new device).\n * However, each keyId must only be registered for a single account.\n *\n * @implNote We use a second table keyed on the public key to enforce uniqueness.\n */\npublic class AppleDeviceChecks {\n\n  // B: uuid, primary key\n  public static final String KEY_ACCOUNT_UUID = \"U\";\n  // B: key id, sort key. The key id is the SHA256 of the X9.62 uncompressed point format of the public key\n  public static final String KEY_PUBLIC_KEY_ID = \"KID\";\n  // N: counter, the number of asserts signed by the public key (updates on every assert)\n  private static final String ATTR_COUNTER = \"C\";\n  // B: attestedCredentialData\n  private static final String ATTR_CRED_DATA = \"CD\";\n  // B: attestationStatement, CBOR\n  private static final String ATTR_STATEMENT = \"S\";\n  // B: authenticatorExtensions, CBOR\n  private static final String ATTR_AUTHENTICATOR_EXTENSIONS = \"AE\";\n\n  // B: public key bytes, primary key for the public key table\n  public static final String KEY_PUBLIC_KEY = \"PK\";\n\n  private static final String CONDITIONAL_CHECK_FAILED = \"ConditionalCheckFailed\";\n\n  private final DynamoDbClient dynamoDbClient;\n  private final String deviceCheckTableName;\n  private final String publicKeyConstraintTableName;\n  private final ObjectConverter objectConverter;\n\n  public AppleDeviceChecks(\n      final DynamoDbClient dynamoDbClient,\n      final ObjectConverter objectConverter,\n      final String deviceCheckTableName,\n      final String publicKeyConstraintTableName) {\n    this.dynamoDbClient = dynamoDbClient;\n    this.objectConverter = objectConverter;\n    this.deviceCheckTableName = deviceCheckTableName;\n    this.publicKeyConstraintTableName = publicKeyConstraintTableName;\n  }\n\n  /**\n   * Retrieve DeviceCheck keyIds\n   *\n   * @param account The account to fetch keyIds for\n   * @return A list of keyIds currently associated with the account\n   */\n  public List<byte[]> keyIds(final Account account) {\n    return dynamoDbClient.queryPaginator(QueryRequest.builder()\n            .tableName(deviceCheckTableName)\n            .keyConditionExpression(\"#aci = :aci\")\n            .expressionAttributeNames(Map.of(\"#aci\", KEY_ACCOUNT_UUID, \"#kid\", KEY_PUBLIC_KEY_ID))\n            .expressionAttributeValues(Map.of(\":aci\", AttributeValues.fromUUID(account.getUuid())))\n            .projectionExpression(\"#kid\")\n            .build())\n        .items()\n        .stream()\n        .flatMap(item -> getByteArray(item, KEY_PUBLIC_KEY_ID).stream())\n        .toList();\n  }\n\n  /**\n   * Register an attestation for a keyId with an account. The attestation can later be retrieved via {@link #lookup}. If\n   * the provided keyId is already registered with the account and is more up to date, no update will occur and this\n   * method will return false.\n   *\n   * @param account     The account to store the registration\n   * @param keyId       The keyId to associate with the account\n   * @param appleDevice Attestation information to store\n   * @return true if the attestation was stored, false if the keyId already had an attestation\n   * @throws DuplicatePublicKeyException If a different account has already registered this public key\n   */\n  public boolean storeAttestation(final Account account, final byte[] keyId, final DCAppleDevice appleDevice)\n      throws DuplicatePublicKeyException {\n    try {\n      dynamoDbClient.transactWriteItems(TransactWriteItemsRequest.builder().transactItems(\n\n          // Register the public key and associated data with the account\n          TransactWriteItem.builder().put(Put.builder()\n              .tableName(deviceCheckTableName)\n              .item(toItem(account, keyId, appleDevice))\n              // The caller should have done a non-transactional read to verify we didn't already have this keyId, but a\n              // race is possible. It's fine to wipe out an existing key (should be identical), as long as we don't\n              // lower the signed count associated with the key.\n              .conditionExpression(\"attribute_not_exists(#counter) OR #counter <= :counter\")\n              .expressionAttributeNames(Map.of(\"#counter\", ATTR_COUNTER))\n              .expressionAttributeValues(Map.of(\":counter\", AttributeValues.n(appleDevice.getCounter())))\n              .build()).build(),\n\n          // Enforce uniqueness on the supplied public key\n          TransactWriteItem.builder().put(Put.builder()\n              .tableName(publicKeyConstraintTableName)\n              .item(Map.of(\n                  KEY_PUBLIC_KEY, AttributeValues.fromByteArray(extractPublicKey(appleDevice).getEncoded()),\n                  KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())\n              ))\n              // Enforces public key uniqueness, as described in https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Store-the-public-key-and-receipt\n              .conditionExpression(\"attribute_not_exists(#pk) or #aci = :aci\")\n              .expressionAttributeNames(Map.of(\"#aci\", KEY_ACCOUNT_UUID, \"#pk\", KEY_PUBLIC_KEY))\n              .expressionAttributeValues(Map.of(\":aci\", AttributeValues.fromUUID(account.getUuid())))\n              .build()).build()).build());\n      return true;\n\n    } catch (TransactionCanceledException e) {\n      final CancellationReason updateCancelReason = e.cancellationReasons().get(0);\n      if (conditionalCheckFailed(updateCancelReason)) {\n        // The provided attestation is older than the one we already have stored\n        return false;\n      }\n      final CancellationReason publicKeyCancelReason = e.cancellationReasons().get(1);\n      if (conditionalCheckFailed(publicKeyCancelReason)) {\n        throw new DuplicatePublicKeyException();\n      }\n      throw e;\n    }\n  }\n\n  /**\n   * Retrieve the device attestation information previous registered with the account\n   *\n   * @param account The account that registered the keyId\n   * @param keyId   The keyId that was registered\n   * @return Device attestation information that can be used to validate an assertion\n   */\n  public Optional<DCAppleDevice> lookup(final Account account, final byte[] keyId) {\n    final GetItemResponse item = dynamoDbClient.getItem(GetItemRequest.builder()\n        .tableName(deviceCheckTableName)\n        .key(Map.of(\n            KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),\n            KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId))).build());\n    return item.hasItem() ? Optional.of(fromItem(item.item())) : Optional.empty();\n  }\n\n  /**\n   * Attempt to increase the signed counter to the newCounter value. This method enforces that the counter increases\n   * monotonically, if the new value is less than the existing counter, no update occurs and the method returns false.\n   *\n   * @param account    The account the keyId is registered to\n   * @param keyId      The keyId to update\n   * @param newCounter The new counter value\n   * @return true if the counter was updated, false if the stored counter was larger than newCounter\n   */\n  public boolean updateCounter(final Account account, final byte[] keyId, final long newCounter) {\n    try {\n      dynamoDbClient.updateItem(UpdateItemRequest.builder()\n          .tableName(deviceCheckTableName)\n          .key(Map.of(\n              KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),\n              KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId)))\n          .expressionAttributeNames(Map.of(\"#counter\", ATTR_COUNTER))\n          .expressionAttributeValues(Map.of(\":counter\", AttributeValues.n(newCounter)))\n          .updateExpression(\"SET #counter = :counter\")\n          // someone could possibly race with us to update the counter. No big deal, but we shouldn't decrease the\n          // current counter\n          .conditionExpression(\"#counter <= :counter\").build());\n      return true;\n    } catch (ConditionalCheckFailedException e) {\n      // We failed to increment the counter because it has already moved forward\n      return false;\n    }\n  }\n\n  private Map<String, AttributeValue> toItem(final Account account, final byte[] keyId, DCAppleDevice appleDevice) {\n    // Serialize the various data members, see: https://webauthn4j.github.io/webauthn4j/en/#deep-dive\n    final AttestedCredentialDataConverter attestedCredentialDataConverter =\n        new AttestedCredentialDataConverter(objectConverter);\n    final byte[] attestedCredentialData =\n        attestedCredentialDataConverter.convert(appleDevice.getAttestedCredentialData());\n    final byte[] attestationStatement = objectConverter.getCborConverter()\n        .writeValueAsBytes(new AttestationStatementEnvelope(appleDevice.getAttestationStatement()));\n    final long counter = appleDevice.getCounter();\n    final byte[] authenticatorExtensions = objectConverter.getCborConverter()\n        .writeValueAsBytes(appleDevice.getAuthenticatorExtensions());\n\n    return Map.of(\n        KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),\n        KEY_PUBLIC_KEY_ID, AttributeValues.fromByteArray(keyId),\n        ATTR_CRED_DATA, AttributeValues.fromByteArray(attestedCredentialData),\n        ATTR_STATEMENT, AttributeValues.fromByteArray(attestationStatement),\n        ATTR_AUTHENTICATOR_EXTENSIONS, AttributeValues.fromByteArray(authenticatorExtensions),\n        ATTR_COUNTER, AttributeValues.n(counter));\n  }\n\n  private DCAppleDevice fromItem(final Map<String, AttributeValue> item) {\n    // Deserialize the fields stored in dynamodb, see: https://webauthn4j.github.io/webauthn4j/en/#deep-dive\n\n    final AttestedCredentialDataConverter attestedCredentialDataConverter =\n        new AttestedCredentialDataConverter(objectConverter);\n\n    final AttestedCredentialData credData = attestedCredentialDataConverter.convert(getByteArray(item, ATTR_CRED_DATA)\n        .orElseThrow(() -> new IllegalStateException(\"Stored device check key missing attestation credential data\")));\n\n    // The attestationStatement is an interface, so we also need to encode enough type information (the format)\n    // so we know how to deserialize the statement. See https://webauthn4j.github.io/webauthn4j/en/#attestationstatement\n    final byte[] serializedStatementEnvelope = getByteArray(item, ATTR_STATEMENT)\n        .orElseThrow(() -> new IllegalStateException(\"Stored device check key missing attestation statement\"));\n    final AttestationStatement statement = Optional.ofNullable(objectConverter.getCborConverter()\n            .readValue(serializedStatementEnvelope, AttestationStatementEnvelope.class))\n        .orElseThrow(() -> new IllegalStateException(\"Stored device check key missing attestation statement\"))\n        .getAttestationStatement();\n\n    final long counter = AttributeValues.getLong(item, ATTR_COUNTER, 0);\n\n    final byte[] serializedExtensions = getByteArray(item, ATTR_AUTHENTICATOR_EXTENSIONS)\n        .orElseThrow(() -> new IllegalStateException(\"Stored device check key missing attestation extensions\"));\n\n    @SuppressWarnings(\"unchecked\") final AuthenticationExtensionsAuthenticatorOutputs<RegistrationExtensionAuthenticatorOutput> extensions = objectConverter.getCborConverter()\n        .readValue(serializedExtensions, AuthenticationExtensionsAuthenticatorOutputs.class);\n\n    return new DCAppleDeviceImpl(credData, statement, counter, extensions);\n  }\n\n  private static PublicKey extractPublicKey(DCAppleDevice appleDevice) {\n    // This is the leaf public key as described here:\n    // https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server#Verify-the-attestation\n    // We know the sha256 of the public key matches the keyId, the apple webauthn verifier validates that. Step 5 here:\n    // https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Walking-through-the-validation-steps\n    final AppleAppAttestAttestationStatement attestationStatement = ((AppleAppAttestAttestationStatement) appleDevice.getAttestationStatement());\n    Objects.requireNonNull(attestationStatement);\n    return attestationStatement.getX5c().getEndEntityAttestationCertificate().getCertificate().getPublicKey();\n  }\n\n\n  private static boolean conditionalCheckFailed(final CancellationReason reason) {\n    return CONDITIONAL_CHECK_FAILED.equals(reason.code());\n  }\n\n  private static Optional<byte[]> getByteArray(Map<String, AttributeValue> item, String key) {\n    return AttributeValues.get(item, key).map(av -> av.b().asByteArray());\n  }\n\n  /**\n   * Wrapper that provides type information when deserializing attestation statements\n   */\n  private static class AttestationStatementEnvelope {\n\n    @JsonProperty(\"attStmt\")\n    @JsonTypeInfo(\n        use = JsonTypeInfo.Id.NAME,\n        include = JsonTypeInfo.As.EXTERNAL_PROPERTY,\n        property = \"fmt\"\n    )\n    private AttestationStatement attestationStatement;\n\n    @JsonCreator\n    public AttestationStatementEnvelope(@JsonProperty(\"attStmt\") AttestationStatement attestationStatement) {\n      this.attestationStatement = attestationStatement;\n    }\n\n    @JsonProperty(\"fmt\")\n    public String getFormat() {\n      return attestationStatement.getFormat();\n    }\n\n    public AttestationStatement getAttestationStatement() {\n      return attestationStatement;\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/ChallengeNotFoundException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\npublic class ChallengeNotFoundException extends Exception {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckKeyIdNotFoundException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\npublic class DeviceCheckKeyIdNotFoundException extends Exception {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckVerificationFailedException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\npublic class DeviceCheckVerificationFailedException extends Exception {\n\n  public DeviceCheckVerificationFailedException(Exception cause) {\n    super(cause);\n  }\n\n  public DeviceCheckVerificationFailedException(String s) {\n    super(s);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/DuplicatePublicKeyException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\npublic class DuplicatePublicKeyException extends Exception {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/RequestReuseException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\npublic class RequestReuseException extends Exception {\n\n  public RequestReuseException(String s) {\n    super(s);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/devicecheck/TooManyKeysException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\npublic class TooManyKeysException extends Exception {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/storage/foundationdb/FoundationDbMessageStore.java",
    "content": "package org.whispersystems.textsecuregcm.storage.foundationdb;\n\nimport com.apple.foundationdb.Database;\nimport com.apple.foundationdb.MutationType;\nimport com.apple.foundationdb.Transaction;\nimport com.apple.foundationdb.subspace.Subspace;\nimport com.apple.foundationdb.tuple.Tuple;\nimport com.apple.foundationdb.tuple.Versionstamp;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.hash.Hashing;\nimport io.dropwizard.util.DataSize;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.Conversions;\n\n/// An implementation of a message store backed by FoundationDB.\n///\n/// @implNote The layout of elements in FoundationDB is as follows:\n/// * messages\n///   * {aci}\n///     * messageAvailableWatch => versionstamp\n///     * {deviceId}\n///       * presence => server_id | last_seen_seconds_since_epoch\n///       * queue\n///         * {versionstamp_1} => envelope_1\n///         * {versionstamp_2} => envelope_2\npublic class FoundationDbMessageStore {\n\n  private final Database[] databases;\n  private final Executor executor;\n  private final Clock clock;\n\n  private static final Subspace MESSAGES_SUBSPACE = new Subspace(Tuple.from(\"M\"));\n  private static final Duration PRESENCE_STALE_THRESHOLD = Duration.ofMinutes(5);\n\n  /// The (approximate) transaction size beyond which we do not add more messages in a transaction. The estimated size\n  /// includes only message payloads (and not key reads/writes) which we assume will dominate the total\n  /// transaction size. Note that the FDB [docs](https://apple.github.io/foundationdb/known-limitations.html) currently\n  /// suggest a limit of 1MB to avoid performance issues, although the hard limit is 10MB\n  private static final long MAX_MESSAGE_CHUNK_SIZE = DataSize.megabytes(1).toBytes();\n\n  /// Result of inserting a message for a particular device\n  ///\n  /// @param versionstamp the versionstamp of the transaction in which this device's message was inserted, empty\n  ///                     otherwise\n  /// @param present      whether the device is online\n  public record InsertResult(Optional<Versionstamp> versionstamp, boolean present) {\n  }\n\n  public FoundationDbMessageStore(final Database[] databases, final Executor executor, final Clock clock) {\n    this.databases = databases;\n    this.executor = executor;\n    this.clock = clock;\n  }\n\n  /// Convenience method for inserting a single recipient message bundle. See [#insert(Map)] for details.\n  ///\n  /// @param aciServiceIdentifier accountId of the recipient\n  /// @param messagesByDeviceId   a map of message envelopes by deviceId to be inserted\n  /// @return a future that yields a map deviceId => the presence state and versionstamp of the transaction in which the\n  /// device's message was inserted (if any)\n  public CompletableFuture<Map<Byte, InsertResult>> insert(final AciServiceIdentifier aciServiceIdentifier,\n      final Map<Byte, MessageProtos.Envelope> messagesByDeviceId) {\n\n    return insert(Map.of(aciServiceIdentifier, messagesByDeviceId))\n        .thenApply(resultsByServiceIdentifier -> {\n          assert resultsByServiceIdentifier.size() == 1;\n\n          return resultsByServiceIdentifier.get(aciServiceIdentifier);\n        });\n  }\n\n  /// Insert a multi-recipient message bundle. Destination ACIs are grouped by shard number. Each shard then starts a\n  /// potentially multi-transaction operation. Messages are inserted in chunks to avoid transaction size limits.\n  ///\n  /// @param messagesByServiceIdentifier a map of accountId to message envelopes by deviceId\n  /// @return a future that yields a map containing the presence states of devices and versionstamps corresponding to\n  /// committed transactions during this operation\n  ///\n  /// @implNote All messages belonging to the same recipient are always committed in the same transaction for\n  /// simplicity. A message may not be inserted if the device is not present (as determined from its presence key) and\n  /// the message is ephemeral. If no messages in a transaction end up being inserted, we won't commit it since the\n  /// transaction was read-only. As such, no corresponding versionstamp is generated.\n  public CompletableFuture<Map<AciServiceIdentifier, Map<Byte, InsertResult>>> insert(\n      final Map<AciServiceIdentifier, Map<Byte, MessageProtos.Envelope>> messagesByServiceIdentifier) {\n\n    if (messagesByServiceIdentifier.entrySet()\n        .stream()\n        .anyMatch(entry -> entry.getValue().isEmpty())) {\n      throw new IllegalArgumentException(\"One or more message bundles is empty\");\n    }\n\n    final Map<Integer, List<Map.Entry<AciServiceIdentifier, Map<Byte, MessageProtos.Envelope>>>> messagesByShardId =\n        messagesByServiceIdentifier.entrySet().stream()\n            .collect(Collectors.groupingBy(entry -> hashAciToShardNumber(entry.getKey())));\n\n    final List<CompletableFuture<Map<AciServiceIdentifier, Map<Byte, InsertResult>>>> chunkFutures =\n        new ArrayList<>();\n\n    messagesByShardId.forEach((shardId, messagesForShard) -> {\n      final Database shard = databases[shardId];\n\n      int start = 0, current = 0;\n      int estimatedTransactionSize = 0;\n\n      while (current < messagesForShard.size()) {\n        estimatedTransactionSize += messagesForShard.get(current).getValue().values()\n            .stream()\n            .mapToInt(MessageProtos.Envelope::getSerializedSize)\n            .sum();\n\n        if (estimatedTransactionSize > MAX_MESSAGE_CHUNK_SIZE) {\n          chunkFutures.add(insertChunk(shard, messagesForShard.subList(start, current)));\n\n          start = current;\n          estimatedTransactionSize = 0;\n        } else {\n          current++;\n        }\n      }\n\n      assert start < messagesForShard.size();\n      chunkFutures.add(insertChunk(shard, messagesForShard.subList(start, messagesForShard.size())));\n    });\n\n    return CompletableFuture.allOf(chunkFutures.toArray(CompletableFuture[]::new))\n        .thenApply(_ -> chunkFutures.stream()\n            .map(CompletableFuture::join)\n            .reduce(new HashMap<>(), (a, b) -> {\n              a.putAll(b);\n              return a;\n            }));\n  }\n\n  private CompletableFuture<Map<AciServiceIdentifier, Map<Byte, InsertResult>>> insertChunk(\n      final Database database,\n      final List<Map.Entry<AciServiceIdentifier, Map<Byte, MessageProtos.Envelope>>> messagesByAccountIdentifier) {\n\n    final Map<AciServiceIdentifier, CompletableFuture<Map<Byte, Boolean>>> insertFuturesByAci = new HashMap<>();\n\n    // In a message bundle (single-recipient or MRM) the ephemerality should be the same for all envelopes, so just get the first.\n    final boolean ephemeral = messagesByAccountIdentifier.stream()\n        .findFirst()\n        .flatMap(entry -> entry.getValue().values().stream().findFirst())\n        .map(MessageProtos.Envelope::getEphemeral)\n        .orElseThrow(() -> new IllegalStateException(\"One or more bundles is empty\"));\n\n    return database.runAsync(transaction -> {\n          messagesByAccountIdentifier.forEach(entry ->\n              insertFuturesByAci.put(entry.getKey(), insert(entry.getKey(), entry.getValue(), transaction)));\n\n          return CompletableFuture.allOf(insertFuturesByAci.values().toArray(CompletableFuture[]::new))\n              .thenApply(_ -> {\n                final boolean anyClientPresent = insertFuturesByAci.values()\n                    .stream()\n                    .map(CompletableFuture::join)\n                    .flatMap(presenceByDeviceId -> presenceByDeviceId.values().stream())\n                    .anyMatch(isPresent -> isPresent);\n                if (anyClientPresent || !ephemeral) {\n                  return transaction.getVersionstamp()\n                      .thenApply(versionstampBytes -> Optional.of(Versionstamp.complete(versionstampBytes)));\n                }\n                return CompletableFuture.completedFuture(Optional.<Versionstamp>empty());\n              });\n        })\n        .thenCompose(Function.identity())\n        .thenApply(maybeVersionstamp -> insertFuturesByAci.entrySet().stream()\n            .collect(Collectors.toMap(Map.Entry::getKey, entry -> {\n              assert entry.getValue().isDone();\n              final Map<Byte, Boolean> presenceByDeviceId = entry.getValue().join();\n\n              return presenceByDeviceId.entrySet().stream()\n                  .collect(Collectors.toMap(Map.Entry::getKey, presenceEntry -> {\n                    final Optional<Versionstamp> insertResultVersionstamp;\n                    if (presenceEntry.getValue() || !ephemeral) {\n                      assert maybeVersionstamp.isPresent();\n                      insertResultVersionstamp = maybeVersionstamp;\n                    } else {\n                      insertResultVersionstamp = Optional.empty();\n                    }\n                    return new InsertResult(insertResultVersionstamp, presenceEntry.getValue());\n                  }));\n            })));\n  }\n\n  /// Insert a message bundle for a single recipient in an ongoing transaction.\n  ///\n  /// @implNote A message for a device is not inserted if it is offline and the message is ephemeral. Additionally, the\n  /// message watch key is updated iff at least one receiving device is present.\n  ///\n  /// @param aci                accountId of the recipient\n  /// @param messagesByDeviceId map of destination deviceId => message envelopes\n  /// @param transaction        the ongoing transaction\n  /// @return a future that yields the presence state of each destination device\n  private CompletableFuture<Map<Byte, Boolean>> insert(final AciServiceIdentifier aci,\n      final Map<Byte, MessageProtos.Envelope> messagesByDeviceId,\n      final Transaction transaction) {\n\n    final Map<Byte, CompletableFuture<Boolean>> messageInsertFuturesByDeviceId = messagesByDeviceId.entrySet()\n        .stream()\n        .collect(Collectors.toMap(Map.Entry::getKey, e -> {\n          final byte deviceId = e.getKey();\n          final MessageProtos.Envelope message = e.getValue();\n          final byte[] presenceKey = getPresenceKey(aci, deviceId);\n\n          return transaction.get(presenceKey)\n              .thenApply(this::isClientPresent)\n              .thenApply(isPresent -> {\n                if (isPresent || !message.getEphemeral()) {\n                  transaction.mutate(MutationType.SET_VERSIONSTAMPED_KEY,\n                      getDeviceQueueSubspace(aci, deviceId)\n                          .packWithVersionstamp(Tuple.from(Versionstamp.incomplete())), message.toByteArray());\n                }\n\n                return isPresent;\n              });\n        }));\n\n    return CompletableFuture.allOf(messageInsertFuturesByDeviceId.values().toArray(CompletableFuture[]::new))\n        .thenApplyAsync(_ -> {\n          final Map<Byte, Boolean> presenceByDeviceId = messageInsertFuturesByDeviceId.entrySet().stream()\n              .collect(Collectors.toMap(Map.Entry::getKey, entry -> {\n                assert entry.getValue().isDone();\n                return entry.getValue().join();\n              }));\n\n          final boolean anyClientPresent = presenceByDeviceId.values().stream().anyMatch(present -> present);\n\n          if (anyClientPresent) {\n            transaction.mutate(MutationType.SET_VERSIONSTAMPED_VALUE, getMessagesAvailableWatchKey(aci),\n                Tuple.from(Versionstamp.incomplete()).packWithVersionstamp());\n          }\n\n          return presenceByDeviceId;\n        }, executor);\n  }\n\n  @VisibleForTesting\n  Database getShardForAci(final AciServiceIdentifier aci) {\n    return databases[hashAciToShardNumber(aci)];\n  }\n\n  @VisibleForTesting\n  int hashAciToShardNumber(final AciServiceIdentifier aci) {\n    // We use a consistent hash here to reduce the number of key remappings if we increase the number of shards\n    return Hashing.consistentHash(aci.uuid().getLeastSignificantBits(), databases.length);\n  }\n\n  @VisibleForTesting\n  Subspace getDeviceQueueSubspace(final AciServiceIdentifier aci, final byte deviceId) {\n    return getDeviceSubspace(aci, deviceId).get(\"Q\");\n  }\n\n  private Subspace getDeviceSubspace(final AciServiceIdentifier aci, final byte deviceId) {\n    return getAccountSubspace(aci).get(deviceId);\n  }\n\n  private Subspace getAccountSubspace(final AciServiceIdentifier aci) {\n    return MESSAGES_SUBSPACE.get(aci.uuid());\n  }\n\n  @VisibleForTesting\n  byte[] getMessagesAvailableWatchKey(final AciServiceIdentifier aci) {\n    return getAccountSubspace(aci).pack(\"l\");\n  }\n\n  @VisibleForTesting\n  byte[] getPresenceKey(final AciServiceIdentifier aci, final byte deviceId) {\n    return getDeviceSubspace(aci, deviceId).pack(\"p\");\n  }\n\n  @VisibleForTesting\n  boolean isClientPresent(final byte[] presenceValueBytes) {\n    if (presenceValueBytes == null) {\n      return false;\n    }\n    final long presenceValue = Conversions.byteArrayToLong(presenceValueBytes);\n    // The presence value is a long with the higher order 16 bits containing a server id, and the lower 48 bits\n    // containing the timestamp (seconds since epoch) that the client updates periodically.\n    final long lastSeenSecondsSinceEpoch = presenceValue & 0x0000ffffffffffffL;\n    return (clock.instant().getEpochSecond() - lastSeenSecondsSinceEpoch) <= PRESENCE_STALE_THRESHOLD.toSeconds();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreClient.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.apple.itunes.storekit.client.APIError;\nimport com.apple.itunes.storekit.client.APIException;\nimport com.apple.itunes.storekit.client.AppStoreServerAPIClient;\nimport com.apple.itunes.storekit.model.Environment;\nimport com.apple.itunes.storekit.model.LastTransactionsItem;\nimport com.apple.itunes.storekit.model.Status;\nimport com.apple.itunes.storekit.model.StatusResponse;\nimport com.apple.itunes.storekit.verification.SignedDataVerifier;\nimport com.apple.itunes.storekit.verification.VerificationException;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.github.resilience4j.retry.Retry;\nimport io.github.resilience4j.retry.RetryConfig;\nimport io.micrometer.core.instrument.Metrics;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.UncheckedIOException;\nimport java.net.http.HttpResponse;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport javax.annotation.Nullable;\nimport io.micrometer.core.instrument.Tags;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\n/// Client for interacting with the storekit server APIs.\n///\n/// This handles fetching information about a subscription from a transaction id, and then can be used to verify\n/// individual transactions from that subscription with [#verify]. Transactions generated with both production and\n/// sandbox environments can be used.\npublic class AppleAppStoreClient {\n\n  private static final Status[] EMPTY_STATUSES = new Status[0];\n\n  private static final String GET_SUBSCRIPTION_ERROR_COUNTER_NAME =\n      MetricsUtil.name(AppleAppStoreClient.class, \"getSubscriptionsError\");\n\n  private final Environment defaultEnvironment;\n  private final SignedDataVerifier productionSignedDataVerifier;\n  private final AppStoreServerAPIClient productionApiClient;\n  private final SignedDataVerifier sandboxSignedDataVerifier;\n  private final AppStoreServerAPIClient sandboxApiClient;\n  private final Retry retry;\n\n\n  /// Construct an AppleAppStoreClient\n  ///\n  /// @param defaultEnvironment     The first environment to try. If it is the production environment and a\n  ///                               transactionId does not exist there, we will fallback to the sandbox environment\n  /// @param bundleId               The bundleId of the app\n  /// @param appAppleId             The integer id of the app\n  /// @param issuerId               The issuerId for the\n  ///                               [keys](https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests)\n  /// @param keyId                  The keyId for encodedKey\n  /// @param encodedKey             A private key with the \"In-App Purchase\" key type\n  /// @param base64AppleRootCerts   [Apple root certificates](https://www.apple.com/certificateauthority/) to verify\n  ///                               signed API responses, encoded as base64 strings:\n  /// @param retryConfigurationName The name of the retry configuration to use in the App Store client; if `null`, uses\n  ///                               the global default configuration.\n  public AppleAppStoreClient(\n      final Environment defaultEnvironment,\n      final String bundleId,\n      final long appAppleId,\n      final String issuerId,\n      final String keyId,\n      final String encodedKey,\n      final List<String> base64AppleRootCerts,\n      @Nullable final String retryConfigurationName) {\n    this(defaultEnvironment,\n        new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, Environment.PRODUCTION,\n            true),\n        new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, Environment.PRODUCTION),\n        new SignedDataVerifier(decodeRootCerts(base64AppleRootCerts), bundleId, appAppleId, Environment.SANDBOX, true),\n        new AppStoreServerAPIClient(encodedKey, keyId, issuerId, bundleId, Environment.SANDBOX),\n        retryConfigurationName);\n  }\n\n  @VisibleForTesting\n  AppleAppStoreClient(\n      Environment defaultEnvironment,\n      SignedDataVerifier productionSignedDataVerifier,\n      AppStoreServerAPIClient productionApiClient,\n      SignedDataVerifier sandboxSignedDataVerifier,\n      AppStoreServerAPIClient sandboxApiClient,\n      @Nullable final String retryConfigurationName) {\n    this.defaultEnvironment = defaultEnvironment;\n    this.sandboxSignedDataVerifier = sandboxSignedDataVerifier;\n    this.sandboxApiClient = sandboxApiClient;\n    this.productionSignedDataVerifier = productionSignedDataVerifier;\n    this.productionApiClient = productionApiClient;\n    this.retry = ResilienceUtil.getRetryRegistry().retry(\"appstore-retry\", RetryConfig\n        .<HttpResponse<?>>from(Optional.ofNullable(retryConfigurationName)\n            .flatMap(name -> ResilienceUtil.getRetryRegistry().getConfiguration(name))\n            .orElseGet(() -> ResilienceUtil.getRetryRegistry().getDefaultConfig()))\n        .retryOnException(AppleAppStoreClient::shouldRetry).build());\n  }\n\n\n  /// Verify signature and decode transaction payloads\n  public AppleAppStoreDecodedTransaction verify(final Environment environment, final LastTransactionsItem tx) {\n    final SignedDataVerifier signedDataVerifier = switch (environment) {\n      case PRODUCTION -> productionSignedDataVerifier;\n      case SANDBOX -> sandboxSignedDataVerifier;\n      default -> throw new IllegalStateException(\"Unexpected environment: \" + environment);\n    };\n    try {\n      return new AppleAppStoreDecodedTransaction(\n          tx,\n          signedDataVerifier.verifyAndDecodeTransaction(tx.getSignedTransactionInfo()),\n          signedDataVerifier.verifyAndDecodeRenewalInfo(tx.getSignedRenewalInfo()));\n    } catch (VerificationException e) {\n      throw new UncheckedIOException(new IOException(\"Failed to verify payload from App Store Server\", e));\n    }\n  }\n\n  public StatusResponse getAllSubscriptions(final String originalTransactionId, final Tags errorTags)\n      throws SubscriptionNotFoundException, SubscriptionInvalidArgumentsException, RateLimitExceededException {\n    try {\n      return retry.executeCallable(() -> {\n        try {\n          return getAllSubscriptionsHelper(defaultEnvironment, originalTransactionId);\n        } catch (final APIException e) {\n          final APIError apiError = e.getApiError();\n          Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, errorTags.and(\"reason\", apiError != null ? apiError.name() : \"http_\" + e.getHttpStatusCode())).increment();\n          throw switch (e.getApiError()) {\n            case TRANSACTION_ID_NOT_FOUND, ORIGINAL_TRANSACTION_ID_NOT_FOUND -> new SubscriptionNotFoundException();\n            case RATE_LIMIT_EXCEEDED -> new RateLimitExceededException(null);\n            case INVALID_ORIGINAL_TRANSACTION_ID -> new SubscriptionInvalidArgumentsException(e.getApiErrorMessage());\n            case null, default -> throw e;\n          };\n        } catch (final IOException e) {\n          Metrics.counter(GET_SUBSCRIPTION_ERROR_COUNTER_NAME, \"reason\", \"io_error\").increment();\n          throw e;\n        }\n      });\n    } catch (SubscriptionNotFoundException | SubscriptionInvalidArgumentsException | RateLimitExceededException e) {\n      throw e;\n    } catch (IOException e) {\n      throw new UncheckedIOException(e);\n    } catch (APIException e) {\n      throw new UncheckedIOException(new IOException(e));\n    } catch (Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private StatusResponse getAllSubscriptionsHelper(final Environment env, final String originalTransactionId)\n      throws APIException, IOException {\n    final AppStoreServerAPIClient client = switch (env) {\n      case SANDBOX -> sandboxApiClient;\n      case PRODUCTION -> productionApiClient;\n      default -> throw new IllegalArgumentException(\"Unknown environment: \" + env);\n    };\n    try {\n      return client.getAllSubscriptionStatuses(originalTransactionId, EMPTY_STATUSES);\n    } catch (APIException e) {\n      // First attempts to look up the transaction on the production environment, falling back to the sandbox env if\n      // the transaction is not found.\n      // See: https://developer.apple.com/documentation/AppStoreServerAPI#Test-using-the-sandbox-environment\n      if (env == Environment.PRODUCTION && e.getApiError() == APIError.TRANSACTION_ID_NOT_FOUND) {\n        return getAllSubscriptionsHelper(Environment.SANDBOX, originalTransactionId);\n      }\n      throw e;\n    }\n  }\n\n  private static Set<InputStream> decodeRootCerts(final List<String> rootCerts) {\n    return rootCerts.stream()\n        .map(Base64.getDecoder()::decode)\n        .map(ByteArrayInputStream::new)\n        .collect(Collectors.toSet());\n  }\n\n  private static boolean shouldRetry(Throwable e) {\n    return e instanceof APIException apiException && switch (apiException.getApiError()) {\n      case ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE, GENERAL_INTERNAL_RETRYABLE, APP_NOT_FOUND_RETRYABLE -> true;\n      case null, default -> false;\n    };\n  }\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreDecodedTransaction.java",
    "content": "package org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;\nimport com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;\nimport com.apple.itunes.storekit.model.LastTransactionsItem;\n\n/**\n * A decoded and validated storekit transaction\n *\n * @param signedTransaction The transaction\n * @param transaction The transaction info with a validated signature\n * @param renewalInfo The renewal info with a validated signature\n */\nrecord AppleAppStoreDecodedTransaction(\n    LastTransactionsItem signedTransaction,\n    JWSTransactionDecodedPayload transaction,\n    JWSRenewalInfoDecodedPayload renewalInfo) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.apple.itunes.storekit.model.AutoRenewStatus;\nimport com.apple.itunes.storekit.model.Status;\nimport com.apple.itunes.storekit.model.StatusResponse;\nimport com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem;\nimport io.micrometer.core.instrument.Tags;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.math.BigDecimal;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\n\n/**\n * Manages subscriptions made with the Apple App Store\n * <p>\n * Clients create a subscription using storekit directly, and then notify us about their subscription with their\n * subscription's <a\n * href=\"https://developer.apple.com/documentation/appstoreserverapi/originaltransactionid\">originalTransactionId</a>.\n */\npublic class AppleAppStoreManager implements SubscriptionPaymentProcessor {\n\n  private static final Logger logger = LoggerFactory.getLogger(AppleAppStoreManager.class);\n\n  private static final String LOOKUP_TYPE_TAG = \"lookup_type\";\n\n  private final AppleAppStoreClient appleAppStoreClient;\n  private final Map<String, Long> productIdToLevel;\n  private final String subscriptionGroupId;\n\n  public AppleAppStoreManager(\n      AppleAppStoreClient appleAppStoreClient,\n      final String subscriptionGroupId,\n      final Map<String, Long> productIdToLevel) {\n    this.appleAppStoreClient = appleAppStoreClient;\n    this.subscriptionGroupId = subscriptionGroupId;\n    this.productIdToLevel = productIdToLevel;\n  }\n\n  @Override\n  public PaymentProvider getProvider() {\n    return PaymentProvider.APPLE_APP_STORE;\n  }\n\n  /**\n   * Check if the subscription with the provided originalTransactionId is valid.\n   *\n   * @param originalTransactionId The originalTransactionId associated with the subscription\n   * @return the subscription level of the valid transaction.\n   * @throws RateLimitExceededException            If rate-limited\n   * @throws SubscriptionNotFoundException        If the provided originalTransactionId was not found\n   * @throws SubscriptionPaymentRequiredException If the originalTransactionId exists but is in a state that does not\n   *                                               grant the user an entitlement\n   * @throws SubscriptionInvalidArgumentsException If the transaction is valid but does not contain a subscription\n   */\n  public Long validateTransaction(final String originalTransactionId)\n      throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException {\n    final AppleAppStoreDecodedTransaction tx = lookupAndValidateTransaction(originalTransactionId, Tags.of(LOOKUP_TYPE_TAG, \"validate\"));\n    if (!isSubscriptionActive(tx)) {\n      throw new SubscriptionPaymentRequiredException();\n    }\n    return getLevel(tx);\n  }\n\n\n  /**\n   * Cancel the subscription\n   * <p>\n   * The App Store does not support backend cancellation, so this does not actually cancel, but it does verify that the\n   * user has no active subscriptions. End-users must cancel their subscription directly through storekit before calling\n   * this method.\n   *\n   * @param originalTransactionId The originalTransactionId associated with the subscription\n   * @throws RateLimitExceededException            If rate-limited\n   * @throws SubscriptionInvalidArgumentsException If the transaction is valid but does not contain a subscription, or\n   *                                                the transaction has not already been cancelled with storekit\n   */\n  @Override\n  public void cancelAllActiveSubscriptions(String originalTransactionId)\n      throws SubscriptionInvalidArgumentsException, RateLimitExceededException {\n    try {\n      final AppleAppStoreDecodedTransaction tx = lookup(originalTransactionId, Tags.of(LOOKUP_TYPE_TAG, \"cancel\"));\n      if (tx.signedTransaction().getStatus() != Status.EXPIRED &&\n          tx.signedTransaction().getStatus() != Status.REVOKED &&\n          tx.renewalInfo().getAutoRenewStatus() != AutoRenewStatus.OFF) {\n        throw new SubscriptionInvalidArgumentsException(\"must cancel subscription with storekit before deleting\");\n      }\n    } catch (SubscriptionNotFoundException _) {\n      // If the subscription is not found there is no need to do anything, so we can squash it\n    }\n    // The subscription will not auto-renew, so we can stop tracking it\n  }\n\n  @Override\n  public SubscriptionInformation getSubscriptionInformation(final String originalTransactionId)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n    final AppleAppStoreDecodedTransaction tx = lookup(originalTransactionId, Tags.of(LOOKUP_TYPE_TAG, \"info\"));\n    final SubscriptionStatus status = switch (tx.signedTransaction().getStatus()) {\n      case ACTIVE -> SubscriptionStatus.ACTIVE;\n      case BILLING_RETRY -> SubscriptionStatus.PAST_DUE;\n      case BILLING_GRACE_PERIOD -> SubscriptionStatus.UNPAID;\n      case EXPIRED, REVOKED -> SubscriptionStatus.CANCELED;\n    };\n\n    return new SubscriptionInformation(\n        getSubscriptionPrice(tx),\n        getLevel(tx),\n        Instant.ofEpochMilli(tx.transaction().getOriginalPurchaseDate()),\n        Instant.ofEpochMilli(tx.transaction().getExpiresDate()),\n        isSubscriptionActive(tx),\n        tx.renewalInfo().getAutoRenewStatus() == AutoRenewStatus.OFF,\n        status,\n        PaymentProvider.APPLE_APP_STORE,\n        PaymentMethod.APPLE_APP_STORE,\n        false,\n        null);\n  }\n\n\n  @Override\n  public ReceiptItem getReceiptItem(String originalTransactionId)\n      throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException {\n    final AppleAppStoreDecodedTransaction tx = lookup(originalTransactionId, Tags.of(LOOKUP_TYPE_TAG, \"receipt\"));\n    if (!isSubscriptionActive(tx)) {\n      throw new SubscriptionPaymentRequiredException();\n    }\n\n    // A new transactionId might be generated if you restore a subscription on a new device. webOrderLineItemId is\n    // guaranteed not to change for a specific renewal purchase.\n    // See: https://developer.apple.com/documentation/appstoreservernotifications/weborderlineitemid\n    final String itemId = tx.transaction().getWebOrderLineItemId();\n    final PaymentTime paymentTime = PaymentTime.periodEnds(Instant.ofEpochMilli(tx.transaction().getExpiresDate()));\n\n    return new ReceiptItem(itemId, paymentTime, getLevel(tx));\n\n  }\n\n  private AppleAppStoreDecodedTransaction lookup(final String originalTransactionId, final Tags tags)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n    try {\n      return lookupAndValidateTransaction(originalTransactionId, tags);\n    } catch (SubscriptionInvalidArgumentsException e) {\n      // Shouldn't happen because we previously validated this transactionId before storing it\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  private AppleAppStoreDecodedTransaction lookupAndValidateTransaction(final String originalTransactionId, final Tags errorTags)\n      throws SubscriptionInvalidArgumentsException, RateLimitExceededException, SubscriptionNotFoundException {\n    final StatusResponse statuses = appleAppStoreClient.getAllSubscriptions(originalTransactionId, errorTags);\n    final SubscriptionGroupIdentifierItem item = statuses.getData().stream()\n        .filter(s -> subscriptionGroupId.equals(s.getSubscriptionGroupIdentifier())).findFirst()\n        .orElseThrow(() -> new SubscriptionInvalidArgumentsException(\"transaction did not contain a backup subscription\", null));\n\n    final List<AppleAppStoreDecodedTransaction> txs = item.getLastTransactions().stream()\n        .map(txItem -> appleAppStoreClient.verify(statuses.getEnvironment(), txItem))\n        .filter(tx -> tx.signedTransaction().getOriginalTransactionId().equals(originalTransactionId))\n        .filter(decoded -> productIdToLevel.containsKey(decoded.transaction().getProductId()))\n        .toList();\n\n    if (txs.isEmpty()) {\n      // Get All Subscriptions only requires that the transaction be some transaction associated with the\n      // subscription. This is too flexible, since we'd like to key on the originalTransactionId in the\n      // SubscriptionManager.\n      throw new SubscriptionInvalidArgumentsException(\"transactionId did not include a paid subscription or the provided transactionId was not an originalTransactionId\", null);\n    }\n\n    if (txs.size() > 1) {\n      logger.warn(\"Multiple matching product transactions found with a sha256(originalTransactionId)={}, only considering first\",\n          sha256(originalTransactionId));\n    }\n    return txs.getFirst();\n  }\n\n  private SubscriptionPrice getSubscriptionPrice(final AppleAppStoreDecodedTransaction tx) {\n    final BigDecimal amount = new BigDecimal(tx.transaction().getPrice()).scaleByPowerOfTen(-3);\n    return new SubscriptionPrice(\n        tx.transaction().getCurrency().toUpperCase(Locale.ROOT),\n        SubscriptionCurrencyUtil.convertConfiguredAmountToApiAmount(tx.transaction().getCurrency(), amount));\n  }\n\n  private long getLevel(final AppleAppStoreDecodedTransaction tx) {\n    final Long level = productIdToLevel.get(tx.transaction().getProductId());\n    if (level == null) {\n      throw new UncheckedIOException(new IOException(\n          \"Transaction for unknown productId \" + tx.transaction().getProductId()));\n    }\n    return level;\n  }\n\n  /**\n   * Return true if the subscription's entitlement can currently be granted\n   */\n  private boolean isSubscriptionActive(final AppleAppStoreDecodedTransaction tx) {\n    return tx.signedTransaction().getStatus() == Status.ACTIVE\n        || tx.signedTransaction().getStatus() == Status.BILLING_GRACE_PERIOD;\n  }\n\n  private static String sha256(final String input) {\n    final MessageDigest sha256;\n    try {\n      sha256 = MessageDigest.getInstance(\"SHA-256\");\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(\"Every implementation of the Java platform is required to support SHA-256\", e);\n    }\n    return Base64.getEncoder().encodeToString(sha256.digest(input.getBytes(StandardCharsets.UTF_8)));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BankMandateTranslator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\nimport javax.annotation.Nonnull;\nimport org.signal.i18n.HeaderControlledResourceBundleLookup;\n\npublic class BankMandateTranslator {\n  private static final String BASE_NAME = \"org.signal.bankmandate.BankMandate\";\n  private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup;\n\n  public BankMandateTranslator(\n      @Nonnull final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) {\n    this.headerControlledResourceBundleLookup = Objects.requireNonNull(headerControlledResourceBundleLookup);\n  }\n\n  public String translate(final List<Locale> acceptableLanguages, final BankTransferType bankTransferType) {\n    final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME,\n        acceptableLanguages);\n    return resourceBundle.getString(getKey(bankTransferType));\n  }\n\n  private static String getKey(final BankTransferType bankTransferType) {\n    return switch (bankTransferType) {\n      case SEPA_DEBIT -> \"SEPA_MANDATE\";\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BankTransferType.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic enum BankTransferType {\n  SEPA_DEBIT\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClient.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.apollographql.apollo3.api.ApolloResponse;\nimport com.apollographql.apollo3.api.Operation;\nimport com.apollographql.apollo3.api.Operations;\nimport com.apollographql.apollo3.api.Optional;\nimport com.apollographql.apollo3.api.json.BufferedSinkJsonWriter;\nimport com.braintree.graphql.client.type.ChargePaymentMethodInput;\nimport com.braintree.graphql.client.type.CreatePayPalBillingAgreementInput;\nimport com.braintree.graphql.client.type.CreatePayPalOneTimePaymentInput;\nimport com.braintree.graphql.client.type.CustomFieldInput;\nimport com.braintree.graphql.client.type.MonetaryAmountInput;\nimport com.braintree.graphql.client.type.PayPalBillingAgreementChargePattern;\nimport com.braintree.graphql.client.type.PayPalBillingAgreementExperienceProfileInput;\nimport com.braintree.graphql.client.type.PayPalBillingAgreementInput;\nimport com.braintree.graphql.client.type.PayPalExperienceProfileInput;\nimport com.braintree.graphql.client.type.PayPalIntent;\nimport com.braintree.graphql.client.type.PayPalLandingPageType;\nimport com.braintree.graphql.client.type.PayPalLineItemInput;\nimport com.braintree.graphql.client.type.PayPalOneTimePaymentInput;\nimport com.braintree.graphql.client.type.PayPalProductAttributesInput;\nimport com.braintree.graphql.client.type.PayPalUserAction;\nimport com.braintree.graphql.client.type.TokenizePayPalBillingAgreementInput;\nimport com.braintree.graphql.client.type.TokenizePayPalOneTimePaymentInput;\nimport com.braintree.graphql.client.type.TransactionInput;\nimport com.braintree.graphql.client.type.TransactionLineItemType;\nimport com.braintree.graphql.client.type.VaultPaymentMethodInput;\nimport com.braintree.graphql.clientoperation.ChargePayPalOneTimePaymentMutation;\nimport com.braintree.graphql.clientoperation.CreatePayPalBillingAgreementMutation;\nimport com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;\nimport com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation;\nimport com.braintree.graphql.clientoperation.TokenizePayPalOneTimePaymentMutation;\nimport com.braintree.graphql.clientoperation.VaultPaymentMethodMutation;\nimport jakarta.ws.rs.ServiceUnavailableException;\nimport java.math.BigDecimal;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport okio.Buffer;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\n\nclass BraintreeGraphqlClient {\n\n  // required header value, recommended to be the date the integration began\n  // https://graphql.braintreepayments.com/guides/making_api_calls/#the-braintree-version-header\n  private static final String BRAINTREE_VERSION = \"2022-10-01\";\n\n  private static final Logger logger = LoggerFactory.getLogger(BraintreeGraphqlClient.class);\n\n  private final FaultTolerantHttpClient httpClient;\n  private final URI graphqlUri;\n  private final String authorizationHeader;\n\n  BraintreeGraphqlClient(final FaultTolerantHttpClient httpClient,\n      final String graphqlUri,\n      final String publicKey,\n      final String privateKey) {\n    this.httpClient = httpClient;\n    try {\n      this.graphqlUri = new URI(graphqlUri);\n    } catch (URISyntaxException e) {\n      throw new IllegalArgumentException(\"Invalid URI\", e);\n    }\n    // “public”/“private” key is a bit of a misnomer, but we follow the upstream nomenclature\n    // they are used for Basic auth similar to “client key”/“client secret” credentials\n    this.authorizationHeader = \"Basic \" + Base64.getEncoder().encodeToString((publicKey + \":\" + privateKey).getBytes());\n  }\n\n  CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> createPayPalOneTimePayment(\n      final BigDecimal amount, final String currency, final String returnUrl,\n      final String cancelUrl, final String locale, final String localizedLineItemName) {\n\n    final CreatePayPalOneTimePaymentInput input = buildCreatePayPalOneTimePaymentInput(amount, currency, returnUrl,\n        cancelUrl, locale, localizedLineItemName);\n    final CreatePayPalOneTimePaymentMutation mutation = new CreatePayPalOneTimePaymentMutation(input);\n    final HttpRequest request = buildRequest(mutation);\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenApply(httpResponse ->\n        {\n          // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”\n          // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/\n          final ApolloResponse<CreatePayPalOneTimePaymentMutation.Data> apolloResponse =\n              extractApolloResponse(httpResponse, mutation);\n          final CreatePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractDataFromApolloResponse(apolloResponse);\n          if (data.createPayPalOneTimePayment == null) {\n            logger.warn(\n                \"createPayPalOneTimePayment requestId={} currency={} amount={} did not have any errors but data was null. response={}\",\n                apolloResponse.extensions.getOrDefault(\"requestId\", \"<No-Request-Id>\"),\n                currency, amount, httpResponse.body());\n            throw new ServiceUnavailableException();\n          }\n          return data.createPayPalOneTimePayment;\n        });\n  }\n\n  private static CreatePayPalOneTimePaymentInput buildCreatePayPalOneTimePaymentInput(BigDecimal amount,\n      String currency, String returnUrl, String cancelUrl, String locale, String localizedLineItemName) {\n\n    // Note locale and localizedLineItemName are a best-effort, and it's possible that they will not match.\n    // We could try to align with the locales PayPal documents <https://developer.paypal.com/reference/locale-codes/#supported-locale-codes>\n    // but that's a moving target, and we can hopefully have one of them be better for the user by selecting\n    // independently.\n\n    return new CreatePayPalOneTimePaymentInput(\n        Optional.absent(),\n        Optional.absent(), // merchant account ID will be specified when charging\n        new MonetaryAmountInput(amount.toString(), currency), // this could potentially use a CustomScalarAdapter\n        cancelUrl,\n        Optional.absent(),\n        PayPalIntent.SALE,\n        Optional.present(List.of(\n            new PayPalLineItemInput(\n                localizedLineItemName,\n                1, // quantity, always 1\n                amount.toString(),\n                TransactionLineItemType.DEBIT,\n                Optional.absent(),\n                Optional.absent(),\n                0, // unitTaxAmount, always zero\n                Optional.absent()\n            ))),\n        Optional.present(false), // offerPayLater,\n        Optional.absent(),\n        Optional.present(\n            new PayPalExperienceProfileInput(Optional.present(\"Signal\"),\n                Optional.present(false),\n                Optional.present(PayPalLandingPageType.LOGIN),\n                Optional.present(locale),\n                Optional.absent(),\n                Optional.present(PayPalUserAction.COMMIT))),\n        Optional.absent(),\n        Optional.absent(),\n        returnUrl,\n        Optional.absent(),\n        Optional.absent()\n    );\n  }\n\n  CompletableFuture<TokenizePayPalOneTimePaymentMutation.TokenizePayPalOneTimePayment> tokenizePayPalOneTimePayment(\n      final String payerId, final String paymentId, final String paymentToken) {\n\n    final TokenizePayPalOneTimePaymentInput input = new TokenizePayPalOneTimePaymentInput(\n        Optional.absent(),\n        Optional.absent(), // merchant account ID will be specified when charging\n        new PayPalOneTimePaymentInput(payerId, paymentId, paymentToken)\n    );\n\n    final TokenizePayPalOneTimePaymentMutation mutation = new TokenizePayPalOneTimePaymentMutation(input);\n    final HttpRequest request = buildRequest(mutation);\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenApply(httpResponse -> {\n          // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”\n          // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/\n          final TokenizePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);\n          return data.tokenizePayPalOneTimePayment;\n        });\n  }\n\n  CompletableFuture<ChargePayPalOneTimePaymentMutation.ChargePaymentMethod> chargeOneTimePayment(\n      final String paymentMethodId, final BigDecimal amount, final String merchantAccount, final long level) {\n\n    final List<CustomFieldInput> customFields = List.of(\n        new CustomFieldInput(\"level\", Optional.present(Long.toString(level))));\n\n    final ChargePaymentMethodInput input = buildChargePaymentMethodInput(paymentMethodId, amount, merchantAccount,\n        customFields);\n    final ChargePayPalOneTimePaymentMutation mutation = new ChargePayPalOneTimePaymentMutation(input);\n    final HttpRequest request = buildRequest(mutation);\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenApply(httpResponse -> {\n          // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”\n          // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/\n          final ChargePayPalOneTimePaymentMutation.Data data = assertSuccessAndExtractData(httpResponse,\n              mutation);\n          return data.chargePaymentMethod;\n        });\n  }\n\n  private static ChargePaymentMethodInput buildChargePaymentMethodInput(String paymentMethodId, BigDecimal amount,\n      String merchantAccount, List<CustomFieldInput> customFields) {\n\n    return new ChargePaymentMethodInput(\n        Optional.absent(),\n        paymentMethodId,\n        new TransactionInput(\n            // documented as “amount: whole number, or exactly two or three decimal places”\n            amount.toString(), // this could potentially use a CustomScalarAdapter\n            Optional.present(merchantAccount),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.present(customFields),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent(),\n            Optional.absent()\n        )\n    );\n  }\n\n  public CompletableFuture<CreatePayPalBillingAgreementMutation.CreatePayPalBillingAgreement> createPayPalBillingAgreement(\n      final String returnUrl, final String cancelUrl, final String locale) {\n\n    final CreatePayPalBillingAgreementInput input = buildCreatePayPalBillingAgreementInput(returnUrl, cancelUrl,\n        locale);\n    final CreatePayPalBillingAgreementMutation mutation = new CreatePayPalBillingAgreementMutation(input);\n    final HttpRequest request = buildRequest(mutation);\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenApply(httpResponse -> {\n          // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”\n          // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/\n          final CreatePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);\n          return data.createPayPalBillingAgreement;\n        });\n  }\n\n  private static CreatePayPalBillingAgreementInput buildCreatePayPalBillingAgreementInput(String returnUrl,\n      String cancelUrl, String locale) {\n\n    return new CreatePayPalBillingAgreementInput(\n        Optional.absent(),\n        Optional.absent(),\n        returnUrl,\n        cancelUrl,\n        Optional.absent(),\n        Optional.absent(),\n        Optional.present(false), // offerPayPalCredit\n        Optional.absent(),\n        Optional.present(\n            new PayPalBillingAgreementExperienceProfileInput(Optional.present(\"Signal\"),\n                Optional.present(false), // collectShippingAddress\n                Optional.present(PayPalLandingPageType.LOGIN),\n                Optional.present(locale),\n                Optional.absent())),\n        Optional.absent(),\n        Optional.present(new PayPalProductAttributesInput(\n            Optional.present(PayPalBillingAgreementChargePattern.RECURRING_PREPAID)\n        ))\n    );\n  }\n\n  public CompletableFuture<TokenizePayPalBillingAgreementMutation.TokenizePayPalBillingAgreement> tokenizePayPalBillingAgreement(\n      final String billingAgreementToken) {\n\n    final TokenizePayPalBillingAgreementInput input = new TokenizePayPalBillingAgreementInput(\n        Optional.absent(),\n        new PayPalBillingAgreementInput(billingAgreementToken));\n    final TokenizePayPalBillingAgreementMutation mutation = new TokenizePayPalBillingAgreementMutation(input);\n    final HttpRequest request = buildRequest(mutation);\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenApply(httpResponse -> {\n          // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”\n          // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/\n          final TokenizePayPalBillingAgreementMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);\n          return data.tokenizePayPalBillingAgreement;\n        });\n  }\n\n  public CompletableFuture<VaultPaymentMethodMutation.VaultPaymentMethod> vaultPaymentMethod(final String customerId,\n      final String paymentMethodId) {\n\n    final VaultPaymentMethodInput input = buildVaultPaymentMethodInput(customerId, paymentMethodId);\n    final VaultPaymentMethodMutation mutation = new VaultPaymentMethodMutation(input);\n    final HttpRequest request = buildRequest(mutation);\n\n    return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())\n        .thenApply(httpResponse -> {\n          // IntelliJ users: type parameters error “no instance of type variable exists so that Data conforms to Data”\n          // is not accurate; this might be fixed in Kotlin 1.8: https://youtrack.jetbrains.com/issue/KTIJ-21905/\n          final VaultPaymentMethodMutation.Data data = assertSuccessAndExtractData(httpResponse, mutation);\n          return data.vaultPaymentMethod;\n        });\n  }\n\n  private static VaultPaymentMethodInput buildVaultPaymentMethodInput(String customerId, String paymentMethodId) {\n    return new VaultPaymentMethodInput(\n        Optional.absent(),\n        paymentMethodId,\n        Optional.absent(),\n        Optional.absent(),\n        Optional.present(customerId),\n        Optional.absent(),\n        Optional.absent()\n    );\n  }\n\n  /**\n   * Verifies that the HTTP response has a {@code 200} status code and the GraphQL response has no errors, otherwise\n   * throws a {@link ServiceUnavailableException}.\n   */\n  private static <T extends Operation<U>, U extends Operation.Data> U assertSuccessAndExtractData(\n      HttpResponse<String> httpResponse, T operation) {\n    return assertSuccessAndExtractDataFromApolloResponse(extractApolloResponse(httpResponse, operation));\n  }\n\n  private static <T extends Operation<U>, U extends Operation.Data> ApolloResponse<U> extractApolloResponse(\n      HttpResponse<String> httpResponse, T operation) {\n\n    if (httpResponse.statusCode() != 200) {\n      logger.warn(\"Received HTTP response status {} ({})\", httpResponse.statusCode(),\n          httpResponse.headers().firstValue(\"paypal-debug-id\").orElse(\"<debug id absent>\"));\n      throw new ServiceUnavailableException();\n    }\n\n    return Operations.parseJsonResponse(operation, httpResponse.body());\n  }\n\n  private static <T extends Operation<U>, U extends Operation.Data> U assertSuccessAndExtractDataFromApolloResponse(final ApolloResponse<U> response) {\n    if (response.hasErrors() || response.data == null) {\n      //noinspection ConstantConditions\n      response.errors.forEach(\n          error -> {\n            final Object legacyCode = java.util.Optional.ofNullable(error.getExtensions())\n                .map(extensions -> extensions.get(\"legacyCode\"))\n                .orElse(\"<none>\");\n            logger.warn(\"Received GraphQL error for {}: \\\"{}\\\" (legacyCode: {})\",\n                response.operation.name(), error.getMessage(), legacyCode);\n          });\n\n      throw new ServiceUnavailableException();\n    }\n\n    return response.data;\n  }\n\n  private HttpRequest buildRequest(final Operation<?> operation) {\n\n    final Buffer buffer = new Buffer();\n    Operations.composeJsonRequest(operation, new BufferedSinkJsonWriter(buffer));\n\n    return HttpRequest.newBuilder()\n        .uri(graphqlUri)\n        .method(\"POST\", HttpRequest.BodyPublishers.ofString(buffer.readUtf8()))\n        .header(\"Content-Type\", \"application/json\")\n        .header(\"Authorization\", authorizationHeader)\n        .header(\"Braintree-Version\", BRAINTREE_VERSION)\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManager.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.braintree.graphql.clientoperation.TokenizePayPalBillingAgreementMutation;\nimport com.braintree.graphql.clientoperation.VaultPaymentMethodMutation;\nimport com.braintreegateway.BraintreeGateway;\nimport com.braintreegateway.ClientTokenRequest;\nimport com.braintreegateway.Customer;\nimport com.braintreegateway.CustomerRequest;\nimport com.braintreegateway.Plan;\nimport com.braintreegateway.ResourceCollection;\nimport com.braintreegateway.Result;\nimport com.braintreegateway.Subscription;\nimport com.braintreegateway.SubscriptionRequest;\nimport com.braintreegateway.Transaction;\nimport com.braintreegateway.TransactionSearchRequest;\nimport com.braintreegateway.exceptions.BraintreeException;\nimport com.braintreegateway.exceptions.NotFoundException;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.cloud.pubsub.v1.PublisherInterface;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.pubsub.v1.PubsubMessage;\nimport io.micrometer.core.instrument.Metrics;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.HexFormat;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.ExecutorUtil;\nimport org.whispersystems.textsecuregcm.util.GoogleApiUtil;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\npublic class BraintreeManager implements CustomerAwareSubscriptionPaymentProcessor {\n\n  private static final Logger logger = LoggerFactory.getLogger(BraintreeManager.class);\n\n  private static final String GENERIC_DECLINED_PROCESSOR_CODE = \"2046\";\n  private static final String PAYPAL_FUNDING_INSTRUMENT_DECLINED_PROCESSOR_CODE = \"2074\";\n  private static final String PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE = \"2094\";\n\n  private static final BigDecimal ONE_MILLION = BigDecimal.valueOf(1_000_000);\n\n  private final BraintreeGateway braintreeGateway;\n  private final BraintreeGraphqlClient braintreeGraphqlClient;\n  private final CurrencyConversionManager currencyConversionManager;\n  private final PublisherInterface pubsubPublisher;\n  private final Executor executor;\n  private final Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod;\n  private final Map<String, String> currenciesToMerchantAccounts;\n\n  private final String PUBSUB_MESSAGE_COUNTER_NAME = MetricsUtil.name(BraintreeManager.class, \"pubSubMessage\");\n\n  public BraintreeManager(final String braintreeMerchantId, final String braintreePublicKey,\n      final String braintreePrivateKey,\n      final String braintreeEnvironment,\n      final Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod,\n      final Map<String, String> currenciesToMerchantAccounts,\n      final String graphqlUri,\n      final CurrencyConversionManager currencyConversionManager,\n      final PublisherInterface pubsubPublisher,\n      @Nullable final String circuitBreakerConfigurationName,\n      final Executor executor) {\n\n    this(new BraintreeGateway(braintreeEnvironment, braintreeMerchantId, braintreePublicKey,\n            braintreePrivateKey),\n        supportedCurrenciesByPaymentMethod,\n        currenciesToMerchantAccounts,\n        new BraintreeGraphqlClient(FaultTolerantHttpClient.newBuilder(\"braintree-graphql\", executor)\n            .withCircuitBreaker(circuitBreakerConfigurationName)\n            // Braintree documents its internal timeout at 60 seconds, and we want to make sure we don’t miss\n            // a response\n            // https://developer.paypal.com/braintree/docs/reference/general/best-practices/java#timeouts\n            .withRequestTimeout(Duration.ofSeconds(70))\n            .build(), graphqlUri, braintreePublicKey, braintreePrivateKey),\n        currencyConversionManager,\n        pubsubPublisher,\n        executor);\n  }\n\n  @VisibleForTesting\n  BraintreeManager(final BraintreeGateway braintreeGateway,\n      final Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod,\n      final Map<String, String> currenciesToMerchantAccounts, final BraintreeGraphqlClient braintreeGraphqlClient,\n      final CurrencyConversionManager currencyConversionManager, final PublisherInterface pubsubPublisher,\n      final Executor executor) {\n    this.braintreeGateway = braintreeGateway;\n    this.supportedCurrenciesByPaymentMethod = supportedCurrenciesByPaymentMethod;\n    this.currenciesToMerchantAccounts = currenciesToMerchantAccounts;\n    this.braintreeGraphqlClient = braintreeGraphqlClient;\n    this.currencyConversionManager = currencyConversionManager;\n    this.pubsubPublisher = pubsubPublisher;\n    this.executor = executor;\n  }\n\n  @Override\n  public Set<String> getSupportedCurrenciesForPaymentMethod(final PaymentMethod paymentMethod) {\n    return supportedCurrenciesByPaymentMethod.getOrDefault(paymentMethod, Collections.emptySet());\n  }\n\n  @Override\n  public PaymentProvider getProvider() {\n    return PaymentProvider.BRAINTREE;\n  }\n\n  @Override\n  public boolean supportsPaymentMethod(final PaymentMethod paymentMethod) {\n    return paymentMethod == PaymentMethod.PAYPAL;\n  }\n\n  public CompletableFuture<PaymentDetails> getPaymentDetails(final String paymentId) {\n    return CompletableFuture.supplyAsync(() -> {\n      try {\n        final Transaction transaction = braintreeGateway.transaction().find(paymentId);\n        ChargeFailure chargeFailure = null;\n        if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {\n          chargeFailure = createChargeFailure(transaction);\n        }\n        return new PaymentDetails(transaction.getGraphQLId(),\n            transaction.getCustomFields(),\n            getPaymentStatus(transaction.getStatus()),\n            transaction.getCreatedAt().toInstant(),\n            chargeFailure);\n\n      } catch (final NotFoundException e) {\n        return null;\n      }\n    }, executor);\n  }\n\n  public CompletableFuture<PayPalOneTimePaymentApprovalDetails> createOneTimePayment(String currency, long amount,\n      String locale, String returnUrl, String cancelUrl, String localizedLineItemname) {\n    return braintreeGraphqlClient.createPayPalOneTimePayment(convertApiAmountToBraintreeAmount(currency, amount),\n            currency.toUpperCase(Locale.ROOT), returnUrl,\n            cancelUrl, locale, localizedLineItemname)\n        .thenApply(result -> new PayPalOneTimePaymentApprovalDetails((String) result.approvalUrl, result.paymentId));\n  }\n\n  public CompletableFuture<PayPalChargeSuccessDetails> captureOneTimePayment(String payerId, String paymentId,\n      String paymentToken, String currency, long amount, long level, @Nullable ClientPlatform clientPlatform) {\n    return braintreeGraphqlClient.tokenizePayPalOneTimePayment(payerId, paymentId, paymentToken)\n        .thenCompose(response -> braintreeGraphqlClient.chargeOneTimePayment(\n                response.paymentMethod.id,\n                convertApiAmountToBraintreeAmount(currency, amount),\n                currenciesToMerchantAccounts.get(currency.toLowerCase(Locale.ROOT)),\n                level)\n            .thenComposeAsync(chargeResponse -> {\n\n              final PaymentStatus paymentStatus = getPaymentStatus(chargeResponse.transaction.status);\n              if (paymentStatus == PaymentStatus.SUCCEEDED || paymentStatus == PaymentStatus.PROCESSING) {\n                publishDonationEvent(amount, currency, Instant.now(), clientPlatform);\n                return CompletableFuture.completedFuture(new PayPalChargeSuccessDetails(chargeResponse.transaction.id));\n              }\n\n              // the GraphQL/Apollo interfaces are a tad unwieldy for this type of status checking\n              final Transaction unsuccessfulTx = braintreeGateway.transaction().find(chargeResponse.transaction.id);\n\n              if (PAYPAL_PAYMENT_ALREADY_COMPLETED_PROCESSOR_CODE.equals(unsuccessfulTx.getProcessorResponseCode())\n                  || Transaction.GatewayRejectionReason.DUPLICATE.equals(unsuccessfulTx.getGatewayRejectionReason())) {\n                // the payment has already been charged - maybe a previous call timed out or was interrupted -\n                // in any case, check for a successful transaction with the paymentId\n                final ResourceCollection<Transaction> search = braintreeGateway.transaction()\n                    .search(new TransactionSearchRequest()\n                        .paypalPaymentId().is(paymentId)\n                        .status().in(\n                            Transaction.Status.SETTLED,\n                            Transaction.Status.SETTLING,\n                            Transaction.Status.SUBMITTED_FOR_SETTLEMENT,\n                            Transaction.Status.SETTLEMENT_PENDING\n                        )\n                    );\n\n                if (search.getMaximumSize() == 0) {\n                  return CompletableFuture.failedFuture(ExceptionUtils.wrap(new IOException()));\n                }\n\n                final Transaction successfulTx = search.getFirst();\n\n                publishDonationEvent(amount, currency, successfulTx.getCreatedAt().toInstant(), clientPlatform);\n\n                return CompletableFuture.completedFuture(\n                    new PayPalChargeSuccessDetails(successfulTx.getGraphQLId()));\n              }\n\n              return switch (unsuccessfulTx.getProcessorResponseCode()) {\n                case GENERIC_DECLINED_PROCESSOR_CODE, PAYPAL_FUNDING_INSTRUMENT_DECLINED_PROCESSOR_CODE ->\n                    CompletableFuture.failedFuture(\n                        new SubscriptionProcessorException(getProvider(), createChargeFailure(unsuccessfulTx)));\n\n                default -> {\n                  logger.info(\"PayPal charge unexpectedly failed: {}\", unsuccessfulTx.getProcessorResponseCode());\n\n                  yield CompletableFuture.failedFuture(ExceptionUtils.wrap(new IOException()));\n                }\n              };\n            }, executor));\n  }\n\n  private void publishDonationEvent(final long amount,\n      final String currency,\n      final Instant timestamp,\n      @Nullable final ClientPlatform clientPlatform) {\n\n    try {\n      final BigDecimal originalAmount = convertApiAmountToBraintreeAmount(currency, amount);\n\n      final BigDecimal originalAmountUsd =\n          currencyConversionManager.convertToUsd(originalAmount, currency)\n              .orElseThrow(() -> new IllegalArgumentException(\"Could not convert to USD from \" + currency));\n\n      final DonationsPubsub.DonationPubSubMessage.Builder donationPubSubMessageBuilder =\n          DonationsPubsub.DonationPubSubMessage.newBuilder()\n              .setTimestamp(timestamp.toEpochMilli() * 1000)\n              .setSource(\"app\")\n              .setProvider(\"braintree\")\n              .setRecurring(false)\n              .setPaymentMethodType(\"paypal\")\n              .setOriginalAmountMicros(toMicros(originalAmount))\n              .setOriginalCurrency(currency)\n              .setOriginalAmountUsdMicros(toMicros(originalAmountUsd));\n\n      if (clientPlatform != null) {\n        donationPubSubMessageBuilder.setClientPlatform(clientPlatform.name().toLowerCase(Locale.ROOT));\n      }\n\n      GoogleApiUtil.toCompletableFuture(pubsubPublisher.publish(PubsubMessage.newBuilder()\n              .setData(donationPubSubMessageBuilder.build().toByteString())\n              .build()), executor)\n          .whenComplete((messageId, throwable) -> {\n            if (throwable != null) {\n              logger.warn(\"Failed to publish donation pub/sub message\", throwable);\n            }\n\n            Metrics.counter(PUBSUB_MESSAGE_COUNTER_NAME, \"success\", String.valueOf(throwable == null))\n                .increment();\n          });\n    } catch (final Exception e) {\n      logger.warn(\"Failed to construct donation pub/sub message\", e);\n    }\n  }\n\n  @VisibleForTesting\n  long toMicros(final BigDecimal amount) {\n    return amount.multiply(ONE_MILLION).longValueExact();\n  }\n\n  private static PaymentStatus getPaymentStatus(Transaction.Status status) {\n    return switch (status) {\n      case SETTLEMENT_CONFIRMED, SETTLING, SUBMITTED_FOR_SETTLEMENT, SETTLED -> PaymentStatus.SUCCEEDED;\n      case AUTHORIZATION_EXPIRED, GATEWAY_REJECTED, PROCESSOR_DECLINED, SETTLEMENT_DECLINED, VOIDED, FAILED ->\n          PaymentStatus.FAILED;\n      default -> PaymentStatus.UNKNOWN;\n    };\n  }\n\n  private static PaymentStatus getPaymentStatus(com.braintree.graphql.client.type.PaymentStatus status) {\n    try {\n      Transaction.Status transactionStatus = Transaction.Status.valueOf(status.rawValue);\n\n      return getPaymentStatus(transactionStatus);\n    } catch (final Exception e) {\n      return PaymentStatus.UNKNOWN;\n    }\n  }\n\n  private static SubscriptionStatus getSubscriptionStatus(final Subscription.Status status, final boolean latestTransactionFailed) {\n    return switch (status) {\n      // Stripe returns a PAST_DUE status if the subscription's most recent payment failed.\n      // This check ensures that Braintree is consistent with Stripe.\n      case ACTIVE -> latestTransactionFailed ? SubscriptionStatus.PAST_DUE : SubscriptionStatus.ACTIVE;\n      case CANCELED, EXPIRED -> SubscriptionStatus.CANCELED;\n      case PAST_DUE -> SubscriptionStatus.PAST_DUE;\n      case PENDING -> SubscriptionStatus.INCOMPLETE;\n      case UNRECOGNIZED -> {\n        logger.error(\"Subscription has unrecognized status; library may need to be updated: {}\", status);\n        yield SubscriptionStatus.UNKNOWN;\n      }\n    };\n  }\n\n  private BigDecimal convertApiAmountToBraintreeAmount(final String currency, final long amount) {\n    return switch (currency.toLowerCase(Locale.ROOT)) {\n      // JPY is the only supported zero-decimal currency\n      case \"jpy\" -> BigDecimal.valueOf(amount);\n      default -> BigDecimal.valueOf(amount).scaleByPowerOfTen(-2);\n    };\n  }\n\n  public record PayPalOneTimePaymentApprovalDetails(String approvalUrl, String paymentId) {\n\n  }\n\n  public record PayPalChargeSuccessDetails(String paymentId) {\n\n  }\n\n  @Override\n  public ProcessorCustomer createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) {\n    CustomerRequest request = new CustomerRequest()\n        .customField(\"subscriber_user\", HexFormat.of().formatHex(subscriberUser));\n\n    if (clientPlatform != null) {\n      request.customField(\"client_platform\", clientPlatform.name().toLowerCase());\n    }\n\n    final Result<Customer> result = braintreeGateway.customer().create(request);\n    if (!result.isSuccess()) {\n      throw new BraintreeException(result.getMessage());\n    }\n    return new ProcessorCustomer(result.getTarget().getId(), PaymentProvider.BRAINTREE);\n  }\n\n  @Override\n  public String createPaymentMethodSetupToken(final String customerId) {\n    ClientTokenRequest request = new ClientTokenRequest().customerId(customerId);\n\n    return braintreeGateway.clientToken().generate(request);\n  }\n\n  @Override\n  public void setDefaultPaymentMethodForCustomer(String customerId, String billingAgreementToken,\n      @Nullable String currentSubscriptionId) {\n    final Optional<String> maybeSubscriptionId = Optional.ofNullable(currentSubscriptionId);\n    final TokenizePayPalBillingAgreementMutation.TokenizePayPalBillingAgreement tokenizePayPalBillingAgreement =\n        braintreeGraphqlClient.tokenizePayPalBillingAgreement(billingAgreementToken).join();\n    final VaultPaymentMethodMutation.VaultPaymentMethod vaultPaymentMethod =\n        braintreeGraphqlClient.vaultPaymentMethod(customerId, tokenizePayPalBillingAgreement.paymentMethod.id).join();\n    final Result<Customer> result = braintreeGateway.customer()\n        .update(customerId, new CustomerRequest().defaultPaymentMethodToken(vaultPaymentMethod.paymentMethod.id));\n    maybeSubscriptionId.ifPresent(subscriptionId ->\n        braintreeGateway.subscription().update(subscriptionId, new SubscriptionRequest()\n            .paymentMethodToken(result.getTarget().getDefaultPaymentMethod().getToken())));\n  }\n\n  @Override\n  public Object getSubscription(String subscriptionId) {\n    return braintreeGateway.subscription().find(subscriptionId);\n  }\n\n  @Override\n  public SubscriptionId createSubscription(String customerId, String planId, long level,\n      long lastSubscriptionCreatedAt)\n      throws SubscriptionProcessorConflictException, SubscriptionProcessorException {\n\n    final com.braintreegateway.PaymentMethod paymentMethod = getDefaultPaymentMethod(customerId);\n    if (paymentMethod == null) {\n      throw new SubscriptionProcessorConflictException();\n    }\n\n    final Optional<Subscription> maybeExistingSubscription = paymentMethod.getSubscriptions().stream()\n        .filter(sub -> sub.getStatus().equals(Subscription.Status.ACTIVE))\n        .filter(Subscription::neverExpires)\n        .findAny();\n\n    if (maybeExistingSubscription.isPresent()) {\n      final Subscription subscription = maybeExistingSubscription.get();\n      final Plan plan = findPlan(subscription.getPlanId());\n      if (getLevelForPlan(plan) != level) {\n        // if this happens, the likely cause is retrying an apparently failed request (likely some sort of timeout or network interruption)\n        // with a different level.\n        // In this case, it’s safer and easier to recover by returning this subscription, rather than\n        // returning an error\n        logger.warn(\"existing subscription had unexpected level\");\n      }\n      return new SubscriptionId(subscription.getId());\n    }\n    final Plan plan = findPlan(planId);\n    final Result<Subscription> result = braintreeGateway.subscription().create(new SubscriptionRequest()\n        .planId(planId)\n        .paymentMethodToken(paymentMethod.getToken())\n        .merchantAccountId(\n            currenciesToMerchantAccounts.get(plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT)))\n        .options()\n        .startImmediately(true)\n        .done());\n\n    if (!result.isSuccess()) {\n      throw Optional\n          .ofNullable(result.getTarget())\n          .flatMap(subscription -> subscription.getTransactions().stream().findFirst())\n          .map(transaction -> new SubscriptionProcessorException(getProvider(),\n              createChargeFailure(transaction)))\n          .orElseThrow(() -> new BraintreeException(result.getMessage()));\n    }\n\n    return new SubscriptionId(result.getTarget().getId());\n  }\n\n  private com.braintreegateway.PaymentMethod getDefaultPaymentMethod(String customerId) {\n    return braintreeGateway.customer().find(customerId).getDefaultPaymentMethod();\n  }\n\n\n  @Override\n  public CustomerAwareSubscriptionPaymentProcessor.SubscriptionId updateSubscription(Object subscriptionObj, String planId, long level,\n      String idempotencyKey) throws SubscriptionProcessorConflictException, SubscriptionProcessorException {\n\n    if (!(subscriptionObj instanceof final Subscription subscription)) {\n      throw new IllegalArgumentException(\"invalid subscription object: \" + subscriptionObj.getClass().getName());\n    }\n\n    // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and\n    // not prorated. Braintree subscriptions cannot change their next billing date,\n    // so we must end the existing one and create a new one\n    endSubscription(subscription);\n\n    final Transaction transaction = getLatestTransactionForSubscription(subscription)\n        .orElseThrow(() -> ExceptionUtils.wrap(new SubscriptionProcessorConflictException()));\n\n    final Customer customer = transaction.getCustomer();\n\n    return createSubscription(customer.getId(), planId, level,\n        subscription.getCreatedAt().toInstant().getEpochSecond());\n  }\n\n  @Override\n  public LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscriptionObj) {\n    final Subscription subscription = getSubscription(subscriptionObj);\n    final Plan plan = findPlan(subscription.getPlanId());\n    return new LevelAndCurrency(getLevelForPlan(plan), plan.getCurrencyIsoCode().toLowerCase(Locale.ROOT));\n  }\n\n  private Plan findPlan(String planId) {\n    return braintreeGateway.plan().find(planId);\n  }\n\n  private long getLevelForPlan(final Plan plan) {\n    final BraintreePlanMetadata metadata;\n    try {\n      metadata = SystemMapper.jsonMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class);\n\n    } catch (JsonProcessingException e) {\n      throw new RuntimeException(e);\n    }\n\n    return metadata.level();\n  }\n\n  @Override\n  public SubscriptionInformation getSubscriptionInformation(final String subscriptionId) {\n    final Subscription subscription =  getSubscription(getSubscription(subscriptionId));\n    final Plan plan = braintreeGateway.plan().find(subscription.getPlanId());\n    final long level = getLevelForPlan(plan);\n\n    final Instant anchor = subscription.getFirstBillingDate().toInstant();\n    final Instant endOfCurrentPeriod = subscription.getBillingPeriodEndDate().toInstant();\n\n    final TransactionInfo latestTransactionInfo = getLatestTransactionForSubscription(subscription)\n        .map(this::getTransactionInfo)\n        .orElse(new TransactionInfo(PaymentMethod.PAYPAL, false, false, null));\n\n    return new SubscriptionInformation(\n        new SubscriptionPrice(plan.getCurrencyIsoCode().toUpperCase(Locale.ROOT),\n            SubscriptionCurrencyUtil.convertBraintreeAmountToApiAmount(plan.getCurrencyIsoCode(), plan.getPrice())),\n        level,\n        anchor,\n        endOfCurrentPeriod,\n        Subscription.Status.ACTIVE == subscription.getStatus(),\n        !subscription.neverExpires(),\n        getSubscriptionStatus(subscription.getStatus(), latestTransactionInfo.transactionFailed()),\n        PaymentProvider.BRAINTREE,\n        latestTransactionInfo.paymentMethod(),\n        latestTransactionInfo.paymentProcessing(),\n        latestTransactionInfo.chargeFailure()\n    );\n  }\n\n  private record TransactionInfo(\n      PaymentMethod paymentMethod,\n      boolean paymentProcessing,\n      boolean transactionFailed,\n      @Nullable ChargeFailure chargeFailure) {}\n\n  private TransactionInfo getTransactionInfo(final Transaction transaction) {\n    final boolean paymentProcessing = isPaymentProcessing(transaction.getStatus());\n    final PaymentMethod paymentMethod = getPaymentMethodFromTransaction(transaction);\n    if (getPaymentStatus(transaction.getStatus()) != PaymentStatus.SUCCEEDED) {\n      return new TransactionInfo(paymentMethod, paymentProcessing, true, createChargeFailure(transaction));\n    }\n    return new TransactionInfo(paymentMethod, paymentProcessing, false, null);\n  }\n\n  private PaymentMethod getPaymentMethodFromTransaction(Transaction transaction) {\n    if (transaction.getPayPalDetails() != null) {\n      return PaymentMethod.PAYPAL;\n    }\n    logger.error(\"Unexpected payment method from Braintree: {}, transaction id {}\", transaction.getPaymentInstrumentType(), transaction.getId());\n    return PaymentMethod.UNKNOWN;\n  }\n\n  private static boolean isPaymentProcessing(final Transaction.Status status) {\n    return status == Transaction.Status.SETTLEMENT_PENDING;\n  }\n\n  private ChargeFailure createChargeFailure(Transaction transaction) {\n\n    final String code;\n    final String message;\n    if (transaction.getStatus() == Transaction.Status.VOIDED) {\n      code = \"voided\";\n      message = \"voided\";\n    } else if (transaction.getProcessorResponseCode() != null) {\n      code = transaction.getProcessorResponseCode();\n      message = transaction.getProcessorResponseText();\n    } else if (transaction.getGatewayRejectionReason() != null) {\n      code = \"gateway\";\n      message = transaction.getGatewayRejectionReason().toString();\n    } else {\n      code = \"unknown\";\n      message = \"unknown\";\n    }\n\n    return new ChargeFailure(\n        code,\n        message,\n        null,\n        null,\n        null);\n  }\n\n  @Override\n  public void cancelAllActiveSubscriptions(String customerId) {\n    final Customer customer = braintreeGateway.customer().find(customerId);\n    ExecutorUtil.runAll(executor, Optional.ofNullable(customer.getDefaultPaymentMethod())\n        .stream()\n        .flatMap(paymentMethod -> paymentMethod.getSubscriptions().stream())\n        .<Runnable>map(subscription -> () -> this.endSubscription(subscription))\n        .toList());\n  }\n\n  private void endSubscription(Subscription subscription) {\n    final boolean latestTransactionFailed = getLatestTransactionForSubscription(subscription)\n        .map(this::getTransactionInfo)\n        .map(TransactionInfo::transactionFailed)\n        .orElse(false);\n    switch (getSubscriptionStatus(subscription.getStatus(), latestTransactionFailed)) {\n      // The payment for this period has not processed yet, we should immediately cancel to prevent any payment from\n      // going through.\n      case INCOMPLETE, PAST_DUE, UNPAID -> cancelSubscriptionImmediately(subscription);\n      // Otherwise, set the subscription to cancel at the current period end. If the subscription is active, it may\n      // continue to be used until the end of the period.\n      default -> cancelSubscriptionAtEndOfCurrentPeriod(subscription);\n    }\n  }\n\n  private void cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {\n    braintreeGateway\n        .subscription()\n        .update(subscription.getId(),\n            new SubscriptionRequest().numberOfBillingCycles(subscription.getCurrentBillingCycle()));\n  }\n\n  private void cancelSubscriptionImmediately(Subscription subscription) {\n    braintreeGateway.subscription().cancel(subscription.getId());\n  }\n\n\n  @Override\n  public ReceiptItem getReceiptItem(String subscriptionId)\n      throws SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionChargeFailurePaymentRequiredException {\n    final Subscription subscription = getSubscription(getSubscription(subscriptionId));\n    final Transaction transaction = getLatestTransactionForSubscription(subscription)\n        .orElseThrow(SubscriptionReceiptRequestedForOpenPaymentException::new);\n    if (!getPaymentStatus(transaction.getStatus()).equals(PaymentStatus.SUCCEEDED)) {\n      final SubscriptionStatus subscriptionStatus = getSubscriptionStatus(subscription.getStatus(), true);\n      if (subscriptionStatus.equals(SubscriptionStatus.ACTIVE) || subscriptionStatus.equals(\n          SubscriptionStatus.PAST_DUE)) {\n        throw new SubscriptionReceiptRequestedForOpenPaymentException();\n      }\n      throw new SubscriptionChargeFailurePaymentRequiredException(getProvider(), createChargeFailure(transaction));\n    }\n\n    final Instant paidAt = transaction.getSubscriptionDetails().getBillingPeriodStartDate().toInstant();\n    final Plan plan = braintreeGateway.plan().find(transaction.getPlanId());\n\n    final BraintreePlanMetadata metadata;\n    try {\n      metadata = SystemMapper.jsonMapper().readValue(plan.getDescription(), BraintreePlanMetadata.class);\n\n    } catch (JsonProcessingException e) {\n      throw new RuntimeException(e);\n    }\n\n    return new ReceiptItem(transaction.getId(), PaymentTime.periodStart(paidAt), metadata.level());\n  }\n\n  private static Subscription getSubscription(Object subscriptionObj) {\n    if (!(subscriptionObj instanceof final Subscription subscription)) {\n      throw new IllegalArgumentException(\"Invalid subscription object: \" + subscriptionObj.getClass().getName());\n    }\n    return subscription;\n  }\n\n  private Optional<Transaction> getLatestTransactionForSubscription(Subscription subscription) {\n    return subscription.getTransactions().stream()\n            .max(Comparator.comparing(Transaction::getCreatedAt));\n  }\n\n  public CompletableFuture<PayPalBillingAgreementApprovalDetails> createPayPalBillingAgreement(final String returnUrl,\n                                                                                               final String cancelUrl, final String locale) {\n    return braintreeGraphqlClient.createPayPalBillingAgreement(returnUrl, cancelUrl, locale)\n            .thenApply(response ->\n                    new PayPalBillingAgreementApprovalDetails((String) response.approvalUrl, response.billingAgreementToken)\n            );\n  }\n\n  public record PayPalBillingAgreementApprovalDetails(String approvalUrl, String billingAgreementToken) {\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/BraintreePlanMetadata.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic record BraintreePlanMetadata(long level) {\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ChargeFailure.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport io.swagger.v3.oas.annotations.ExternalDocumentation;\nimport io.swagger.v3.oas.annotations.media.Schema;\nimport javax.annotation.Nullable;\n\n/**\n * Information about a charge failure.\n * <p>\n * This is returned directly from {@link org.whispersystems.textsecuregcm.controllers.SubscriptionController}, so modify\n * with care.\n */\n@Schema(description = \"\"\"\n      Meaningfully interpreting chargeFailure response fields requires inspecting the processor field first.\n\n      For Stripe, code will be one of the [codes defined here](https://stripe.com/docs/api/charges/object#charge_object-failure_code),\n      while message [may contain a further textual description](https://stripe.com/docs/api/charges/object#charge_object-failure_message).\n      The outcome fields are nullable, but present values will directly map to Stripe [response properties](https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status)\n\n      For Braintree, the outcome fields will be null. The code and message will contain one of\n        - a processor decline code (as a string) in code, and associated text in message, as defined this [table](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses)\n        - `gateway` in code, with a [reason](https://developer.paypal.com/braintree/articles/control-panel/transactions/gateway-rejections) in message\n        - `code` = \"unknown\", message = \"unknown\"\n\n      IAP payment processors will never include charge failure information, and detailed order information should be\n      retrieved from the payment processor directly\n    \"\"\")\npublic record ChargeFailure(\n    @Schema(description = \"\"\"\n        See [Stripe failure codes](https://stripe.com/docs/api/charges/object#charge_object-failure_code) or\n        [Braintree decline codes](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes)\n        depending on which processor was used\n        \"\"\")\n    String code,\n\n    @Schema(description = \"\"\"\n        See [Stripe failure codes](https://stripe.com/docs/api/charges/object#charge_object-failure_code) or\n        [Braintree decline codes](https://developer.paypal.com/braintree/docs/reference/general/processor-responses/authorization-responses#decline-codes)\n        depending on which processor was used\n        \"\"\")\n    String message,\n\n    @Schema(externalDocs = @ExternalDocumentation(description = \"Outcome Network Status\", url = \"https://stripe.com/docs/api/charges/object#charge_object-outcome-network_status\"))\n    @Nullable String outcomeNetworkStatus,\n\n    @Schema(externalDocs = @ExternalDocumentation(description = \"Outcome Reason\", url = \"https://stripe.com/docs/api/charges/object#charge_object-outcome-reason\"))\n    @Nullable String outcomeReason,\n\n    @Schema(externalDocs = @ExternalDocumentation(description = \"Outcome Type\", url = \"https://stripe.com/docs/api/charges/object#charge_object-outcome-type\"))\n    @Nullable String outcomeType) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/CustomerAwareSubscriptionPaymentProcessor.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.util.Set;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\n/**\n * Interface for an external payment provider that has an API-accessible notion of customer that implementations can\n * manage. Payment providers that let you add and remove payment methods to an existing customer should implement this\n * interface. Contrast this with the super interface {@link SubscriptionPaymentProcessor}, which allows for a payment\n * provider with an API that only operations on subscriptions.\n */\npublic interface CustomerAwareSubscriptionPaymentProcessor extends SubscriptionPaymentProcessor {\n\n  boolean supportsPaymentMethod(PaymentMethod paymentMethod);\n\n  Set<String> getSupportedCurrenciesForPaymentMethod(PaymentMethod paymentMethod);\n\n  /**\n   * Create a customer on the payment processor\n   *\n   * @param subscriberUser An identifier that will be stored with the customer\n   * @param clientPlatform The {@link ClientPlatform} of the requesting client\n   * @return A {@link ProcessorCustomer} that can be used to identify this customer on the provider\n   */\n  ProcessorCustomer createCustomer(byte[] subscriberUser, @Nullable ClientPlatform clientPlatform);\n\n  String createPaymentMethodSetupToken(String customerId);\n\n\n  /**\n   * Set a default payment method\n   *\n   * @param customerId            The customer to add a default payment method to\n   * @param paymentMethodToken    a processor-specific token previously acquired at\n   *                              {@link #createPaymentMethodSetupToken}\n   * @param currentSubscriptionId (nullable) an active subscription ID, in case it needs an explicit update\n   * @throws SubscriptionInvalidArgumentsException If the paymentMethodToken is invalid or the payment method has not\n   *                                                finished being set up\n   */\n  void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodToken,\n      @Nullable String currentSubscriptionId) throws SubscriptionInvalidArgumentsException;\n\n  Object getSubscription(String subscriptionId);\n\n  /**\n   * Create a subscription on a customer\n   *\n   * @param customerId                The customer to create the subscription on\n   * @param templateId                An identifier for the type of subscription to create\n   * @param level                     The level of the subscription\n   * @param lastSubscriptionCreatedAt The timestamp of the last successfully created subscription\n   * @return A subscription identifier\n   * @throws SubscriptionProcessorException If there was a failure processing the charge\n   * @throws SubscriptionInvalidArgumentsException   If there was a failure because an idempotency key was reused on a\n   *                                                  modified request, or if the payment requires additional steps\n   *                                                  before charging\n   * @throws SubscriptionProcessorConflictException  If there was no payment method on the customer\n   */\n  SubscriptionId createSubscription(String customerId, String templateId, long level, long lastSubscriptionCreatedAt)\n      throws SubscriptionProcessorException, SubscriptionInvalidArgumentsException, SubscriptionProcessorConflictException;\n\n  /**\n   * Update an existing subscription on a customer\n   *\n   * @param subscription              The subscription to update\n   * @param templateId                An identifier for the new subscription type\n   * @param level                     The target level of the subscription\n   * @param idempotencyKey            An idempotency key to prevent retries of successful requests\n   * @return A subscription identifier\n   * @throws SubscriptionProcessorException If there was a failure processing the charge\n   * @throws SubscriptionInvalidArgumentsException   If there was a failure because an idempotency key was reused on a\n   *                                                  modified request, or if the payment requires additional steps\n   *                                                  before charging\n   * @throws SubscriptionProcessorConflictException  If there was no payment method on the customer\n   */\n  SubscriptionId updateSubscription(Object subscription, String templateId, long level, String idempotencyKey)\n      throws SubscriptionInvalidArgumentsException, SubscriptionProcessorException, SubscriptionProcessorConflictException;\n\n  /**\n   * @param subscription\n   * @return the subscription’s current level and lower-case currency code\n   */\n  LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscription);\n\n  record SubscriptionId(String id) {\n\n  }\n\n  record LevelAndCurrency(long level, String currency) {\n\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManager.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;\nimport com.google.api.client.googleapis.json.GoogleJsonResponseException;\nimport com.google.api.client.http.HttpResponseException;\nimport com.google.api.client.json.gson.GsonFactory;\nimport com.google.api.services.androidpublisher.AndroidPublisher;\nimport com.google.api.services.androidpublisher.AndroidPublisherRequest;\nimport com.google.api.services.androidpublisher.AndroidPublisherScopes;\nimport com.google.api.services.androidpublisher.model.AutoRenewingPlan;\nimport com.google.api.services.androidpublisher.model.Money;\nimport com.google.api.services.androidpublisher.model.OfferDetails;\nimport com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;\nimport com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;\nimport com.google.api.services.androidpublisher.model.SubscriptionPurchasesAcknowledgeRequest;\nimport com.google.auth.http.HttpCredentialsAdapter;\nimport com.google.auth.oauth2.GoogleCredentials;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.UncheckedIOException;\nimport java.security.GeneralSecurityException;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.format.DateTimeParseException;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\n\n/**\n * Manages subscriptions made with the Play Billing API\n * <p>\n * Clients create a subscription using Play Billing directly, and then notify us about their subscription with their\n * <a href=\"https://developer.android.com/google/play/billing/#concepts\">purchaseToken</a>. This class provides methods\n * for\n * <ul>\n * <li> <a href=\"https://developer.android.com/google/play/billing/security#verify\">validating purchaseTokens</a> </li>\n * <li> <a href=\"https://developer.android.com/google/play/billing/integrate#subscriptions\">acknowledging purchaseTokens</a> </li>\n * <li> querying the current status of a token's underlying subscription </li>\n * </ul>\n */\npublic class GooglePlayBillingManager implements SubscriptionPaymentProcessor {\n\n  private static final Logger logger = LoggerFactory.getLogger(GooglePlayBillingManager.class);\n\n  private final AndroidPublisher androidPublisher;\n  private final String packageName;\n  private final Map<String, Long> productIdToLevel;\n  private final Clock clock;\n\n  private static final String VALIDATE_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, \"validate\");\n  private static final String CANCEL_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, \"cancel\");\n  private static final String GET_RECEIPT_COUNTER_NAME = MetricsUtil.name(GooglePlayBillingManager.class, \"getReceipt\");\n\n\n  public GooglePlayBillingManager(\n      final InputStream credentialsStream,\n      final String packageName,\n      final String applicationName,\n      final Map<String, Long> productIdToLevel)\n      throws GeneralSecurityException, IOException {\n    this(new AndroidPublisher.Builder(\n            GoogleNetHttpTransport.newTrustedTransport(),\n            GsonFactory.getDefaultInstance(),\n            new HttpCredentialsAdapter(GoogleCredentials\n                .fromStream(credentialsStream)\n                .createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER)))\n            .setApplicationName(applicationName)\n            .build(),\n        Clock.systemUTC(), packageName, productIdToLevel);\n  }\n\n  @VisibleForTesting\n  GooglePlayBillingManager(\n      final AndroidPublisher androidPublisher,\n      final Clock clock,\n      final String packageName,\n      final Map<String, Long> productIdToLevel) {\n    this.clock = clock;\n    this.androidPublisher = androidPublisher;\n    this.productIdToLevel = productIdToLevel;\n    this.packageName = packageName;\n  }\n\n  @Override\n  public PaymentProvider getProvider() {\n    return PaymentProvider.GOOGLE_PLAY_BILLING;\n  }\n\n  /**\n   * Represents a valid purchaseToken that should be durably stored and then acknowledged with\n   * {@link #acknowledgePurchase()}\n   */\n  public class ValidatedToken {\n\n    private final long level;\n    private final String productId;\n    private final String purchaseToken;\n    // If false, the purchase has already been acknowledged\n    private final boolean requiresAck;\n\n    ValidatedToken(final long level, final String productId, final String purchaseToken, final boolean requiresAck) {\n      this.level = level;\n      this.productId = productId;\n      this.purchaseToken = purchaseToken;\n      this.requiresAck = requiresAck;\n    }\n\n    /**\n     * Acknowledge the purchase to the play billing server. If a purchase is never acknowledged, it will eventually be\n     * refunded.\n     *\n     */\n    public void acknowledgePurchase()\n        throws RateLimitExceededException, SubscriptionNotFoundException {\n      if (!requiresAck) {\n        // We've already acknowledged this purchase on a previous attempt, nothing to do\n        return;\n      }\n      executeTokenOperation(pub -> pub.purchases().subscriptions()\n          .acknowledge(packageName, productId, purchaseToken, new SubscriptionPurchasesAcknowledgeRequest()));\n    }\n\n    public long getLevel() {\n      return level;\n    }\n  }\n\n  /**\n   * Check if the purchaseToken is valid. If it's valid it should be durably associated with the user's subscriberId and\n   * then acknowledged with {@link ValidatedToken#acknowledgePurchase()}\n   *\n   * @param purchaseToken The play store billing purchaseToken that represents a subscription purchase\n   * @return A {@link ValidatedToken} that can be acknowledged\n   * @throws RateLimitExceededException            If rate-limited by play-billing\n   * @throws SubscriptionNotFoundException        If the provided purchaseToken was not found in play-billing\n   * @throws SubscriptionPaymentRequiredException If the purchaseToken exists but is in a state that does not grant the\n   *                                               user an entitlement\n   */\n  public ValidatedToken validateToken(String purchaseToken)\n      throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException {\n    final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);\n    final SubscriptionState state = SubscriptionState\n        .fromString(subscription.getSubscriptionState())\n        .orElse(SubscriptionState.UNSPECIFIED);\n\n    Metrics.counter(VALIDATE_COUNTER_NAME, subscriptionTags(subscription)).increment();\n\n    // We only accept tokens in a state where the user may be entitled to their purchase. This is true even in the\n    // CANCELLED state. For example, a user may subscribe for 1 month, then immediately cancel (disabling auto-renew)\n    // and then submit their token. In this case they should still be able to retrieve their entitlement.\n    // See https://developer.android.com/google/play/billing/integrate#life\n    if (state != SubscriptionState.ACTIVE\n        && state != SubscriptionState.IN_GRACE_PERIOD\n        && state != SubscriptionState.CANCELED) {\n      throw new SubscriptionPaymentRequiredException(\n          \"Cannot acknowledge purchase for subscription in state \" + subscription.getSubscriptionState());\n    }\n\n    final AcknowledgementState acknowledgementState = AcknowledgementState\n        .fromString(subscription.getAcknowledgementState())\n        .orElse(AcknowledgementState.UNSPECIFIED);\n\n    final boolean requiresAck = switch (acknowledgementState) {\n      case ACKNOWLEDGED -> false;\n      case PENDING -> true;\n      case UNSPECIFIED -> throw new UncheckedIOException(\n          new IOException(\"Invalid acknowledgement state \" + subscription.getAcknowledgementState()));\n    };\n\n    final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);\n    final long level = productIdToLevel(purchase.getProductId());\n\n    return new ValidatedToken(level, purchase.getProductId(), purchaseToken, requiresAck);\n  }\n\n\n  /**\n   * Cancel the subscription. Cancellation stops auto-renewal, but does not refund the user nor cut off access to their\n   * entitlement until their current period expires.\n   *\n   * @param purchaseToken The purchaseToken associated with the subscription\n   * @throws RateLimitExceededException If rate-limited by play-billing\n   */\n  public void cancelAllActiveSubscriptions(String purchaseToken) throws RateLimitExceededException {\n    try {\n      final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);\n      Metrics.counter(CANCEL_COUNTER_NAME, subscriptionTags(subscription)).increment();\n\n      final SubscriptionState state = SubscriptionState\n          .fromString(subscription.getSubscriptionState())\n          .orElse(SubscriptionState.UNSPECIFIED);\n\n      if (state == SubscriptionState.CANCELED || state == SubscriptionState.EXPIRED) {\n        // already cancelled, nothing to do\n        return;\n      }\n      final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);\n\n      executeTokenOperation(pub ->\n          pub.purchases().subscriptions().cancel(packageName, purchase.getProductId(), purchaseToken));\n    } catch (SubscriptionNotFoundException e) {\n      // If the subscription is not found there is no need to do anything, so we can squash it\n    }\n  }\n\n  @Override\n  public SubscriptionInformation getSubscriptionInformation(final String purchaseToken)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n\n    final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);\n    final SubscriptionPrice price = getSubscriptionPrice(subscription);\n\n    final SubscriptionPurchaseLineItem lineItem = getLineItem(subscription);\n    final Optional<Instant> billingCycleAnchor = getStartTime(subscription);\n    final Optional<Instant> expiration = getExpiration(lineItem);\n\n    final SubscriptionStatus status = switch (SubscriptionState\n        .fromString(subscription.getSubscriptionState())\n        .orElse(SubscriptionState.UNSPECIFIED)) {\n      // In play terminology CANCELLED is the same as an active subscription with cancelAtPeriodEnd set in Stripe. So\n      // it should map to the ACTIVE stripe status.\n      case ACTIVE, CANCELED -> SubscriptionStatus.ACTIVE;\n      case PENDING -> SubscriptionStatus.INCOMPLETE;\n      case ON_HOLD, PAUSED -> SubscriptionStatus.PAST_DUE;\n      case IN_GRACE_PERIOD -> SubscriptionStatus.UNPAID;\n      // EXPIRED is the equivalent of a Stripe CANCELLED subscription\n      case EXPIRED, PENDING_PURCHASE_CANCELED -> SubscriptionStatus.CANCELED;\n      case UNSPECIFIED -> SubscriptionStatus.UNKNOWN;\n    };\n\n    final boolean autoRenewEnabled = Optional\n        .ofNullable(lineItem.getAutoRenewingPlan())\n        .map(AutoRenewingPlan::getAutoRenewEnabled) // returns null or false if auto-renew disabled\n        .orElse(false);\n    return new SubscriptionInformation(\n        price,\n        productIdToLevel(lineItem.getProductId()),\n        billingCycleAnchor.orElse(null),\n        expiration.orElse(null),\n        expiration.map(clock.instant()::isBefore).orElse(false),\n        !autoRenewEnabled,\n        status,\n        PaymentProvider.GOOGLE_PLAY_BILLING,\n        PaymentMethod.GOOGLE_PLAY_BILLING,\n        false,\n        null);\n  }\n\n  private SubscriptionPrice getSubscriptionPrice(final SubscriptionPurchaseV2 subscriptionPurchase) {\n    final SubscriptionPurchaseLineItem lineItem = getLineItem(subscriptionPurchase);\n\n    // We don't offer pre-paid plans, so autoRenewingPlan must be nonnull\n    if (lineItem.getAutoRenewingPlan() == null) {\n      throw new UncheckedIOException(new IOException(\"Subscription purchases must be auto-renewing plans\"));\n    }\n    final Money price = lineItem.getAutoRenewingPlan().getRecurringPrice();\n    return new SubscriptionPrice(\n        price.getCurrencyCode().toUpperCase(Locale.ROOT),\n        SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(price));\n  }\n\n  @Override\n  public ReceiptItem getReceiptItem(String purchaseToken)\n      throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionPaymentRequiredException {\n    final SubscriptionPurchaseV2 subscription = lookupSubscription(purchaseToken);\n    final AcknowledgementState acknowledgementState = AcknowledgementState\n        .fromString(subscription.getAcknowledgementState())\n        .orElse(AcknowledgementState.UNSPECIFIED);\n    if (acknowledgementState != AcknowledgementState.ACKNOWLEDGED) {\n      // We should only ever generate receipts for a stored and acknowledged token.\n      logger.error(\"Tried to fetch receipt for purchaseToken {} that was never acknowledged\", purchaseToken);\n      throw new IllegalStateException(\"Tried to fetch receipt for purchaseToken that was never acknowledged\");\n    }\n\n    Metrics.counter(GET_RECEIPT_COUNTER_NAME, subscriptionTags(subscription)).increment();\n\n    final SubscriptionPurchaseLineItem purchase = getLineItem(subscription);\n    final Instant expiration = getExpiration(purchase)\n        .orElseThrow(() -> new UncheckedIOException(new IOException(\"Invalid subscription expiration\")));\n\n    if (expiration.isBefore(clock.instant())) {\n      // We don't need to check any state at this point, just whether the subscription is currently valid. If the\n      // subscription is in a grace period, the expiration time will be dynamically extended, see\n      // https://developer.android.com/google/play/billing/lifecycle/subscriptions#grace-period\n      throw new SubscriptionPaymentRequiredException();\n    }\n\n    return new ReceiptItem(\n        subscription.getLatestOrderId(),\n        PaymentTime.periodEnds(expiration),\n        productIdToLevel(purchase.getProductId()));\n  }\n\n\n  interface ApiCall<T> {\n\n    AndroidPublisherRequest<T> req(AndroidPublisher publisher) throws IOException;\n  }\n\n  /**\n   * Asynchronously execute a synchronous API call on a purchaseToken, mapping expected errors to the appropriate\n   * {@link SubscriptionException}\n   *\n   * @param apiCall An API call that operates on a purchaseToken\n   * @param <R>     The result of the API call\n   * @return A stage that completes with the result of the API call\n   */\n  private <R> R executeTokenOperation(final ApiCall<R> apiCall)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n    try {\n      return apiCall.req(androidPublisher).execute();\n    } catch (HttpResponseException e) {\n      if (e.getStatusCode() == Response.Status.NOT_FOUND.getStatusCode()\n          || e.getStatusCode() == Response.Status.GONE.getStatusCode()) {\n        throw new SubscriptionNotFoundException();\n      }\n      if (e.getStatusCode() == Response.Status.TOO_MANY_REQUESTS.getStatusCode()) {\n        throw new RateLimitExceededException(null);\n      }\n      final String details = e instanceof GoogleJsonResponseException\n          ? ((GoogleJsonResponseException) e).getDetails().toString()\n          : \"\";\n\n      final String message =\n          String.format(\"Unexpected HTTP status code %s from androidpublisher: %s\", e.getStatusCode(), details);\n      logger.warn(message);\n      throw new UncheckedIOException(new IOException(message));\n    } catch (IOException e) {\n      throw new UncheckedIOException(e);\n    }\n  }\n\n  private SubscriptionPurchaseV2 lookupSubscription(final String purchaseToken)\n      throws RateLimitExceededException, SubscriptionNotFoundException {\n    return executeTokenOperation(publisher -> publisher.purchases().subscriptionsv2().get(packageName, purchaseToken));\n  }\n\n  private long productIdToLevel(final String productId) {\n    final Long level = this.productIdToLevel.get(productId);\n    if (level == null) {\n      logger.error(\"productId={} had no associated level\", productId);\n      // This was a productId a user was able to successfully purchase from our catalog,\n      // but we don't know about it. The server's configuration is behind.\n      throw new IllegalStateException(\"no level found for productId \" + productId);\n    }\n    return level;\n  }\n\n  private SubscriptionPurchaseLineItem getLineItem(final SubscriptionPurchaseV2 subscription) {\n    final List<SubscriptionPurchaseLineItem> lineItems = subscription.getLineItems();\n    if (lineItems.isEmpty()) {\n      throw new IllegalArgumentException(\"Subscriptions should have line items\");\n    }\n    if (lineItems.size() > 1) {\n      logger.warn(\"{} line items found for purchase {}, expected 1\", lineItems.size(), subscription.getLatestOrderId());\n    }\n    return lineItems.getFirst();\n  }\n\n  private Tags subscriptionTags(final SubscriptionPurchaseV2 subscription) {\n    final boolean expired = subscription.getLineItems().isEmpty() ||\n        getExpiration(getLineItem(subscription)).orElse(Instant.EPOCH).isBefore(clock.instant());\n    return Tags.of(\n        \"expired\", Boolean.toString(expired),\n        \"subscriptionState\", subscription.getSubscriptionState(),\n        \"acknowledgementState\", subscription.getAcknowledgementState());\n  }\n\n  private Optional<Instant> getStartTime(final SubscriptionPurchaseV2 subscription) {\n    return parseTimestamp(subscription.getStartTime());\n  }\n\n  private Optional<Instant> getExpiration(final SubscriptionPurchaseLineItem purchaseLineItem) {\n    return parseTimestamp(purchaseLineItem.getExpiryTime());\n  }\n\n  private Optional<Instant> parseTimestamp(final String timestamp) {\n    if (StringUtils.isBlank(timestamp)) {\n      return Optional.empty();\n    }\n    try {\n      return Optional.of(Instant.parse(timestamp));\n    } catch (DateTimeParseException e) {\n      logger.warn(\"received a timestamp with an invalid format: {}\", timestamp);\n      return Optional.empty();\n    }\n  }\n\n  // https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#SubscriptionState\n  @VisibleForTesting\n  enum SubscriptionState {\n    UNSPECIFIED(\"SUBSCRIPTION_STATE_UNSPECIFIED\"),\n    PENDING(\"SUBSCRIPTION_STATE_PENDING\"),\n    ACTIVE(\"SUBSCRIPTION_STATE_ACTIVE\"),\n    PAUSED(\"SUBSCRIPTION_STATE_PAUSED\"),\n    IN_GRACE_PERIOD(\"SUBSCRIPTION_STATE_IN_GRACE_PERIOD\"),\n    ON_HOLD(\"SUBSCRIPTION_STATE_ON_HOLD\"),\n    CANCELED(\"SUBSCRIPTION_STATE_CANCELED\"),\n    EXPIRED(\"SUBSCRIPTION_STATE_EXPIRED\"),\n    PENDING_PURCHASE_CANCELED(\"SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED\");\n\n    private static final Map<String, SubscriptionState> VALUES = Arrays\n        .stream(SubscriptionState.values())\n        .collect(Collectors.toMap(ss -> ss.s, ss -> ss));\n\n    private final String s;\n\n    SubscriptionState(String s) {\n      this.s = s;\n    }\n\n    private static Optional<SubscriptionState> fromString(String s) {\n      return Optional.ofNullable(SubscriptionState.VALUES.getOrDefault(s, null));\n    }\n\n    @VisibleForTesting\n    String apiString() {\n      return s;\n    }\n  }\n\n  // https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2#AcknowledgementState\n  @VisibleForTesting\n  enum AcknowledgementState {\n    UNSPECIFIED(\"ACKNOWLEDGEMENT_STATE_UNSPECIFIED\"),\n    PENDING(\"ACKNOWLEDGEMENT_STATE_PENDING\"),\n    ACKNOWLEDGED(\"ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED\");\n\n    private static final Map<String, AcknowledgementState> VALUES = Arrays\n        .stream(AcknowledgementState.values())\n        .collect(Collectors.toMap(as -> as.s, ss -> ss));\n\n    private final String s;\n\n    AcknowledgementState(String s) {\n      this.s = s;\n    }\n\n    private static Optional<AcknowledgementState> fromString(String s) {\n      return Optional.ofNullable(AcknowledgementState.VALUES.getOrDefault(s, null));\n    }\n\n    @VisibleForTesting\n    String apiString() {\n      return s;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslator.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Objects;\nimport java.util.ResourceBundle;\nimport javax.annotation.Nonnull;\nimport org.signal.i18n.HeaderControlledResourceBundleLookup;\n\npublic class PayPalDonationsTranslator {\n\n  public static final String ONE_TIME_DONATION_LINE_ITEM_KEY = \"oneTime.donationLineItemName\";\n\n  private static final String BASE_NAME = \"org.signal.donations.PayPal\";\n\n  private final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup;\n\n  public PayPalDonationsTranslator(\n      @Nonnull final HeaderControlledResourceBundleLookup headerControlledResourceBundleLookup) {\n    this.headerControlledResourceBundleLookup = Objects.requireNonNull(headerControlledResourceBundleLookup);\n  }\n\n  public String translate(final List<Locale> acceptableLanguages, final String key) {\n    final ResourceBundle resourceBundle = headerControlledResourceBundleLookup.getResourceBundle(BASE_NAME,\n        acceptableLanguages);\n    return resourceBundle.getString(key);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentDetails.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.time.Instant;\nimport java.util.Map;\nimport javax.annotation.Nullable;\n\n/**\n * Payment details for a one-time payment specified by id\n *\n * @param id             The id of the payment in the payment processor\n * @param customMetadata Any custom metadata attached to the payment\n * @param status         The status of the payment in the payment processor\n * @param created        When the payment was created\n * @param chargeFailure  If present, additional information about why the payment failed. Will not be set if the status\n *                       is not {@link PaymentStatus#SUCCEEDED}\n */\npublic record PaymentDetails(String id,\n                             Map<String, String> customMetadata,\n                             PaymentStatus status,\n                             Instant created,\n                             @Nullable ChargeFailure chargeFailure) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentMethod.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic enum PaymentMethod {\n  UNKNOWN,\n  /**\n   * A credit card or debit card, including those from Apple Pay and Google Pay\n   */\n  CARD,\n  /**\n   * A PayPal account\n   */\n  PAYPAL,\n  /**\n   * A SEPA debit account\n   */\n  SEPA_DEBIT,\n  /**\n   * An iDEAL account\n   */\n  IDEAL,\n  GOOGLE_PLAY_BILLING,\n  APPLE_APP_STORE\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentProvider.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.util.Arrays;\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * A set of payment providers used for donations\n */\npublic enum PaymentProvider {\n  // because provider IDs are stored, they should not be reused, and great care\n  // must be used if a provider is removed from the list\n  STRIPE(1),\n  BRAINTREE(2),\n  GOOGLE_PLAY_BILLING(3),\n  APPLE_APP_STORE(4);\n\n  private static final Map<Integer, PaymentProvider> IDS_TO_PROCESSORS = new HashMap<>();\n\n  static {\n    Arrays.stream(PaymentProvider.values())\n        .forEach(provider -> IDS_TO_PROCESSORS.put((int) provider.id, provider));\n  }\n\n  /**\n   * @return the provider associated with the given ID, or {@code null} if none exists\n   */\n  public static PaymentProvider forId(byte id) {\n    return IDS_TO_PROCESSORS.get((int) id);\n  }\n\n  private final byte id;\n\n  PaymentProvider(int id) {\n    if (id > 255) {\n      throw new IllegalArgumentException(\"ID must fit in one byte: \" + id);\n    }\n\n    this.id = (byte) id;\n  }\n\n  public byte getId() {\n    return id;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/PaymentStatus.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic enum PaymentStatus {\n  SUCCEEDED,\n  PROCESSING,\n  FAILED,\n  UNKNOWN,\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/ProcessorCustomer.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.nio.charset.StandardCharsets;\n\npublic record ProcessorCustomer(String customerId, PaymentProvider processor) {\n\n  public byte[] toDynamoBytes() {\n    final byte[] customerIdBytes = customerId.getBytes(StandardCharsets.UTF_8);\n    final byte[] combinedBytes = new byte[customerIdBytes.length + 1];\n\n    combinedBytes[0] = processor.getId();\n    System.arraycopy(customerIdBytes, 0, combinedBytes, 1, customerIdBytes.length);\n\n    return combinedBytes;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/StripeManager.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.collect.Lists;\nimport com.stripe.Stripe;\nimport com.stripe.StripeClient;\nimport com.stripe.exception.CardException;\nimport com.stripe.exception.IdempotencyException;\nimport com.stripe.exception.InvalidRequestException;\nimport com.stripe.exception.StripeException;\nimport com.stripe.model.Charge;\nimport com.stripe.model.Customer;\nimport com.stripe.model.Invoice;\nimport com.stripe.model.InvoiceLineItem;\nimport com.stripe.model.InvoicePayment;\nimport com.stripe.model.PaymentIntent;\nimport com.stripe.model.Price;\nimport com.stripe.model.Product;\nimport com.stripe.model.SetupIntent;\nimport com.stripe.model.StripeCollection;\nimport com.stripe.model.Subscription;\nimport com.stripe.model.SubscriptionItem;\nimport com.stripe.net.RequestOptions;\nimport com.stripe.param.ChargeRetrieveParams;\nimport com.stripe.param.CustomerCreateParams;\nimport com.stripe.param.CustomerRetrieveParams;\nimport com.stripe.param.CustomerUpdateParams;\nimport com.stripe.param.CustomerUpdateParams.InvoiceSettings;\nimport com.stripe.param.PaymentIntentCreateParams;\nimport com.stripe.param.PaymentIntentRetrieveParams;\nimport com.stripe.param.PriceRetrieveParams;\nimport com.stripe.param.SetupIntentCreateParams;\nimport com.stripe.param.SetupIntentRetrieveParams;\nimport com.stripe.param.SubscriptionCancelParams;\nimport com.stripe.param.SubscriptionCreateParams;\nimport com.stripe.param.SubscriptionItemListParams;\nimport com.stripe.param.SubscriptionListParams;\nimport com.stripe.param.SubscriptionRetrieveParams;\nimport com.stripe.param.SubscriptionUpdateParams;\nimport com.stripe.param.SubscriptionUpdateParams.BillingCycleAnchor;\nimport com.stripe.param.SubscriptionUpdateParams.ProrationBehavior;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.Executor;\nimport java.util.function.Consumer;\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\nimport org.apache.commons.lang3.StringUtils;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerVersion;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\nimport org.whispersystems.textsecuregcm.util.Conversions;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport org.whispersystems.textsecuregcm.util.ExecutorUtil;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\npublic class StripeManager implements CustomerAwareSubscriptionPaymentProcessor {\n  private static final Logger logger = LoggerFactory.getLogger(StripeManager.class);\n  private static final String METADATA_KEY_LEVEL = \"level\";\n  private static final String METADATA_KEY_CLIENT_PLATFORM = \"clientPlatform\";\n\n  private final StripeClient stripeClient;\n  private final Executor executor;\n  private final byte[] idempotencyKeyGenerator;\n  private final String boostDescription;\n  private final Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod;\n\n  @VisibleForTesting\n  StripeManager(\n      @Nonnull StripeClient stripeClient,\n      @Nonnull Executor executor,\n      @Nonnull byte[] idempotencyKeyGenerator,\n      @Nonnull String boostDescription,\n      @Nonnull Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod) {\n    Stripe.setAppInfo(\"Signal-Server\", WhisperServerVersion.getServerVersion());\n\n    this.stripeClient = Objects.requireNonNull(stripeClient);\n    this.executor = Objects.requireNonNull(executor);\n    this.idempotencyKeyGenerator = Objects.requireNonNull(idempotencyKeyGenerator);\n    if (idempotencyKeyGenerator.length == 0) {\n      throw new IllegalArgumentException(\"idempotencyKeyGenerator cannot be empty\");\n    }\n    this.boostDescription = Objects.requireNonNull(boostDescription);\n    this.supportedCurrenciesByPaymentMethod = supportedCurrenciesByPaymentMethod;\n  }\n  public StripeManager(\n      @Nonnull String apiKey,\n      @Nonnull Executor executor,\n      @Nonnull byte[] idempotencyKeyGenerator,\n      @Nonnull String boostDescription,\n      @Nonnull Map<PaymentMethod, Set<String>> supportedCurrenciesByPaymentMethod) {\n    this(new StripeClient(apiKey), executor, idempotencyKeyGenerator, boostDescription, supportedCurrenciesByPaymentMethod);\n\n    if (StringUtils.isEmpty(apiKey)) {\n      throw new IllegalArgumentException(\"apiKey cannot be empty\");\n    }\n  }\n\n  @Override\n  public PaymentProvider getProvider() {\n    return PaymentProvider.STRIPE;\n  }\n\n  @Override\n  public boolean supportsPaymentMethod(PaymentMethod paymentMethod) {\n    return paymentMethod == PaymentMethod.CARD\n        || paymentMethod == PaymentMethod.SEPA_DEBIT\n        || paymentMethod == PaymentMethod.IDEAL;\n  }\n\n  private RequestOptions commonOptions() {\n    return commonOptions(null);\n  }\n\n  private RequestOptions commonOptions(@Nullable String idempotencyKey) {\n    return RequestOptions.builder()\n        .setIdempotencyKey(idempotencyKey)\n        .build();\n  }\n\n  @Override\n  public ProcessorCustomer createCustomer(final byte[] subscriberUser, @Nullable final ClientPlatform clientPlatform) {\n    final CustomerCreateParams.Builder builder = CustomerCreateParams.builder()\n        .putMetadata(\"subscriberUser\", HexFormat.of().formatHex(subscriberUser));\n\n    if (clientPlatform != null) {\n      builder.putMetadata(METADATA_KEY_CLIENT_PLATFORM, clientPlatform.name().toLowerCase());\n    }\n\n    try {\n      final Customer customer = stripeClient.v1().customers()\n          .create(builder.build(), commonOptions(generateIdempotencyKeyForSubscriberUser(subscriberUser)));\n      return new ProcessorCustomer(customer.getId(), getProvider());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  public Customer getCustomer(String customerId) {\n    CustomerRetrieveParams params = CustomerRetrieveParams.builder().build();\n    try {\n      return stripeClient.v1().customers().retrieve(customerId, params, commonOptions());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  @Override\n  public void setDefaultPaymentMethodForCustomer(String customerId, String paymentMethodId,\n      @Nullable String currentSubscriptionId) throws SubscriptionInvalidArgumentsException {\n      CustomerUpdateParams params = CustomerUpdateParams.builder()\n          .setInvoiceSettings(InvoiceSettings.builder()\n              .setDefaultPaymentMethod(paymentMethodId)\n              .build())\n          .build();\n      try {\n        stripeClient.v1().customers().update(customerId, params, commonOptions());\n      } catch (InvalidRequestException e) {\n        // Could happen if the paymentMethodId was bunk or the client didn't actually finish setting it up\n        throw new SubscriptionInvalidArgumentsException(e.getMessage());\n      } catch (StripeException e) {\n        throw new UncheckedIOException(new IOException(e));\n      }\n  }\n\n  @Override\n  public String createPaymentMethodSetupToken(String customerId) {\n    SetupIntentCreateParams params = SetupIntentCreateParams.builder()\n        .setCustomer(customerId)\n        .build();\n    try {\n      return stripeClient.v1().setupIntents().create(params, commonOptions()).getClientSecret();\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  @Override\n  public Set<String> getSupportedCurrenciesForPaymentMethod(final PaymentMethod paymentMethod) {\n    return supportedCurrenciesByPaymentMethod.getOrDefault(paymentMethod, Collections.emptySet());\n  }\n\n  /**\n   * Creates a payment intent. May throw a {@link SubscriptionInvalidAmountException} if stripe rejects the\n   * attempt if the amount is too large or too small\n   */\n  public CompletableFuture<PaymentIntent> createPaymentIntent(final String currency,\n      final long amount,\n      final long level,\n      @Nullable final ClientPlatform clientPlatform) {\n\n    return CompletableFuture.supplyAsync(() -> {\n      final PaymentIntentCreateParams.Builder builder = PaymentIntentCreateParams.builder()\n          .setAmount(amount)\n          .setCurrency(currency.toLowerCase(Locale.ROOT))\n          .setDescription(boostDescription)\n          .setCaptureMethod(PaymentIntentCreateParams.CaptureMethod.AUTOMATIC)\n          .putMetadata(\"level\", Long.toString(level));\n\n      if (clientPlatform != null) {\n        builder.putMetadata(METADATA_KEY_CLIENT_PLATFORM, clientPlatform.name().toLowerCase());\n      }\n\n      try {\n        return stripeClient.v1().paymentIntents().create(builder.build(), commonOptions());\n      } catch (StripeException e) {\n        final String errorCode = e.getCode() == null\n            ? \"unknown\"\n            : e.getCode().toLowerCase(Locale.ROOT);\n        switch (errorCode) {\n          case \"amount_too_small\",\"amount_too_large\" ->\n              throw ExceptionUtils.wrap(new SubscriptionInvalidAmountException(errorCode));\n          default -> throw new CompletionException(e);\n        }\n      }\n    }, executor);\n  }\n\n  public CompletableFuture<PaymentDetails> getPaymentDetails(String paymentIntentId) {\n    return CompletableFuture.supplyAsync(() -> {\n      try {\n        final PaymentIntent paymentIntent = getPaymentIntent(paymentIntentId);\n\n        ChargeFailure chargeFailure = null;\n        if (paymentIntent.getLatestChargeObject() != null) {\n          final Charge charge = paymentIntent.getLatestChargeObject();\n          if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {\n            chargeFailure = createChargeFailure(charge);\n          }\n        }\n\n        return new PaymentDetails(paymentIntent.getId(),\n            paymentIntent.getMetadata() == null ? Collections.emptyMap() : paymentIntent.getMetadata(),\n            getPaymentStatusForStatus(paymentIntent.getStatus()),\n            Instant.ofEpochSecond(paymentIntent.getCreated()),\n            chargeFailure);\n      } catch (StripeException e) {\n        if (e.getStatusCode() == 404) {\n          return null;\n        } else {\n          throw new CompletionException(e);\n        }\n      }\n    }, executor);\n  }\n\n  private static PaymentStatus getPaymentStatusForStatus(String status) {\n    return switch (status.toLowerCase(Locale.ROOT)) {\n      case \"processing\" -> PaymentStatus.PROCESSING;\n      case \"succeeded\" -> PaymentStatus.SUCCEEDED;\n      default -> PaymentStatus.UNKNOWN;\n    };\n  }\n\n  private static SubscriptionStatus getSubscriptionStatus(final String status) {\n    return SubscriptionStatus.forApiValue(status);\n  }\n\n  @Override\n  public SubscriptionId createSubscription(String customerId, String priceId, long level,\n      long lastSubscriptionCreatedAt)\n      throws SubscriptionProcessorException, SubscriptionInvalidArgumentsException {\n    // this relies on Stripe's idempotency key to avoid creating more than one subscription if the client\n    // retries this request\n    SubscriptionCreateParams params = SubscriptionCreateParams.builder()\n        .setCustomer(customerId)\n        .setOffSession(true)\n        .setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)\n        .addItem(SubscriptionCreateParams.Item.builder()\n            .setPrice(priceId)\n            .build())\n        .putMetadata(METADATA_KEY_LEVEL, Long.toString(level))\n        .build();\n    try {\n      // the idempotency key intentionally excludes priceId\n      //\n      // If the client tells the server several times in a row before the initial creation of a subscription to\n      // create a subscription, we want to ensure only one gets created.\n      final Subscription subscription = stripeClient.v1().subscriptions().create(\n          params,\n          commonOptions(generateIdempotencyKeyForCreateSubscription(customerId, lastSubscriptionCreatedAt)));\n      return new SubscriptionId(subscription.getId());\n    } catch (IdempotencyException e) {\n      throw new SubscriptionInvalidArgumentsException(e.getStripeError().getMessage());\n    } catch (CardException e) {\n      throw new SubscriptionProcessorException(getProvider(), createChargeFailureFromCardException(e));\n    } catch (StripeException e) {\n      if (\"subscription_payment_intent_requires_action\".equals(e.getCode())) {\n        throw new SubscriptionPaymentRequiresActionException();\n      }\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  @Override\n  public SubscriptionId updateSubscription(Object subscriptionObj, String priceId, long level, String idempotencyKey)\n      throws SubscriptionInvalidArgumentsException, SubscriptionProcessorException {\n\n    final Subscription subscription = getSubscription(subscriptionObj);\n\n    if (getSubscriptionStatus(subscription.getStatus()) == SubscriptionStatus.CANCELED) {\n      // If the existing subscription is cancelled, just create a new subscription rather than trying to update a\n      // cancelled subscription (which stripe forbids)\n      return createSubscription(subscription.getCustomer(), priceId, level, subscription.getCreated());\n    }\n\n    List<SubscriptionUpdateParams.Item> items = new ArrayList<>();\n    try {\n      final StripeCollection<SubscriptionItem> subscriptionItems = stripeClient.v1().subscriptionItems()\n          .list(SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(),\n              commonOptions());\n      for (final SubscriptionItem item : subscriptionItems.autoPagingIterable()) {\n        items.add(SubscriptionUpdateParams.Item.builder()\n            .setId(item.getId())\n            .setDeleted(true)\n            .build());\n      }\n      items.add(SubscriptionUpdateParams.Item.builder()\n          .setPrice(priceId)\n          .build());\n      SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()\n          .putMetadata(METADATA_KEY_LEVEL, Long.toString(level))\n\n          // since badge redemption is untrackable by design and unrevokable, subscription changes must be immediate and\n          // not prorated\n          .setProrationBehavior(ProrationBehavior.NONE)\n          .setBillingCycleAnchor(BillingCycleAnchor.NOW)\n          .setOffSession(true)\n          .setPaymentBehavior(SubscriptionUpdateParams.PaymentBehavior.ERROR_IF_INCOMPLETE)\n          .addAllItem(items)\n          .build();\n      final Subscription subscription1 = stripeClient.v1().subscriptions().update(subscription.getId(), params,\n          commonOptions(generateIdempotencyKeyForSubscriptionUpdate(subscription.getCustomer(), idempotencyKey)));\n      return new SubscriptionId(subscription1.getId());\n    } catch (IdempotencyException e) {\n      throw new SubscriptionInvalidArgumentsException(e.getStripeError().getMessage());\n    } catch (CardException e) {\n      throw new SubscriptionProcessorException(getProvider(), createChargeFailureFromCardException(e));\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  /// Retrieves the subscription object with `latest_invoice.payments` expanded\n  @Override\n  public Object getSubscription(String subscriptionId) {\n    SubscriptionRetrieveParams params = SubscriptionRetrieveParams.builder()\n        .addExpand(\"latest_invoice.payments\")\n        .build();\n    try {\n      return stripeClient.v1().subscriptions().retrieve(subscriptionId, params, commonOptions());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  /// Retrieves the payment intent object with `latest_charge` expanded\n  public PaymentIntent getPaymentIntent(String paymentIntentId) throws StripeException {\n    PaymentIntentRetrieveParams params = PaymentIntentRetrieveParams.builder()\n        .addExpand(\"latest_charge\")\n        .build();\n    return stripeClient.v1().paymentIntents().retrieve(paymentIntentId, params, commonOptions());\n  }\n\n  public Charge getCharge(String chargeId) {\n    ChargeRetrieveParams params = ChargeRetrieveParams.builder()\n        .build();\n    try {\n      return stripeClient.v1().charges().retrieve(chargeId, params, commonOptions());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  @Override\n  public void cancelAllActiveSubscriptions(String customerId) {\n    final Customer customer = getCustomer(customerId);\n      if (customer == null) {\n        throw new UncheckedIOException(new IOException(\"no customer record found for id \" + customerId));\n      }\n      if (StringUtils.isBlank(customer.getId()) || (!customer.getId().equals(customerId))) {\n        logger.error(\"customer ID returned by Stripe ({}) did not match query ({})\",  customerId, customer.getSubscriptions());\n        throw new UncheckedIOException(new IOException(\"unexpected customer ID returned by Stripe\"));\n      }\n\n    final Collection<Subscription> subscriptions = listNonCanceledSubscriptions(customer);\n    if (subscriptions.stream()\n        .anyMatch(subscription -> !subscription.getCustomer().equals(customerId))) {\n      logger.error(\"Subscription did not match expected customer ID: {}\", customerId);\n      throw new UncheckedIOException(new IOException(\"mismatched customer ID\"));\n    }\n    ExecutorUtil.runAll(executor, subscriptions\n        .stream()\n        .<Runnable>map(subscription -> () -> this.endSubscription(subscription))\n        .toList());\n  }\n\n  public Collection<Subscription> listNonCanceledSubscriptions(Customer customer) {\n    SubscriptionListParams params = SubscriptionListParams.builder()\n        .setCustomer(customer.getId())\n        .build();\n    try {\n      return Lists.newArrayList(\n          stripeClient.v1().subscriptions().list(params, commonOptions()).autoPagingIterable());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  @VisibleForTesting\n  void endSubscription(Subscription subscription) {\n\n    final SubscriptionStatus status = SubscriptionStatus.forApiValue(subscription.getStatus());\n    switch (status) {\n      // The payment for this period has not processed yet, we should immediately cancel to prevent any payment from\n      // going through.\n      case UNPAID, PAST_DUE, INCOMPLETE -> cancelSubscriptionImmediately(subscription);\n      // Otherwise, set the subscription to cancel at the current period end. If the subscription is active, it may\n      // continue to be used until the end of the period.\n      default -> {\n\n        final Price price = getPriceForSubscription(subscription);\n\n        if (supportedCurrenciesByPaymentMethod.values().stream()\n            .noneMatch(supported -> supported.contains(price.getCurrency()))) {\n\n          // This currency is no longer supported. Cancel-at-period-end will fail, so we must cancel immediately.\n          cancelSubscriptionImmediately(subscription);\n        } else {\n\n          cancelSubscriptionAtEndOfCurrentPeriod(subscription);\n        }\n      }\n    };\n  }\n\n  private void cancelSubscriptionImmediately(Subscription subscription) {\n    SubscriptionCancelParams params = SubscriptionCancelParams.builder().build();\n    try {\n      stripeClient.v1().subscriptions().cancel(subscription.getId(), params, commonOptions());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  private void cancelSubscriptionAtEndOfCurrentPeriod(Subscription subscription) {\n    SubscriptionUpdateParams params = SubscriptionUpdateParams.builder()\n        .setCancelAtPeriodEnd(true)\n        .build();\n    try {\n      stripeClient.v1().subscriptions().update(subscription.getId(), params, commonOptions());\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  public Collection<SubscriptionItem> getItemsForSubscription(Subscription subscription) {\n    try {\n      final StripeCollection<SubscriptionItem> subscriptionItems = stripeClient.v1().subscriptionItems().list(\n          SubscriptionItemListParams.builder().setSubscription(subscription.getId()).build(), commonOptions());\n      return Lists.newArrayList(subscriptionItems.autoPagingIterable());\n\n    } catch (final StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  public Price getPriceForSubscription(Subscription subscription) {\n    final Collection<SubscriptionItem> subscriptionItems = getItemsForSubscription(subscription);\n    if (subscriptionItems.isEmpty()) {\n      throw new IllegalStateException(\"no items found in subscription \" + subscription.getId());\n    } else if (subscriptionItems.size() > 1) {\n      throw new IllegalStateException(\n          \"too many items found in subscription \" + subscription.getId() + \"; items=\" + subscriptionItems.size());\n    } else {\n      return subscriptionItems.stream().findAny().get().getPrice();\n    }\n  }\n\n  private Product getProductForSubscription(Subscription subscription) {\n    return getProductForPrice(getPriceForSubscription(subscription).getId());\n  }\n\n  @Override\n  public LevelAndCurrency getLevelAndCurrencyForSubscription(Object subscriptionObj) {\n    final Subscription subscription = getSubscription(subscriptionObj);\n\n    final Product product = getProductForSubscription(subscription);\n    return new LevelAndCurrency(\n        getLevelForProduct(product),\n        subscription.getCurrency().toLowerCase(Locale.ROOT));\n  }\n\n  public long getLevelForPrice(Price price) {\n    return getLevelForProduct(getProductForPrice(price.getId()));\n  }\n\n  public Product getProductForPrice(String priceId) {\n    PriceRetrieveParams params = PriceRetrieveParams.builder().addExpand(\"product\").build();\n    try {\n      return stripeClient.v1().prices().retrieve(priceId, params, commonOptions()).getProductObject();\n    } catch (StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  public long getLevelForProduct(Product product) {\n    return Long.parseLong(product.getMetadata().get(METADATA_KEY_LEVEL));\n  }\n\n  private static ChargeFailure createChargeFailure(final Charge charge) {\n    Charge.Outcome outcome = charge.getOutcome();\n    return new ChargeFailure(\n        charge.getFailureCode(),\n        charge.getFailureMessage(),\n        outcome != null ? outcome.getNetworkStatus() : null,\n        outcome != null ? outcome.getReason() : null,\n        outcome != null ? outcome.getType() : null);\n  }\n\n  private static ChargeFailure createChargeFailureFromCardException(CardException e) {\n    return new ChargeFailure(\n        StringUtils.defaultIfBlank(e.getDeclineCode(), e.getCode()),\n        e.getStripeError().getMessage(),\n        null,\n        null,\n        null\n    );\n  }\n\n  @Override\n  public SubscriptionInformation getSubscriptionInformation(final String subscriptionId) {\n    final Subscription subscription = getSubscription(getSubscription(subscriptionId));\n    final Price price = getPriceForSubscription(subscription);\n    final long level = getLevelForPrice(price);\n\n    ChargeFailure chargeFailure = null;\n    boolean paymentProcessing = false;\n    PaymentMethod paymentMethod = null;\n\n    if (subscription.getLatestInvoiceObject() != null) {\n      final Invoice invoice = subscription.getLatestInvoiceObject();\n      paymentProcessing = \"open\".equalsIgnoreCase(invoice.getStatus()) || \"draft\".equalsIgnoreCase(invoice.getStatus());\n\n      final Optional<InvoicePayment> latestInvoicePayment = getMostRecentInvoicePayment(invoice);\n\n      if (latestInvoicePayment.isPresent()) {\n        final Optional<Charge> maybeCharge = getChargeForInvoicePayment(latestInvoicePayment.get());\n\n        if (maybeCharge.isPresent()) {\n          final Charge charge = maybeCharge.get();\n          if (charge.getFailureCode() != null || charge.getFailureMessage() != null) {\n            chargeFailure = createChargeFailure(charge);\n          }\n\n          if (charge.getPaymentMethodDetails() != null\n              && charge.getPaymentMethodDetails().getType() != null) {\n            paymentMethod = getPaymentMethodFromStripeString(charge.getPaymentMethodDetails().getType(), invoice.getId());\n          }\n        }\n      }\n    }\n\n    return new SubscriptionInformation(\n        new SubscriptionPrice(price.getCurrency().toUpperCase(Locale.ROOT), price.getUnitAmountDecimal()),\n        level,\n        Instant.ofEpochSecond(subscription.getBillingCycleAnchor()),\n        Instant.ofEpochSecond(subscription.getItems().getData().getFirst().getCurrentPeriodEnd()),\n        Objects.equals(subscription.getStatus(), \"active\"),\n        subscription.getCancelAtPeriodEnd(),\n        getSubscriptionStatus(subscription.getStatus()),\n        PaymentProvider.STRIPE,\n        paymentMethod,\n        paymentProcessing,\n        chargeFailure\n    );\n  }\n\n  private static PaymentMethod getPaymentMethodFromStripeString(final String paymentMethodString, final String invoiceId) {\n    return switch (paymentMethodString) {\n      case \"sepa_debit\" -> PaymentMethod.SEPA_DEBIT;\n      case \"card\" -> PaymentMethod.CARD;\n      default -> {\n        logger.error(\"Unexpected payment method from Stripe: {}, invoice id: {}\", paymentMethodString, invoiceId);\n        yield PaymentMethod.UNKNOWN;\n      }\n    };\n  }\n\n  private Subscription getSubscription(Object subscriptionObj) {\n    if (!(subscriptionObj instanceof final Subscription subscription)) {\n      throw new IllegalArgumentException(\"invalid subscription object: \" + subscriptionObj.getClass().getName());\n    }\n\n    return subscription;\n  }\n\n  @Override\n  public ReceiptItem getReceiptItem(String subscriptionId)\n      throws SubscriptionPaymentRequiredException, SubscriptionReceiptRequestedForOpenPaymentException {\n    final Invoice invoice = getSubscription(getSubscription(subscriptionId)).getLatestInvoiceObject();\n    return convertInvoiceToReceipt(invoice, subscriptionId);\n  }\n\n  private ReceiptItem convertInvoiceToReceipt(Invoice latestSubscriptionInvoice, String subscriptionId)\n      throws SubscriptionReceiptRequestedForOpenPaymentException, SubscriptionPaymentRequiredException {\n    if (latestSubscriptionInvoice == null) {\n      throw new SubscriptionReceiptRequestedForOpenPaymentException();\n    }\n    if (\"open\".equalsIgnoreCase(latestSubscriptionInvoice.getStatus()) || \"draft\".equalsIgnoreCase(latestSubscriptionInvoice.getStatus())) {\n      throw new SubscriptionReceiptRequestedForOpenPaymentException();\n    }\n    if (!\"paid\".equalsIgnoreCase(latestSubscriptionInvoice.getStatus())) {\n\n      final Optional<InvoicePayment> latestInvoicePayment = getMostRecentInvoicePayment(latestSubscriptionInvoice);\n\n      final SubscriptionPaymentRequiredException exception = latestInvoicePayment.map(invoicePayment -> {\n\n            final Optional<Charge> maybeCharge = getChargeForInvoicePayment(invoicePayment);\n\n            return maybeCharge.filter(charge -> charge.getFailureCode() != null || charge.getFailureMessage() != null)\n                .map(charge -> {\n                  // If the charge object has a failure reason we can present to the user, create a detailed exception\n                  return (SubscriptionPaymentRequiredException) (new SubscriptionChargeFailurePaymentRequiredException(\n                      getProvider(), createChargeFailure(charge)));\n                })\n                .orElseGet(SubscriptionPaymentRequiredException::new);\n          })\n          .orElseGet(SubscriptionPaymentRequiredException::new);\n\n      throw exception;\n    }\n\n    final Collection<InvoiceLineItem> invoiceLineItems = getInvoiceLineItemsForInvoice(latestSubscriptionInvoice);\n    Collection<InvoiceLineItem> subscriptionLineItems = invoiceLineItems.stream()\n        .filter(invoiceLineItem -> \"subscription_item_details\".equalsIgnoreCase(invoiceLineItem.getParent().getType()))\n        .toList();\n    if (subscriptionLineItems.isEmpty()) {\n      throw new IllegalStateException(\"latest subscription invoice has no subscription line items; subscriptionId=\"\n          + subscriptionId + \"; invoiceId=\" + latestSubscriptionInvoice.getId());\n    }\n    if (subscriptionLineItems.size() > 1) {\n      throw new IllegalStateException(\n          \"latest subscription invoice has too many subscription line items; subscriptionId=\" + subscriptionId\n              + \"; invoiceId=\" + latestSubscriptionInvoice.getId() + \"; count=\" + subscriptionLineItems.size());\n    }\n\n    InvoiceLineItem subscriptionLineItem = subscriptionLineItems.stream().findAny().get();\n    return getReceiptForSubscription(subscriptionLineItem, latestSubscriptionInvoice);\n  }\n\n  private ReceiptItem getReceiptForSubscription(InvoiceLineItem subscriptionLineItem, Invoice invoice) {\n    final Instant paidAt;\n    if (invoice.getStatusTransitions().getPaidAt() != null) {\n      paidAt = Instant.ofEpochSecond(invoice.getStatusTransitions().getPaidAt());\n    } else {\n      logger.warn(\"No paidAt timestamp exists for paid invoice {}, falling back to start of subscription period\",\n          invoice.getId());\n      paidAt = Instant.ofEpochSecond(subscriptionLineItem.getPeriod().getStart());\n    }\n    final Product product = getProductForPrice(subscriptionLineItem.getPricing().getPriceDetails().getPrice());\n    return new ReceiptItem(\n        subscriptionLineItem.getId(),\n        PaymentTime.periodStart(paidAt),\n        getLevelForProduct(product));\n  }\n\n  public Collection<InvoiceLineItem> getInvoiceLineItemsForInvoice(Invoice invoice) {\n    try {\n      final StripeCollection<InvoiceLineItem> lineItems = stripeClient.v1().invoices().lineItems()\n          .list(invoice.getId(), commonOptions());\n      return Lists.newArrayList(lineItems.autoPagingIterable());\n    } catch (final StripeException e) {\n      throw new UncheckedIOException(new IOException(e));\n    }\n  }\n\n  public CompletableFuture<String> getGeneratedSepaIdFromSetupIntent(String setupIntentId) {\n    return CompletableFuture.supplyAsync(() -> {\n      SetupIntentRetrieveParams params = SetupIntentRetrieveParams.builder()\n          .addExpand(\"latest_attempt\")\n          .build();\n      try {\n        final SetupIntent setupIntent = stripeClient.v1().setupIntents().retrieve(setupIntentId, params, commonOptions());\n        if (setupIntent.getLatestAttemptObject() == null\n            || setupIntent.getLatestAttemptObject().getPaymentMethodDetails() == null\n            || setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal() == null\n            || setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal().getGeneratedSepaDebit() == null) {\n          // This usually indicates that the client has made requests out of order, either by not confirming\n          // the SetupIntent or not having the user authorize the transaction.\n          logger.debug(\"setupIntent {} missing expected fields\", setupIntentId);\n          throw ExceptionUtils.wrap(new SubscriptionProcessorConflictException());\n        }\n        return setupIntent.getLatestAttemptObject().getPaymentMethodDetails().getIdeal().getGeneratedSepaDebit();\n      } catch (StripeException e) {\n        if (e.getStatusCode() == 404) {\n          throw ExceptionUtils.wrap(new SubscriptionNotFoundException());\n        }\n        logger.error(\"unexpected error from Stripe when retrieving setupIntent {}\", setupIntentId, e);\n        throw ExceptionUtils.wrap(e);\n      }\n    }, executor);\n  }\n\n  private Optional<InvoicePayment> getMostRecentInvoicePayment(final Invoice invoice) {\n    final List<InvoicePayment> sorted = new ArrayList<>(invoice.getPayments().getData());\n    sorted.sort(Comparator.comparingLong(InvoicePayment::getCreated).reversed());\n\n    return sorted.isEmpty() ? Optional.empty() : Optional.of(sorted.getFirst());\n  }\n\n  private Optional<Charge> getChargeForInvoicePayment(final InvoicePayment invoicePayment) {\n    return Optional.ofNullable(invoicePayment.getPayment().getPaymentIntent())\n        .map(paymentIntentId -> {\n          try {\n            return getPaymentIntent(paymentIntentId);\n          } catch (final StripeException e) {\n            throw new UncheckedIOException(new IOException(e));\n          }\n        })\n        .map(PaymentIntent::getLatestChargeObject)\n        .or(() -> Optional.ofNullable(invoicePayment.getPayment().getCharge()).map(this::getCharge));\n  }\n\n  /**\n   * We use a client generated idempotency key for subscription updates due to not being able to distinguish between a\n   * call to update to level 2, then back to level 1, then back to level 2. If this all happens within Stripe's\n   * idempotency window the subsequent update call would not happen unless we get some indication from the client that\n   * it is intentionally sending a repeat of the update to level 2 request because user is changing again, so in this\n   * case we derive idempotency from the client.\n   */\n  private String generateIdempotencyKeyForSubscriptionUpdate(String customerId, String idempotencyKey) {\n    return generateIdempotencyKey(\"subscriptionUpdate\", mac -> {\n      mac.update(customerId.getBytes(StandardCharsets.UTF_8));\n      mac.update(idempotencyKey.getBytes(StandardCharsets.UTF_8));\n    });\n  }\n\n  private String generateIdempotencyKeyForSubscriberUser(byte[] subscriberUser) {\n    return generateIdempotencyKey(\"subscriberUser\", mac -> mac.update(subscriberUser));\n  }\n\n  private String generateIdempotencyKeyForCreateSubscription(String customerId, long lastSubscriptionCreatedAt) {\n    return generateIdempotencyKey(\"customerId\", mac -> {\n      mac.update(customerId.getBytes(StandardCharsets.UTF_8));\n      mac.update(Conversions.longToByteArray(lastSubscriptionCreatedAt));\n    });\n  }\n\n  private String generateIdempotencyKey(String type, Consumer<Mac> byteConsumer) {\n    try {\n      Mac mac = Mac.getInstance(\"HmacSHA256\");\n      mac.init(new SecretKeySpec(idempotencyKeyGenerator, \"HmacSHA256\"));\n      mac.update(type.getBytes(StandardCharsets.UTF_8));\n      byteConsumer.accept(mac);\n      return Base64.getUrlEncoder().encodeToString(mac.doFinal());\n    } catch (NoSuchAlgorithmException | InvalidKeyException e) {\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionChargeFailurePaymentRequiredException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionChargeFailurePaymentRequiredException extends SubscriptionPaymentRequiredException {\n\n  private final PaymentProvider processor;\n  private final ChargeFailure chargeFailure;\n\n  public SubscriptionChargeFailurePaymentRequiredException(final PaymentProvider processor,\n      final ChargeFailure chargeFailure) {\n    super();\n    this.processor = processor;\n    this.chargeFailure = chargeFailure;\n  }\n\n  public PaymentProvider getProcessor() {\n    return processor;\n  }\n\n  public ChargeFailure getChargeFailure() {\n    return chargeFailure;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.google.api.services.androidpublisher.model.Money;\nimport java.math.BigDecimal;\nimport java.util.Locale;\nimport java.util.Set;\n\n/**\n * Utility for scaling amounts among Stripe, Braintree, configuration, and API responses.\n * <p>\n * In general, the API input and output follow’s Stripe’s <a href= >specification</a> to use amounts in a currency’s\n * smallest unit. The exception is configuration APIs, which return values in the currency’s primary unit. Braintree\n * uses the currency’s primary unit for its input and output.\n * <h2>Examples</h2>\n * <table>\n *   <thead>\n *     <td>Currency, Amount</td>API</td><td>Stripe</td><td>Braintree</td>\n *   </thead>\n *   <tbody>\n *   <tr>\n *     <td>USD 4.99</td><td>499</td><td>499</td><td>4.99</td>\n *   </tr>\n *   <tr>\n *     <td>JPY 501</td><td>501</td><td>501</td><td>501</td>\n *   </tr>\n *   </tbody>\n * </table>\n */\npublic class SubscriptionCurrencyUtil {\n\n  // This list was taken from https://stripe.com/docs/currencies?presentment-currency=US\n  // Braintree\n  private static final Set<String> stripeZeroDecimalCurrencies = Set.of(\"bif\", \"clp\", \"djf\", \"gnf\", \"jpy\", \"kmf\", \"krw\",\n      \"mga\", \"pyg\", \"rwf\", \"vnd\", \"vuv\", \"xaf\", \"xof\", \"xpf\");\n\n\n  /**\n   * Takes an amount as configured and turns it into an amount as API clients (and Stripe) expect to see it. For\n   * instance, {@code USD 4.99} return {@code 499}, while {@code JPY 500} returns {@code 500}.\n   *\n   * <p>\n   * Stripe appears to only support zero- and two-decimal currencies, but also has some backwards compatibility issues\n   * with 0 decimal currencies, so this is not to any ISO standard but rather directly from Stripe's API doc page.\n   */\n  public static BigDecimal convertConfiguredAmountToApiAmount(String currency, BigDecimal configuredAmount) {\n    if (stripeZeroDecimalCurrencies.contains(currency.toLowerCase(Locale.ROOT))) {\n      return configuredAmount;\n    }\n\n    return configuredAmount.scaleByPowerOfTen(2);\n  }\n\n  /**\n   * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,\n   * BigDecimal)\n   */\n  public static BigDecimal convertConfiguredAmountToStripeAmount(String currency, BigDecimal configuredAmount) {\n    return convertConfiguredAmountToApiAmount(currency, configuredAmount);\n  }\n\n  /**\n   * Braintree’s API expects amounts in a currency’s primary unit (e.g. USD 4.99)\n   *\n   * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,\n   * BigDecimal)\n   */\n  static BigDecimal convertBraintreeAmountToApiAmount(final String currency, final BigDecimal amount) {\n    return convertConfiguredAmountToApiAmount(currency, amount);\n  }\n\n  /**\n   * Convert Play Billing's representation of currency amounts to a Stripe-style amount\n   *\n   * @see org.whispersystems.textsecuregcm.subscriptions.SubscriptionCurrencyUtil#convertConfiguredAmountToApiAmount(String,\n   * BigDecimal)\n   */\n  static BigDecimal convertGoogleMoneyToApiAmount(final Money money) {\n    final BigDecimal fractionalComponent = money.getNanos() == null\n        ? BigDecimal.ZERO\n        : BigDecimal.valueOf(money.getNanos()).scaleByPowerOfTen(-9);\n    final BigDecimal amount = BigDecimal.valueOf(money.getUnits()).add(fractionalComponent);\n    return convertConfiguredAmountToApiAmount(money.getCurrencyCode(), amount);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionException.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.util.Optional;\nimport javax.annotation.Nullable;\n\npublic class SubscriptionException extends Exception {\n\n  private @Nullable String errorDetail;\n\n  public SubscriptionException(Exception cause) {\n    this(cause, null);\n  }\n\n  SubscriptionException(Exception cause, String errorDetail) {\n    super(cause);\n    this.errorDetail = errorDetail;\n  }\n\n  /**\n   * @return An error message suitable to include in a client response\n   */\n  public Optional<String> errorDetail() {\n    return Optional.ofNullable(errorDetail);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionForbiddenException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionForbiddenException extends SubscriptionException {\n\n  public SubscriptionForbiddenException(final String message) {\n    super(null, message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInformation.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.time.Instant;\nimport javax.annotation.Nullable;\n\npublic record SubscriptionInformation(\n    SubscriptionPrice price,\n    long level,\n    Instant billingCycleAnchor,\n    Instant endOfCurrentPeriod,\n    boolean active,\n    boolean cancelAtPeriodEnd,\n    SubscriptionStatus status,\n    PaymentProvider paymentProvider,\n    PaymentMethod paymentMethod,\n    boolean paymentProcessing,\n    @Nullable ChargeFailure chargeFailure) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidAmountException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionInvalidAmountException extends SubscriptionInvalidArgumentsException {\n\n  private String errorCode;\n\n  public SubscriptionInvalidAmountException(String errorCode) {\n    super(null, null);\n    this.errorCode = errorCode;\n  }\n\n  public String getErrorCode() {\n    return errorCode;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidArgumentsException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionInvalidArgumentsException extends SubscriptionException {\n\n  public SubscriptionInvalidArgumentsException(final String message, final Exception cause) {\n    super(cause, message);\n  }\n\n  public SubscriptionInvalidArgumentsException(final String message) {\n    this(message, null);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionInvalidLevelException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionInvalidLevelException extends SubscriptionInvalidArgumentsException {\n\n  public SubscriptionInvalidLevelException() {\n    super(null, null);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionNotFoundException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionNotFoundException extends SubscriptionException {\n\n  public SubscriptionNotFoundException() {\n    super(null);\n  }\n\n  public SubscriptionNotFoundException(Exception cause) {\n    super(cause);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentProcessor.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\n\npublic interface SubscriptionPaymentProcessor {\n\n  PaymentProvider getProvider();\n\n  /**\n   * A receipt of payment from a payment provider\n   *\n   * @param itemId      An identifier for the payment that should be unique within the payment provider. Note that this\n   *                    must identify an actual individual charge, not the subscription as a whole.\n   * @param paymentTime The time this payment was for\n   * @param level       The level which this payment corresponds to\n   */\n  record ReceiptItem(String itemId, PaymentTime paymentTime, long level) {}\n\n  /**\n   * Retrieve a {@link ReceiptItem} for the subscriptionId stored in the subscriptions table\n   *\n   * @param subscriptionId A subscriptionId that potentially corresponds to a valid subscription\n   * @return A {@link ReceiptItem} if the subscription is valid\n   * @throws RateLimitExceededException                          If rate-limited\n   * @throws SubscriptionNotFoundException                       If the provided subscriptionId could not be found with\n   *                                                             the provider\n   * @throws SubscriptionPaymentRequiredException                If the subscription is in a state does not grant the\n   *                                                             user an entitlement\n   * @throws SubscriptionReceiptRequestedForOpenPaymentException If a receipt was requested while a payment transaction\n   *                                                             was still open\n   */\n  ReceiptItem getReceiptItem(String subscriptionId)\n      throws RateLimitExceededException, SubscriptionNotFoundException, SubscriptionChargeFailurePaymentRequiredException, SubscriptionPaymentRequiredException, SubscriptionReceiptRequestedForOpenPaymentException;\n\n  /**\n   * Cancel all active subscriptions for this key within the payment processor.\n   *\n   * @param key An identifier for the subscriber within the payment provider, corresponds to the customerId field in the\n   *            subscriptions table\n   * @throws RateLimitExceededException             If rate-limited\n   * @throws SubscriptionInvalidArgumentsException If a precondition for cancellation was not met\n   */\n  void cancelAllActiveSubscriptions(String key)\n      throws SubscriptionInvalidArgumentsException, RateLimitExceededException;\n\n  /**\n   * Retrieve subscription information from the processor\n   *\n   * @param subscriptionId The identifier with the processor to retrieve information for\n   * @return {@link SubscriptionInformation} from the provider\n   * @throws RateLimitExceededException    If rate-limited\n   * @throws SubscriptionNotFoundException If the provided key was not found with the provider\n   */\n  SubscriptionInformation getSubscriptionInformation(final String subscriptionId)\n      throws RateLimitExceededException, SubscriptionNotFoundException;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiredException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionPaymentRequiredException extends SubscriptionException {\n\n  public SubscriptionPaymentRequiredException() {\n    super(null, null);\n  }\n\n  public SubscriptionPaymentRequiredException(String message) {\n    super(null, message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPaymentRequiresActionException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionPaymentRequiresActionException extends SubscriptionInvalidArgumentsException {\n\n  public SubscriptionPaymentRequiresActionException(String message) {\n    super(message, null);\n  }\n\n  public SubscriptionPaymentRequiresActionException() {\n    super(null, null);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionPrice.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport java.math.BigDecimal;\n\npublic record SubscriptionPrice(String currency, BigDecimal amount) {}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorConflictException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionProcessorConflictException extends SubscriptionException {\n\n  public SubscriptionProcessorConflictException() {\n    super(null, null);\n  }\n\n  public SubscriptionProcessorConflictException(final String message) {\n    super(null, message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionProcessorException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\npublic class SubscriptionProcessorException extends SubscriptionException {\n\n  private final PaymentProvider processor;\n  private final ChargeFailure chargeFailure;\n\n  public SubscriptionProcessorException(final PaymentProvider processor, final ChargeFailure chargeFailure) {\n    super(null, null);\n    this.processor = processor;\n    this.chargeFailure = chargeFailure;\n  }\n\n  public PaymentProvider getProcessor() {\n    return processor;\n  }\n\n  public ChargeFailure getChargeFailure() {\n    return chargeFailure;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionReceiptRequestedForOpenPaymentException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\n/**\n * Attempted to retrieve a receipt for a subscription that hasn't yet been charged or the invoice is in the open state\n */\npublic class SubscriptionReceiptRequestedForOpenPaymentException extends SubscriptionException {\n\n  public SubscriptionReceiptRequestedForOpenPaymentException() {\n    super(null, null);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionStatus.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic enum SubscriptionStatus {\n  /**\n   * The subscription is in good standing and the most recent payment was successful.\n   */\n  ACTIVE(\"active\"),\n\n  /**\n   * Payment failed when creating the subscription, or the subscription’s start date is in the future.\n   */\n  INCOMPLETE(\"incomplete\"),\n\n  /**\n   * Payment on the latest renewal failed but there are processor retries left, or payment wasn't attempted.\n   */\n  PAST_DUE(\"past_due\"),\n\n  /**\n   * The subscription has been canceled.\n   */\n  CANCELED(\"canceled\"),\n\n  /**\n   * The latest renewal hasn't been paid but the subscription remains in place.\n   */\n  UNPAID(\"unpaid\"),\n\n  /**\n   * The status from the downstream processor is unknown.\n   */\n  UNKNOWN(\"unknown\");\n\n\n  private final String apiValue;\n\n  SubscriptionStatus(String apiValue) {\n    this.apiValue = apiValue;\n  }\n\n  public static SubscriptionStatus forApiValue(String status) {\n    return switch (status) {\n      case \"active\" -> ACTIVE;\n      case \"canceled\", \"incomplete_expired\" -> CANCELED;\n      case \"unpaid\" -> UNPAID;\n      case \"past_due\" -> PAST_DUE;\n      case \"incomplete\" -> INCOMPLETE;\n\n      case \"trialing\" -> {\n        final Logger logger = LoggerFactory.getLogger(CustomerAwareSubscriptionPaymentProcessor.class);\n        logger.error(\"Subscription has status that should never happen: {}\", status);\n\n        yield UNKNOWN;\n      }\n      default -> {\n        final Logger logger = LoggerFactory.getLogger(CustomerAwareSubscriptionPaymentProcessor.class);\n        logger.error(\"Subscription has unknown status: {}\", status);\n\n        yield UNKNOWN;\n      }\n    };\n  }\n\n  public String getApiValue() {\n    return apiValue;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierData.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony;\n\nimport java.util.Optional;\n\n/// Line type and home network information for a specific phone number.\n///\n/// @param carrierName  the name of the network operator for the specified phone number\n/// @param lineType     the line type for the specified phone number\n/// @param mcc          the mobile country code (MCC) of the phone number's home network if known; may be empty if the\n///                     phone number is not a mobile number\n/// @param mnc          the mobile network code (MNC) of the phone number's home network if known; may be empty if the\n///                     phone number is not a mobile number\n/// @param isPorted     indicates whether the number has been ported from its original network to another network; may\n///                     be empty if not known\n/// @param isDisposable indicates whether the number is believed to be connected to a service that offers pooled,\n///                     \"disposable\" numbers; may be empty if not known\npublic record CarrierData(String carrierName,\n                          LineType lineType,\n                          Optional<String> mcc,\n                          Optional<String> mnc,\n                          Optional<Boolean> isPorted,\n                          Optional<Boolean> isDisposable) {\n\n  public enum LineType {\n    MOBILE,\n    LANDLINE,\n    FIXED_VOIP,\n    NON_FIXED_VOIP,\n    OTHER,\n    UNKNOWN\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataException.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony;\n\n/// Indicates that a request for carrier data failed permanently (e.g. it was affirmatively rejected by the provider)\n/// and should not be retried without modification.\npublic class CarrierDataException extends Exception {\n\n  public CarrierDataException(final String message) {\n    super(message);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/CarrierDataProvider.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony;\n\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.Optional;\n\n/// A carrier data provider returns line type and home network information about a specific phone number. Carrier data\n/// providers may cache results and return cached results if they are newer than a given maximum age.\npublic interface CarrierDataProvider {\n\n  /// Retrieves carrier data for a given phone number.\n  ///\n  /// @param phoneNumber the phone number for which to retrieve line type and home network information\n  /// @param maxCachedAge the maximum age of a cached response to return; providers must attempt to fetch fresh data if\n  /// cached data is older than the given maximum age, and may choose to fetch fresh data under any circumstances\n  ///\n  /// @return line type and home network information for the given phone number if available or empty if this provider\n  /// could not find information for the given phone number\n  ///\n  /// @throws IOException if the provider could not be reached due to a network problem of any kind\n  /// @throws CarrierDataException if the request failed and should not be retried without modification\n  Optional<CarrierData> lookupCarrierData(Phonenumber.PhoneNumber phoneNumber, Duration maxCachedAge)\n      throws IOException, CarrierDataException;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProvider.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport javax.annotation.Nullable;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.telephony.CarrierData;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataException;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n/// A carrier data provider that uses [HLR Lookup](https://www.hlrlookup.com/) as its data source.\npublic class HlrLookupCarrierDataProvider implements CarrierDataProvider {\n\n  private final String apiKey;\n  private final String apiSecret;\n\n  private final FaultTolerantHttpClient httpClient;\n  private final URI lookupUri;\n\n  private static final URI HLR_LOOKUP_URI = URI.create(\"https://api.hlrlookup.com/apiv2/hlr\");\n\n  private static final ObjectMapper OBJECT_MAPPER = SystemMapper.jsonMapper();\n\n  private static final String REQUEST_COUNTER_NAME = MetricsUtil.name(HlrLookupCarrierDataProvider.class, \"request\");\n  private static final String RESULT_COUNTER_NAME = MetricsUtil.name(HlrLookupCarrierDataProvider.class, \"result\");\n\n  private static final String CREDITS_SPENT_TAG_NAME = \"creditsSpent\";\n\n  public HlrLookupCarrierDataProvider(final String apiKey,\n      final String apiSecret,\n      final Executor httpRequestExecutor,\n      @Nullable final String circuitBreakerConfigurationName,\n      @Nullable final String retryConfigurationName,\n      final ScheduledExecutorService retryExecutor) {\n\n    this(apiKey,\n        apiSecret,\n        FaultTolerantHttpClient.newBuilder(\"hlr-lookup\", httpRequestExecutor)\n            .withCircuitBreaker(circuitBreakerConfigurationName)\n            .withRetry(retryConfigurationName, retryExecutor)\n            .build(),\n        HLR_LOOKUP_URI);\n  }\n\n  @VisibleForTesting\n  HlrLookupCarrierDataProvider(final String apiKey,\n      final String apiSecret,\n      final FaultTolerantHttpClient httpClient,\n      final URI lookupUri) {\n\n    this.apiKey = apiKey;\n    this.apiSecret = apiSecret;\n    this.httpClient = httpClient;\n    this.lookupUri = lookupUri;\n  }\n\n  @Override\n  public Optional<CarrierData> lookupCarrierData(final Phonenumber.PhoneNumber phoneNumber,\n      final Duration maxCachedAge) throws IOException, CarrierDataException {\n\n    final HlrLookupResponse response;\n\n    try {\n      final String requestJson = OBJECT_MAPPER.writeValueAsString(\n          new HlrLookupRequest(apiKey, apiSecret,\n              List.of(TelephoneNumberRequest.forPhoneNumber(phoneNumber, maxCachedAge))));\n\n      final HttpRequest request = HttpRequest\n          .newBuilder(lookupUri)\n          .POST(HttpRequest.BodyPublishers.ofString(requestJson))\n          .header(\"Content-Type\", \"application/json\")\n          .build();\n\n      final HttpResponse<String> httpResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofString());\n\n      if (httpResponse.statusCode() != 200) {\n        // We may or may not have helpful data in the response body\n        try {\n          final HlrLookupResponse hlrLookupResponse = parseResponse(httpResponse.body());\n\n          if (StringUtils.isNotBlank(hlrLookupResponse.error()) || StringUtils.isNotBlank(\n              hlrLookupResponse.message())) {\n            throw new CarrierDataException(\n                \"Received a non-success status code (%d): error = %s; message = %s\".formatted(\n                    httpResponse.statusCode(), hlrLookupResponse.error(), hlrLookupResponse.message()));\n          }\n        } catch (final JsonProcessingException _) {\n          // We couldn't parse the body, so just move on with the default error message\n        }\n\n        throw new CarrierDataException(\"Received a non-success status code (%d)\".formatted(httpResponse.statusCode()));\n      }\n\n      response = parseResponse(httpResponse.body());\n\n      Metrics.counter(REQUEST_COUNTER_NAME, \"status\", String.valueOf(httpResponse.statusCode()))\n          .increment();\n    } catch (final IOException | CarrierDataException e) {\n      Metrics.counter(REQUEST_COUNTER_NAME, \"exception\", e.getClass().getSimpleName())\n          .increment();\n\n      throw e;\n    }\n\n    if (response.results() == null || response.results().isEmpty()) {\n      throw new CarrierDataException(\"No error reported, but no results provided\");\n    }\n\n    final HlrLookupResult result = response.results().getFirst();\n\n    if (!result.error().equals(\"NONE\")) {\n      Metrics.counter(RESULT_COUNTER_NAME, Tags.of(\n              Tag.of(\"error\", result.error()),\n              getCreditsSpentTag(result)))\n          .increment();\n\n      throw new CarrierDataException(\"Received a per-number error: \" + result.error());\n    }\n\n    Metrics.counter(RESULT_COUNTER_NAME, Tags.of(getCreditsSpentTag(result)))\n        .increment();\n\n    return getNetworkDetails(result)\n        .map(networkDetails -> new CarrierData(\n            networkDetails.name(),\n            lineType(result.telephoneNumberType()),\n            mccFromMccMnc(networkDetails.mccmnc()),\n            mncFromMccMnc(networkDetails.mccmnc()),\n            isPorted(result.isPorted()),\n            isDisposableNumber(result.disposableNumber())));\n  }\n\n  private static Tag getCreditsSpentTag(final HlrLookupResult hlrLookupResult) {\n    // HLR Lookup's docs at https://www.hlrlookup.com/knowledge/full-api-result suggest:\n    //\n    // > Please parse the full float to 1 decimal place to allow for future changes (e.g. service which consumes 1.5 or\n    // > 0.5 credits).\n    return Tag.of(CREDITS_SPENT_TAG_NAME, \"%.1f\".formatted(hlrLookupResult.creditsSpent()));\n  }\n\n  @VisibleForTesting\n  static Optional<String> mccFromMccMnc(@Nullable final String mccMnc) {\n    // MCCs are always 3 digits\n    return Optional.ofNullable(StringUtils.stripToNull(mccMnc))\n        .map(trimmedMccMnc -> trimmedMccMnc.substring(0, 3));\n  }\n\n  @VisibleForTesting\n  static Optional<String> mncFromMccMnc(@Nullable final String mccMnc) {\n    // MNCs may be 2 or 3 digits, but always come after a 3-digit MCC\n    return Optional.ofNullable(StringUtils.stripToNull(mccMnc))\n        .map(trimmedMccMnc -> StringUtils.stripToNull(trimmedMccMnc.substring(3)));\n  }\n\n  @VisibleForTesting\n  static CarrierData.LineType lineType(@Nullable final String telephoneNumberType) {\n    return switch (telephoneNumberType) {\n      case \"MOBILE\" -> CarrierData.LineType.MOBILE;\n      case \"LANDLINE\", \"MOBILE_OR_LANDLINE\" -> CarrierData.LineType.LANDLINE;\n      case \"VOIP\" -> CarrierData.LineType.NON_FIXED_VOIP;\n      case \"UNKNOWN\" -> CarrierData.LineType.UNKNOWN;\n      case null -> CarrierData.LineType.UNKNOWN;\n      default -> CarrierData.LineType.OTHER;\n    };\n  }\n\n  @VisibleForTesting\n  static Optional<NetworkDetails> getNetworkDetails(final HlrLookupResult hlrLookupResult) {\n    if (hlrLookupResult.currentNetwork().equals(\"AVAILABLE\")) {\n      return Optional.of(hlrLookupResult.currentNetworkDetails());\n    } else if (hlrLookupResult.originalNetwork().equals(\"AVAILABLE\")) {\n      return Optional.of(hlrLookupResult.originalNetworkDetails());\n    }\n\n    return Optional.empty();\n  }\n\n  @VisibleForTesting\n  static Optional<Boolean> isPorted(@Nullable final String isPorted) {\n    return switch (isPorted) {\n      case \"YES\" -> Optional.of(true);\n      case \"NO\" -> Optional.of(false);\n      case null, default -> Optional.empty();\n    };\n  }\n\n  @VisibleForTesting\n  static Optional<Boolean> isDisposableNumber(@Nullable final String disposableNumber) {\n    return switch (disposableNumber) {\n      case \"YES\" -> Optional.of(true);\n      case \"NO\" -> Optional.of(false);\n      case null, default -> Optional.empty();\n    };\n  }\n\n  @VisibleForTesting\n  static HlrLookupResponse parseResponse(final String responseJson) throws JsonProcessingException {\n    return OBJECT_MAPPER.readValue(responseJson, HlrLookupResponse.class);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupRequest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport com.fasterxml.jackson.databind.PropertyNamingStrategies;\nimport com.fasterxml.jackson.databind.annotation.JsonNaming;\nimport java.util.List;\n\n@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)\nrecord HlrLookupRequest(String apiKey, String apiSecret, List<TelephoneNumberRequest> requests) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResponse.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport javax.annotation.Nullable;\nimport java.util.List;\n\nrecord HlrLookupResponse(@Nullable List<HlrLookupResult> results,\n                         @Nullable String error,\n                         @Nullable String message) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupResult.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport com.fasterxml.jackson.databind.PropertyNamingStrategies;\nimport com.fasterxml.jackson.databind.annotation.JsonNaming;\n\n@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)\nrecord HlrLookupResult(String error,\n                       float creditsSpent,\n                       String originalNetwork,\n                       NetworkDetails originalNetworkDetails,\n                       String currentNetwork,\n                       NetworkDetails currentNetworkDetails,\n                       String telephoneNumberType,\n                       String isPorted,\n                       String disposableNumber) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/NetworkDetails.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport com.fasterxml.jackson.databind.PropertyNamingStrategies;\nimport com.fasterxml.jackson.databind.annotation.JsonNaming;\n\n@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)\nrecord NetworkDetails(String name,\n                      String mccmnc,\n                      String countryName,\n                      String countryIso3,\n                      String area,\n                      String countryPrefix) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/TelephoneNumberRequest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.PropertyNamingStrategies;\nimport com.fasterxml.jackson.databind.annotation.JsonNaming;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport org.apache.commons.lang3.StringUtils;\nimport javax.annotation.Nullable;\nimport java.time.Duration;\n\n@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)\nrecord TelephoneNumberRequest(String telephoneNumber,\n                              @Nullable Integer cacheDaysGlobal,\n                              @Nullable Integer cacheDaysPrivate,\n                              @JsonProperty(\"save_to_cache\") String saveToGlobalCache) {\n\n  static TelephoneNumberRequest forPhoneNumber(final Phonenumber.PhoneNumber phoneNumber, final Duration maxCachedAge) {\n\n    return new TelephoneNumberRequest(\n        StringUtils.stripStart(PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164), \"+\"),\n        (int) maxCachedAge.toDays(),\n        (int) maxCachedAge.toDays(),\n        \"NO\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/AbstractPublicKeyDeserializer.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonParseException;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport java.io.IOException;\nimport java.util.Base64;\nimport io.micrometer.core.instrument.Metrics;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\nabstract class AbstractPublicKeyDeserializer<K> extends JsonDeserializer<K> {\n\n  private final String invalidKeyCounterName = MetricsUtil.name(getClass(), \"invalidKey\");\n\n  private static final String REASON_TAG_NAME = \"reason\";\n\n  @Override\n  public K deserialize(final JsonParser parser, final DeserializationContext context) throws IOException {\n    final byte[] publicKeyBytes;\n\n    try {\n      publicKeyBytes = Base64.getDecoder().decode(parser.getValueAsString());\n    } catch (final IllegalArgumentException e) {\n      Metrics.counter(invalidKeyCounterName, REASON_TAG_NAME, \"illegal-base64\").increment();\n      throw new JsonParseException(parser, \"Could not parse public key as a base64-encoded value\", e);\n    }\n\n    if (publicKeyBytes.length == 0) {\n      return null;\n    }\n\n    try {\n      return deserializePublicKey(publicKeyBytes);\n    } catch (final InvalidKeyException e) {\n      Metrics.counter(invalidKeyCounterName, REASON_TAG_NAME, \"invalid-key\").increment();\n      throw new JsonParseException(parser, \"Could not interpret key bytes as a public key\", e);\n    }\n  }\n\n  protected abstract K deserializePublicKey(final byte[] publicKeyBytes) throws InvalidKeyException;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/AbstractPublicKeySerializer.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\n\nabstract class AbstractPublicKeySerializer<K> extends JsonSerializer<K> {\n\n  @Override\n  public void serialize(final K publicKey,\n      final JsonGenerator jsonGenerator,\n      final SerializerProvider serializerProvider) throws IOException {\n\n    jsonGenerator.writeString(Base64.getEncoder().encodeToString(serializePublicKey(publicKey)));\n  }\n\n  protected abstract byte[] serializePublicKey(final K publicKey);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/AsyncTimerUtil.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport io.micrometer.core.instrument.Timer;\nimport javax.annotation.Nonnull;\nimport java.util.concurrent.CompletionStage;\nimport java.util.function.Supplier;\n\npublic class AsyncTimerUtil {\n  @Nonnull\n  public static <T> CompletionStage<T> record(final Timer timer, final Supplier<CompletionStage<T>> toRecord)  {\n    final Timer.Sample sample = Timer.start();\n    return toRecord.get().whenComplete((ignoreT, ignoreE) -> sample.stop(timer));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/AttributeValues.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.nio.ByteBuffer;\nimport java.util.Base64;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Metrics;\nimport org.apache.commons.lang3.StringUtils;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\n\n/** AwsAV provides static helper methods for working with AWS AttributeValues. */\npublic class AttributeValues {\n\n  // Clear-type methods\n\n  public static AttributeValue b(byte[] value) {\n    return AttributeValue.builder().b(SdkBytes.fromByteArray(value)).build();\n  }\n\n  public static AttributeValue b(ByteBuffer value) {\n    return AttributeValue.builder().b(SdkBytes.fromByteBuffer(value)).build();\n  }\n\n  public static AttributeValue b(UUID value) {\n    return b(UUIDUtil.toByteBuffer(value));\n  }\n\n  public static AttributeValue n(long value) {\n    return AttributeValue.builder().n(String.valueOf(value)).build();\n  }\n\n  public static AttributeValue s(String value) {\n    return AttributeValue.builder().s(value).build();\n  }\n\n  public static AttributeValue m(Map<String, AttributeValue> value) {\n    return AttributeValue.builder().m(value).build();\n  }\n\n  // More opinionated methods\n\n  public static AttributeValue fromString(String value) {\n    return AttributeValue.builder().s(value).build();\n  }\n\n  public static AttributeValue fromLong(long value) {\n    return AttributeValue.builder().n(Long.toString(value)).build();\n  }\n\n  public static AttributeValue fromBool(boolean value) { return AttributeValue.builder().bool(value).build(); }\n\n  public static AttributeValue fromInt(int value) {\n    return AttributeValue.builder().n(Integer.toString(value)).build();\n  }\n\n  public static AttributeValue fromByteArray(byte[] value) {\n    return AttributeValues.fromSdkBytes(SdkBytes.fromByteArray(value));\n  }\n\n  public static AttributeValue fromByteBuffer(ByteBuffer value) {\n    return AttributeValues.fromSdkBytes(SdkBytes.fromByteBuffer(value));\n  }\n\n  public static AttributeValue fromUUID(UUID uuid) {\n    return AttributeValues.fromSdkBytes(SdkBytes.fromByteArrayUnsafe(UUIDUtil.toBytes(uuid)));\n  }\n\n  public static AttributeValue fromSdkBytes(SdkBytes value) {\n    return AttributeValue.builder().b(value).build();\n  }\n\n  private static boolean toBool(AttributeValue av) {\n    return av.bool();\n  }\n\n  private static int toInt(AttributeValue av) {\n    return Integer.parseInt(av.n());\n  }\n\n  private static long toLong(AttributeValue av) {\n    return Long.parseLong(av.n());\n  }\n\n  private static UUID toUUID(AttributeValue av) {\n    return UUIDUtil.fromBytes(av.b().asByteArrayUnsafe());  // We're guaranteed not to modify the byte array\n  }\n\n  private static byte[] toByteArray(AttributeValue av) {\n    return av.b().asByteArray();\n  }\n\n  private static String toString(AttributeValue av) {\n    return av.s();\n  }\n\n  public static Optional<AttributeValue> get(Map<String, AttributeValue> item, String key) {\n    return Optional.ofNullable(item.get(key));\n  }\n\n  public static boolean getBool(Map<String, AttributeValue> item, String key, boolean defaultValue) {\n    return AttributeValues.get(item, key).map(AttributeValues::toBool).orElse(defaultValue);\n  }\n\n  public static int getInt(Map<String, AttributeValue> item, String key, int defaultValue) {\n    return AttributeValues.get(item, key).map(AttributeValues::toInt).orElse(defaultValue);\n  }\n\n  public static String getString(Map<String, AttributeValue> item, String key, String defaultValue) {\n    return AttributeValues.get(item, key).map(AttributeValues::toString).orElse(defaultValue);\n  }\n\n  public static long getLong(Map<String, AttributeValue> item, String key, long defaultValue) {\n    return AttributeValues.get(item, key).map(AttributeValues::toLong).orElse(defaultValue);\n  }\n\n  public static byte[] getByteArray(Map<String, AttributeValue> item, String key, byte[] defaultValue) {\n    return AttributeValues.get(item, key).map(AttributeValues::toByteArray).orElse(defaultValue);\n  }\n\n  public static UUID getUUID(Map<String, AttributeValue> item, String key, UUID defaultValue) {\n    return AttributeValues.get(item, key).filter(av -> av.b() != null).map(AttributeValues::toUUID).orElse(defaultValue);\n  }\n\n  /**\n   * Extracts a byte array from an {@link AttributeValue} that may be either a byte array or a base64-encoded string.\n   *\n   * @param attributeValue the {@code AttributeValue} from which to extract a byte array\n   *\n   * @return the byte array represented by the given {@code AttributeValue}\n   */\n  @VisibleForTesting\n  public static byte[] extractByteArray(final AttributeValue attributeValue, final String counterName) {\n    if (attributeValue.b() != null) {\n      Metrics.counter(counterName, \"format\", \"bytes\").increment();\n      return attributeValue.b().asByteArray();\n    } else if (StringUtils.isNotBlank(attributeValue.s())) {\n      Metrics.counter(counterName, \"format\", \"string\").increment();\n      return Base64.getDecoder().decode(attributeValue.s());\n    }\n\n    throw new IllegalArgumentException(\"Attribute value has neither a byte array nor a string value\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/BackupAuthCredentialAdapter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParseException;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.io.IOException;\nimport java.util.Base64;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.internal.ByteArray;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\npublic class BackupAuthCredentialAdapter {\n\n  private static final Counter INVALID_BASE64_COUNTER =\n      Metrics.counter(MetricsUtil.name(BackupAuthCredentialAdapter.class, \"invalidBase64\"));\n\n  private static final Counter INVALID_BYTES_COUNTER =\n      Metrics.counter(MetricsUtil.name(BackupAuthCredentialAdapter.class, \"invalidBackupAuthObject\"));\n\n  abstract static class GenericDeserializer<T> extends JsonDeserializer<T> {\n\n    abstract T deserialize(final byte[] bytes) throws InvalidInputException;\n\n    @Override\n    public T deserialize(final JsonParser parser, final DeserializationContext deserializationContext)\n        throws IOException {\n      final byte[] bytes;\n      try {\n        bytes = Base64.getDecoder().decode(parser.getValueAsString());\n      } catch (final IllegalArgumentException e) {\n        INVALID_BASE64_COUNTER.increment();\n        throw new JsonParseException(parser, \"Could not parse string as a base64-encoded value\", e);\n      }\n      try {\n        return deserialize(bytes);\n      } catch (InvalidInputException e) {\n        INVALID_BYTES_COUNTER.increment();\n        throw new JsonParseException(parser, \"Could not interpret bytes as a BackupAuth object\");\n      }\n    }\n  }\n\n  static class GenericSerializer<T extends ByteArray> extends JsonSerializer<T> {\n\n    @Override\n    public void serialize(final T t, final JsonGenerator jsonGenerator, final SerializerProvider serializerProvider)\n        throws IOException {\n      jsonGenerator.writeString(Base64.getEncoder().encodeToString(t.serialize()));\n    }\n  }\n\n  public static class CredentialRequestSerializer extends GenericSerializer<BackupAuthCredentialRequest> {}\n  public static class CredentialRequestDeserializer extends GenericDeserializer<BackupAuthCredentialRequest> {\n    @Override\n    BackupAuthCredentialRequest deserialize(final byte[] bytes) throws InvalidInputException {\n      return new BackupAuthCredentialRequest(bytes);\n    }\n  }\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/BoundedVirtualThreadFactory.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tags;\nimport java.util.concurrent.ThreadFactory;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\n/**\n * A thread factory that creates virtual threads but limits the total number of virtual threads created.\n */\npublic class BoundedVirtualThreadFactory implements ThreadFactory {\n\n  private static final Logger logger = LoggerFactory.getLogger(BoundedVirtualThreadFactory.class);\n\n  private final AtomicInteger runningThreads = new AtomicInteger();\n  private final ThreadFactory delegate;\n  private final int maxConcurrentThreads;\n\n  private final Counter created;\n  private final Counter completed;\n\n  public BoundedVirtualThreadFactory(final String threadPoolName, final int maxConcurrentThreads) {\n    this.maxConcurrentThreads = maxConcurrentThreads;\n\n    final Tags tags = Tags.of(\"pool\", threadPoolName);\n    Metrics.gauge(\n        MetricsUtil.name(BoundedVirtualThreadFactory.class, \"active\"),\n        tags, runningThreads, (rt) -> (double) rt.get());\n    this.created = Metrics.counter(MetricsUtil.name(BoundedVirtualThreadFactory.class, \"created\"), tags);\n    this.completed = Metrics.counter(MetricsUtil.name(BoundedVirtualThreadFactory.class, \"completed\"), tags);\n\n    // The virtual thread factory will initialize thread names by appending the thread index to the provided prefix\n    this.delegate = Thread.ofVirtual().name(threadPoolName + \"-\", 0).factory();\n\n  }\n\n  @Override\n  public Thread newThread(final Runnable r) {\n    if (!tryAcquire()) {\n      return null;\n    }\n    Thread thread = null;\n    try {\n      final Runnable wrapped = () -> {\n        try {\n          r.run();\n        } finally {\n          release();\n        }\n      };\n      thread = delegate.newThread(wrapped);\n    } finally {\n      if (thread == null) {\n        release();\n      }\n    }\n    return thread;\n  }\n\n\n  @VisibleForTesting\n  int getRunningThreads() {\n    return runningThreads.get();\n  }\n\n  private boolean tryAcquire() {\n    int old;\n    do {\n      old = runningThreads.get();\n      if (old >= maxConcurrentThreads) {\n        return false;\n      }\n    } while (!runningThreads.compareAndSet(old, old + 1));\n    created.increment();\n    return true;\n  }\n\n  private void release() {\n    int updated = runningThreads.decrementAndGet();\n    if (updated < 0) {\n      logger.error(\"Released a thread and count was {}, which should never happen\", updated);\n    }\n    completed.increment();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/BufferingInterceptor.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.ext.WriterInterceptor;\nimport jakarta.ws.rs.ext.WriterInterceptorContext;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.OutputStream;\nimport org.glassfish.jersey.message.internal.CommittingOutputStream;\n\n\n/**\n * This is an elaborate workaround to avoid doing blocking operations under synchronized blocks, which is currently a\n * suboptimal case for virtual threads.\n * <p>\n * Jersey's {@link CommittingOutputStream} has two modes: direct write and buffered writes. In buffered mode, if the\n * total amount written does not exceed the output stream's buffer size, CommittingOutputStream will compute the\n * content-length for us. However, when it passes through our write to its own underlying output stream it uses\n * {@link ByteArrayOutputStream#writeTo(OutputStream)} which performs the write under a synchronized block.\n * <p>\n * If we just disable buffering, we lose our content length. However, we can't really set content-length ourselves\n * without the same access to internal state that CommittingOutputStream has. Fortunately, the underlying OutputStream\n * wrapped by CommittingOutputStream ALSO has an internal buffer, and can compute the content-length from that if the\n * content fits. But to make use of that, we need to avoid flushing that output stream until calling close, so that the\n * underlying output stream can see that it has all the data. Unfortunately the runtime inserts manual flushes after\n * writes rather than letting the underlying output stream handle it.\n * <p>\n * So here we disable buffering on CommittingOutputStream, and buffer ourselves. We don't write anything to the\n * CommittingOutputStream until we are going to close, and we do nothing on flush.\n */\npublic class BufferingInterceptor implements WriterInterceptor {\n\n  @Override\n  public void aroundWriteTo(final WriterInterceptorContext ctx) throws IOException, WebApplicationException {\n    final OutputStream orig = ctx.getOutputStream();\n    if (Thread.currentThread().isVirtual() && orig instanceof CommittingOutputStream cos) {\n      cos.enableBuffering(0);\n      ctx.setOutputStream(new BufferingOutputStream(cos));\n    }\n    ctx.proceed();\n  }\n\n  private static class BufferingOutputStream extends ByteArrayOutputStream {\n\n    private final CommittingOutputStream original;\n\n    BufferingOutputStream(final CommittingOutputStream original) {\n      this.original = original;\n    }\n\n    @Override\n    public void close() throws IOException {\n      original.write(buf, 0, count);\n      original.close();\n      super.close();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayAdapter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\n\npublic class ByteArrayAdapter {\n\n  public static class Serializing extends JsonSerializer<byte[]> {\n    @Override\n    public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)\n        throws IOException {\n      jsonGenerator.writeString(Base64.getEncoder().withoutPadding().encodeToString(bytes));\n    }\n  }\n\n  public static class Deserializing extends JsonDeserializer<byte[]> {\n    @Override\n    public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {\n      return Base64.getDecoder().decode(jsonParser.getValueAsString());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayBase64UrlAdapter.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\n\npublic class ByteArrayBase64UrlAdapter {\n  public static class Serializing extends JsonSerializer<byte[]> {\n    @Override\n    public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)\n        throws IOException {\n      jsonGenerator.writeString(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));\n    }\n  }\n\n  public static class Deserializing extends JsonDeserializer<byte[]> {\n    @Override\n    public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {\n      return Base64.getUrlDecoder().decode(jsonParser.getValueAsString());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ByteArrayBase64WithPaddingAdapter.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\n\npublic class ByteArrayBase64WithPaddingAdapter {\n  public static class Serializing extends JsonSerializer<byte[]> {\n    @Override\n    public void serialize(byte[] bytes, JsonGenerator jsonGenerator, SerializerProvider serializerProvider)\n        throws IOException {\n      jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));\n    }\n  }\n\n  public static class Deserializing extends JsonDeserializer<byte[]> {\n    @Override\n    public byte[] deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {\n      return Base64.getDecoder().decode(jsonParser.getValueAsString());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/CertificateUtil.java",
    "content": "/*\n * Copyright 2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.security.KeyStore;\nimport java.security.KeyStoreException;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.cert.CertificateException;\nimport java.security.cert.CertificateFactory;\nimport java.security.cert.X509Certificate;\n\npublic class CertificateUtil {\n\n  public static KeyStore buildKeyStoreForPem(final String... caCertificatePems) throws CertificateException {\n    try {\n      final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());\n      keyStore.load(null);\n\n      for (int i = 0; i < caCertificatePems.length; i++) {\n        final X509Certificate certificate = getCertificate(caCertificatePems[i]);\n\n        if (certificate == null) {\n          throw new CertificateException(\"No certificate found in parsing!\");\n        }\n\n        keyStore.setCertificateEntry(\"ca-\" + i, certificate);\n      }\n\n      return keyStore;\n    } catch (IOException | KeyStoreException ex) {\n      throw new CertificateException(ex);\n    } catch (NoSuchAlgorithmException ex) {\n      throw new AssertionError(ex);\n    }\n  }\n\n  public static X509Certificate getCertificate(final String certificatePem) throws CertificateException {\n    final CertificateFactory certificateFactory = CertificateFactory.getInstance(\"X.509\");\n\n    try (final ByteArrayInputStream pemInputStream = new ByteArrayInputStream(certificatePem.getBytes())) {\n      return (X509Certificate) certificateFactory.generateCertificate(pemInputStream);\n    } catch (IOException e) {\n      throw new CertificateException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ClosableEpoch.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.util.concurrent.atomic.AtomicInteger;\n\n/**\n * A closable epoch is a concurrency construct that measures the number of callers in some critical section. A closable\n * epoch can be closed to prevent new callers from entering the critical section, and takes a specific action when the\n * critical section is empty after closure.\n */\npublic class ClosableEpoch {\n\n  private final Runnable onCloseHandler;\n\n  private final AtomicInteger state = new AtomicInteger();\n\n  private static final int CLOSING_BIT_MASK = 0x00000001;\n\n  /**\n   * Constructs a new closable epoch that will execute the given handler when the epoch is closed and all callers have\n   * departed the critical section. The handler will be executed on the thread that calls {@link #close()} if the\n   * critical section is empty at the time of the call or on the last thread to call {@link #depart()} otherwise.\n   * Callers should provide handlers that delegate execution to a specific thread/executor if more precise control over\n   * which thread runs the handler is required.\n   *\n   * @param onCloseHandler a handler to run when the epoch is closed and all callers have departed the critical section\n   */\n  public ClosableEpoch(final Runnable onCloseHandler) {\n    this.onCloseHandler = onCloseHandler;\n  }\n\n  /**\n   * Announces the arrival of a caller at the start of the critical section. If the caller is allowed to enter the\n   * critical section, the epoch's internal caller counter is incremented accordingly.\n   *\n   * @return {@code true} if the caller is allowed to enter the critical section or {@code false} if it is not allowed\n   * to enter the critical section because this epoch is closing\n   */\n  public boolean tryArrive() {\n    // Increment the number of active callers if and only if we're not closing. We add 2 because the lowest bit encodes\n    // the \"closing\" state, and the bits above it encode the actual call count. More verbosely, we're doing\n    // `state += (1 << 1)` to avoid overwriting the closing state bit.\n    return !isClosing(state.updateAndGet(state -> isClosing(state) ? state : state + 2));\n  }\n\n  /**\n   * Announces the departure of a caller from the critical section. If the epoch is closing and the caller is the last\n   * to depart the critical section, then the epoch will fire its {@code onCloseHandler}.\n   */\n  public void depart() {\n    // Decrement the active caller count unconditionally. As with `tryActive`, we work in increments of 2 to \"dodge\" the\n    // \"is closing\" bit. If the call count is zero and we're closing then `state` will just have the \"closing\" bit set.\n    if (state.addAndGet(-2) == CLOSING_BIT_MASK) {\n      onCloseHandler.run();\n    }\n  }\n\n  /**\n   * Closes this epoch, preventing new callers from entering the critical section. If the critical section is empty when\n   * this method is called, it will trigger the {@code onCloseHandler} immediately. Otherwise, the\n   * {@code onCloseHandler} will fire when the last caller departs the critical section.\n   *\n   * @throws IllegalStateException if this epoch is already closed; note that this exception is thrown on a\n   * \"best-effort\" basis to help callers detect bugs\n   */\n  public void close() {\n    // Note that this is not airtight and is a \"best-effort\" check\n    if (isClosing(state.get())) {\n      throw new IllegalStateException(\"Epoch already closed\");\n    }\n\n    // Set the \"closing\" bit. If the closing bit is the only bit set, then the call count is zero and we can call the\n    // \"on close\" handler.\n    if (state.updateAndGet(state -> state | CLOSING_BIT_MASK) == CLOSING_BIT_MASK) {\n      onCloseHandler.run();\n    }\n  }\n\n  @VisibleForTesting\n  int getActiveCallers() {\n    return state.get() >> 1;\n  }\n\n  private static boolean isClosing(final int state) {\n    return (state & CLOSING_BIT_MASK) != 0;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/CompletableFutureUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.util.concurrent.FutureCallback;\nimport com.google.common.util.concurrent.Futures;\nimport com.google.common.util.concurrent.ListenableFuture;\n\nimport javax.annotation.Nullable;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\n\npublic class CompletableFutureUtil {\n\n  public static <T> CompletableFuture<T> toCompletableFuture(final ListenableFuture<T> listenableFuture,\n      final Executor callbackExecutor) {\n    final CompletableFuture<T> completableFuture = new CompletableFuture<>();\n\n    Futures.addCallback(listenableFuture, new FutureCallback<T>() {\n      @Override\n      public void onSuccess(@Nullable final T result) {\n        completableFuture.complete(result);\n      }\n\n      @Override\n      public void onFailure(final Throwable throwable) {\n        completableFuture.completeExceptionally(throwable);\n      }\n    }, callbackExecutor);\n\n    return completableFuture;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/Constants.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport io.dropwizard.util.DataSize;\n\npublic class Constants {\n  public static final String METRICS_NAME = \"textsecure\";\n  public static final int MAXIMUM_STICKER_SIZE_BYTES = (int) DataSize.kibibytes(300 + 1).toBytes(); // add 1 kiB for encryption overhead\n  public static final int MAXIMUM_STICKER_MANIFEST_SIZE_BYTES = (int) DataSize.kibibytes(10).toBytes();\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/Conversions.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\npublic class Conversions {\n\n  public static byte intsToByteHighAndLow(int highValue, int lowValue) {\n    return (byte)((highValue << 4 | lowValue) & 0xFF);\n  }\n\n  public static int highBitsToInt(byte value) {\n    return (value & 0xFF) >> 4;\n  }\n\n  public static int lowBitsToInt(byte value) {\n    return (value & 0xF);\n  }\n\n  public static int highBitsToMedium(int value) {\n    return (value >> 12);\n  }\n\n  public static int lowBitsToMedium(int value) {\n    return (value & 0xFFF);\n  }\n\n  public static byte[] shortToByteArray(int value) {\n    byte[] bytes = new byte[2];\n    shortToByteArray(bytes, 0, value);\n    return bytes;\n  }\n\n  public static int shortToByteArray(byte[] bytes, int offset, int value) {\n    bytes[offset+1] = (byte)value;\n    bytes[offset]   = (byte)(value >> 8);\n    return 2;\n  }\n\n  public static int shortToLittleEndianByteArray(byte[] bytes, int offset, int value) {\n    bytes[offset]   = (byte)value;\n    bytes[offset+1] = (byte)(value >> 8);\n    return 2;\n  }\n\n  public static byte[] mediumToByteArray(int value) {\n    byte[] bytes = new byte[3];\n    mediumToByteArray(bytes, 0, value);\n    return bytes;\n  }\n\n  public static int mediumToByteArray(byte[] bytes, int offset, int value) {\n    bytes[offset + 2] = (byte)value;\n    bytes[offset + 1] = (byte)(value >> 8);\n    bytes[offset]     = (byte)(value >> 16);\n    return 3;\n  }\n\n  public static byte[] intToByteArray(int value) {\n    byte[] bytes = new byte[4];\n    intToByteArray(bytes, 0, value);\n    return bytes;\n  }\n\n  public static int intToByteArray(byte[] bytes, int offset, int value) {\n    bytes[offset + 3] = (byte)value;\n    bytes[offset + 2] = (byte)(value >> 8);\n    bytes[offset + 1] = (byte)(value >> 16);\n    bytes[offset]     = (byte)(value >> 24);\n    return 4;\n  }\n\n  public static int intToLittleEndianByteArray(byte[] bytes, int offset, int value) {\n    bytes[offset]   = (byte)value;\n    bytes[offset+1] = (byte)(value >> 8);\n    bytes[offset+2] = (byte)(value >> 16);\n    bytes[offset+3] = (byte)(value >> 24);\n    return 4;\n  }\n\n  public static byte[] longToByteArray(long l) {\n    byte[] bytes = new byte[8];\n    longToByteArray(bytes, 0, l);\n    return bytes;\n  }\n\n  public static int longToByteArray(byte[] bytes, int offset, long value) {\n    bytes[offset + 7] = (byte)value;\n    bytes[offset + 6] = (byte)(value >> 8);\n    bytes[offset + 5] = (byte)(value >> 16);\n    bytes[offset + 4] = (byte)(value >> 24);\n    bytes[offset + 3] = (byte)(value >> 32);\n    bytes[offset + 2] = (byte)(value >> 40);\n    bytes[offset + 1] = (byte)(value >> 48);\n    bytes[offset]     = (byte)(value >> 56);\n    return 8;\n  }\n\n  public static int longTo4ByteArray(byte[] bytes, int offset, long value) {\n    bytes[offset + 3] = (byte)value;\n    bytes[offset + 2] = (byte)(value >> 8);\n    bytes[offset + 1] = (byte)(value >> 16);\n    bytes[offset + 0] = (byte)(value >> 24);\n    return 4;\n  }\n\n  public static int byteArrayToShort(byte[] bytes) {\n    return byteArrayToShort(bytes, 0);\n  }\n\n  public static int byteArrayToShort(byte[] bytes, int offset) {\n    return\n      (bytes[offset] & 0xff) << 8 | (bytes[offset + 1] & 0xff);\n  }\n\n  // The SSL patented 3-byte Value.\n  public static int byteArrayToMedium(byte[] bytes, int offset) {\n    return\n      (bytes[offset]     & 0xff) << 16 |\n      (bytes[offset + 1] & 0xff) << 8  |\n      (bytes[offset + 2] & 0xff);\n  }\n\n  public static int byteArrayToInt(byte[] bytes) {\n    return byteArrayToInt(bytes, 0);\n  }\n\n  public static int byteArrayToInt(byte[] bytes, int offset)  {\n    return\n      (bytes[offset]     & 0xff) << 24 |\n      (bytes[offset + 1] & 0xff) << 16 |\n      (bytes[offset + 2] & 0xff) << 8  |\n      (bytes[offset + 3] & 0xff);\n  }\n\n  public static int byteArrayToIntLittleEndian(byte[] bytes, int offset) {\n    return\n      (bytes[offset + 3] & 0xff) << 24 |\n      (bytes[offset + 2] & 0xff) << 16 |\n      (bytes[offset + 1] & 0xff) << 8  |\n      (bytes[offset]     & 0xff);\n  }\n\n  public static long byteArrayToLong(byte[] bytes) {\n    return byteArrayToLong(bytes, 0);\n  }\n\n  public static long byteArray4ToLong(byte[] bytes, int offset) {\n    return\n        ((bytes[offset + 0] & 0xffL) << 24) |\n        ((bytes[offset + 1] & 0xffL) << 16) |\n        ((bytes[offset + 2] & 0xffL) << 8)  |\n        ((bytes[offset + 3] & 0xffL));\n  }\n\n  public static long byteArrayToLong(byte[] bytes, int offset) {\n    return\n      ((bytes[offset]     & 0xffL) << 56) |\n      ((bytes[offset + 1] & 0xffL) << 48) |\n      ((bytes[offset + 2] & 0xffL) << 40) |\n      ((bytes[offset + 3] & 0xffL) << 32) |\n      ((bytes[offset + 4] & 0xffL) << 24) |\n      ((bytes[offset + 5] & 0xffL) << 16) |\n      ((bytes[offset + 6] & 0xffL) << 8)  |\n      ((bytes[offset + 7] & 0xffL));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapter.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport java.io.IOException;\nimport java.util.EnumSet;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class DeviceCapabilityAdapter {\n\n  private static final TypeReference<Map<String, Boolean>> STRING_TO_BOOLEAN_MAP_TYPE = new TypeReference<>() {};\n\n  private DeviceCapabilityAdapter() {\n  }\n\n  public static class Serializer extends JsonSerializer<Set<DeviceCapability>> {\n\n    @Override\n    public void serialize(final Set<DeviceCapability> capabilities,\n        final JsonGenerator jsonGenerator,\n        final SerializerProvider serializerProvider) throws IOException {\n\n      jsonGenerator.writeObject(capabilities.stream()\n          .collect(Collectors.toMap(DeviceCapability::getName, ignored -> true)));\n    }\n  }\n\n  public static class Deserializer extends JsonDeserializer<Set<DeviceCapability>> {\n\n    @Override\n    public Set<DeviceCapability> deserialize(final JsonParser jsonParser,\n        final DeserializationContext deserializationContext) throws IOException {\n\n      return mapToSet(jsonParser.readValueAs(STRING_TO_BOOLEAN_MAP_TYPE));\n    }\n\n  }\n\n  public static Set<DeviceCapability> mapToSet(Map<String, Boolean> capabilitiesMap) {\n    return capabilitiesMap.entrySet()\n        .stream()\n        .filter(Map.Entry::getValue)\n        .map(entry -> DeviceCapability.forName(entry.getKey()))\n        .filter(Optional::isPresent)\n        .map(Optional::get)\n        .collect(Collectors.toCollection(() -> EnumSet.noneOf(DeviceCapability.class)));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/DeviceNameByteArrayAdapter.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport java.io.IOException;\nimport java.util.Base64;\n\n/**\n * Serializes a byte array as a standard Base64-encoded string and deserializes Base64-encoded strings to byte arrays,\n * but treats any string that cannot be parsed as Base64 to {@code null}.\n * <p/>\n * Historically, device names were passed around as weakly-typed strings with the expectation that clients would provide\n * Base64 strings, but nothing in the server ever verified that was the case. In the absence of strict validation, some\n * third-party clients started submitting unencrypted names for devices, and so device names in persistent storage are a\n * mix of Base64-encoded device name ciphertexts from first-party clients and plaintext device names from third-party\n * clients. This adapter will discard the latter.\n */\npublic class DeviceNameByteArrayAdapter {\n\n  private static final Counter UNPARSEABLE_DEVICE_NAME_COUNTER =\n      Metrics.counter(MetricsUtil.name(DeviceNameByteArrayAdapter.class, \"unparseableDeviceName\"));\n\n  public static class Serializer extends JsonSerializer<byte[]> {\n    @Override\n    public void serialize(final byte[] bytes,\n        final JsonGenerator jsonGenerator,\n        final SerializerProvider serializerProvider) throws IOException {\n\n      jsonGenerator.writeString(Base64.getEncoder().encodeToString(bytes));\n    }\n  }\n\n  public static class Deserializer extends JsonDeserializer<byte[]> {\n    @Override\n    public byte[] deserialize(final JsonParser jsonParser,\n        final DeserializationContext deserializationContext) throws IOException {\n\n      try {\n        return Base64.getDecoder().decode(jsonParser.getValueAsString());\n      } catch (final IllegalArgumentException e) {\n        UNPARSEABLE_DEVICE_NAME_COUNTER.increment();\n        return null;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/E164.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static java.lang.annotation.ElementType.FIELD;\nimport static java.lang.annotation.ElementType.METHOD;\nimport static java.lang.annotation.ElementType.PARAMETER;\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\nimport jakarta.validation.Constraint;\nimport jakarta.validation.ConstraintValidator;\nimport jakarta.validation.ConstraintValidatorContext;\nimport jakarta.validation.Payload;\nimport java.lang.annotation.Documented;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\nimport java.util.Objects;\nimport java.util.Optional;\n\n/**\n * Constraint annotation that requires annotated entity\n * to hold (or return) a string value that is a valid E164-normalized phone number.\n */\n@Target({ FIELD, PARAMETER, METHOD })\n@Retention(RUNTIME)\n@Constraint(validatedBy = {\n    E164.Validator.class,\n    E164.OptionalValidator.class\n})\n@Documented\npublic @interface E164 {\n\n  String message() default \"value is not a valid E164 number\";\n\n  Class<?>[] groups() default { };\n\n  Class<? extends Payload>[] payload() default { };\n\n  class Validator implements ConstraintValidator<E164, String> {\n\n    @Override\n    public boolean isValid(final String value, final ConstraintValidatorContext context) {\n      if (Objects.isNull(value)) {\n        return true;\n      }\n      if (!value.startsWith(\"+\")) {\n        return false;\n      }\n      try {\n        Util.requireNormalizedNumber(value);\n      } catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) {\n        return false;\n      }\n      return true;\n    }\n  }\n\n  class OptionalValidator implements ConstraintValidator<E164, Optional<String>> {\n\n    @Override\n    public boolean isValid(final Optional<String> value, final ConstraintValidatorContext context) {\n        return value.map(s -> new Validator().isValid(s, context)).orElse(true);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ECPublicKeyAdapter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\n\npublic class ECPublicKeyAdapter {\n\n  public static class Serializer extends AbstractPublicKeySerializer<ECPublicKey> {\n\n    @Override\n    protected byte[] serializePublicKey(final ECPublicKey publicKey) {\n      return publicKey.serialize();\n    }\n  }\n\n  public static class Deserializer extends AbstractPublicKeyDeserializer<ECPublicKey> {\n\n    @Override\n    protected ECPublicKey deserializePublicKey(final byte[] publicKeyBytes) throws InvalidKeyException {\n      return new ECPublicKey(publicKeyBytes);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/EncryptDeviceCreationTimestampUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport java.nio.ByteBuffer;\n\npublic class EncryptDeviceCreationTimestampUtil {\n  @VisibleForTesting\n  final static String ENCRYPTION_INFO = \"deviceCreatedAt\";\n\n  /**\n   * Encrypts the provided timestamp with the ACI identity key using the\n   * Hybrid Public Key Encryption scheme as defined in (<a href=\"https://www.rfc-editor.org/rfc/rfc9180.html\">RFC 9180</a>).\n   *\n   * @param createdAt The timestamp in milliseconds since epoch when a given device was linked to the account.\n   * @param aciIdentityKey The ACI identity key associated with the account.\n   * @param deviceId The ID of the given device.\n   * @param registrationId The registration ID of the given device.\n   *\n   * @return The timestamp ciphertext\n   */\n  public static byte[] encrypt(\n      final long createdAt,\n      final IdentityKey aciIdentityKey,\n      final byte deviceId,\n      final int registrationId) {\n    final ByteBuffer timestampBytes = ByteBuffer.allocate(8);\n    timestampBytes.putLong(createdAt);\n\n    // \"Associated data\" is metadata that ties the ciphertext to a specific context to prevent an adversary from\n    // swapping the ciphertext of two devices on the same account.\n    final ByteBuffer associatedData = ByteBuffer.allocate(5);\n    associatedData.put(deviceId);\n    associatedData.putInt(registrationId);\n\n    return aciIdentityKey.getPublicKey().seal(timestampBytes.array(), ENCRYPTION_INFO, associatedData.array());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/EnumMapUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.util.Arrays;\nimport java.util.EnumMap;\nimport java.util.Map;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\n\npublic class EnumMapUtil {\n\n  private EnumMapUtil() {}\n\n  public static <E extends Enum<E>, V> EnumMap<E, V> toEnumMap(final Class<E> enumClass, final Function<E, V> valueMapper) {\n    return Arrays.stream(enumClass.getEnumConstants())\n        .collect(Collectors.toMap(Function.identity(), valueMapper, (a, b) -> {\n              throw new AssertionError(\"Duplicate enumeration key\");\n            },\n            () -> new EnumMap<>(enumClass)));\n  }\n\n  public static <E extends Enum<E>, V> EnumMap<E, V> toCompleteEnumMap(final Class<E> enumClass, final Map<E, V> map) {\n    for (E e : enumClass.getEnumConstants()) {\n      if (!map.containsKey(e)) {\n        throw new IllegalArgumentException(\"Missing enum key: \" + e);\n      }\n    }\n    return new EnumMap<>(map);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySize.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static java.lang.annotation.ElementType.ANNOTATION_TYPE;\nimport static java.lang.annotation.ElementType.CONSTRUCTOR;\nimport static java.lang.annotation.ElementType.FIELD;\nimport static java.lang.annotation.ElementType.METHOD;\nimport static java.lang.annotation.ElementType.PARAMETER;\nimport static java.lang.annotation.ElementType.TYPE_USE;\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\nimport jakarta.validation.Constraint;\nimport jakarta.validation.Payload;\nimport java.lang.annotation.Documented;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\n\n@Target({ FIELD, METHOD, CONSTRUCTOR, PARAMETER, ANNOTATION_TYPE, TYPE_USE })\n@Retention(RUNTIME)\n@Constraint(validatedBy = {\n    ExactlySizeValidatorForString.class,\n    ExactlySizeValidatorForArraysOfByte.class,\n    ExactlySizeValidatorForCollection.class,\n    ExactlySizeValidatorForSecretBytes.class,\n})\n@Documented\npublic @interface ExactlySize {\n\n  String message() default \"{org.whispersystems.textsecuregcm.util.ExactlySize.message}\";\n\n  Class<?>[] groups() default { };\n\n  Class<? extends Payload>[] payload() default { };\n\n  int[] value();\n\n  @Target({ FIELD, METHOD, PARAMETER, ANNOTATION_TYPE })\n  @Retention(RUNTIME)\n  @Documented\n  @interface List {\n    ExactlySize[] value();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidator.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport jakarta.validation.ConstraintValidator;\nimport jakarta.validation.ConstraintValidatorContext;\nimport java.util.Arrays;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic abstract class ExactlySizeValidator<T> implements ConstraintValidator<ExactlySize, T> {\n\n  private Set<Integer> permittedSizes;\n\n  @Override\n  public void initialize(ExactlySize annotation) {\n    permittedSizes = Arrays.stream(annotation.value()).boxed().collect(Collectors.toSet());\n  }\n\n  @Override\n  public boolean isValid(T value, ConstraintValidatorContext context) {\n    return permittedSizes.contains(size(value));\n  }\n\n  protected abstract int size(T value);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForArraysOfByte.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\npublic class ExactlySizeValidatorForArraysOfByte extends ExactlySizeValidator<byte[]> {\n\n  @Override\n  protected int size(final byte[] value) {\n    return value == null ? 0 : value.length;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForCollection.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.util.Collection;\n\npublic class ExactlySizeValidatorForCollection extends ExactlySizeValidator<Collection<?>> {\n\n  @Override\n  protected int size(final Collection<?> value) {\n    return value == null ? 0 : value.size();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForSecretBytes.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\n\npublic class ExactlySizeValidatorForSecretBytes extends ExactlySizeValidator<SecretBytes> {\n  @Override\n  protected int size(final SecretBytes value) {\n    return value == null ? 0 : value.value().length;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExactlySizeValidatorForString.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\n\npublic class ExactlySizeValidatorForString extends ExactlySizeValidator<String> {\n\n  @Override\n  protected int size(final String value) {\n    return value == null ? 0 : value.length();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExceptionUtils.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport java.util.concurrent.CompletionException;\nimport java.util.function.Function;\nimport java.util.function.Supplier;\n\npublic final class ExceptionUtils {\n\n  private ExceptionUtils() {\n    // utility class\n  }\n\n  /**\n   * Extracts the cause of a {@link CompletionException}. If the given {@code throwable} is a\n   * {@code CompletionException}, this method will recursively iterate through its causal chain until it finds the first\n   * cause that is not a {@code CompletionException}. If the last {@code CompletionException} in the causal chain has a\n   * {@code null} cause, then this method returns the last {@code CompletionException} in the chain. If the given\n   * {@code throwable} is not a {@code CompletionException}, then this method returns the original {@code throwable}.\n   *\n   * @param throwable the throwable to \"unwrap\"\n   * @return the first entity in the given {@code throwable}'s causal chain that is not a {@code CompletionException}\n   */\n  public static Throwable unwrap(Throwable throwable) {\n    while (throwable instanceof CompletionException e && throwable.getCause() != null) {\n      throwable = e.getCause();\n    }\n    return throwable;\n  }\n\n  /**\n   * Wraps the given {@code throwable} in a {@link CompletionException} unless the given {@code throwable} is already a\n   * {@code CompletionException}, in which case this method returns the original throwable.\n   *\n   * @param throwable the throwable to wrap in a {@code CompletionException}\n   */\n  public static CompletionException wrap(final Throwable throwable) {\n    return throwable instanceof CompletionException completionException\n        ? completionException\n        : new CompletionException(throwable);\n  }\n\n  /**\n   * Create a handler suitable for use with {@link java.util.concurrent.CompletionStage#exceptionally} that only handles\n   * a specific exception subclass.\n   *\n   * @param exceptionType The class of exception that will be handled\n   * @param fn            A function that handles exceptions of type exceptionType\n   * @param <T>           The type of the stage that will be mapped\n   * @param <E>           The type of the exception that will be handled\n   * @return A function suitable for use with {@link java.util.concurrent.CompletionStage#exceptionally}\n   */\n  public static <T, E extends Throwable> Function<Throwable, ? extends T> exceptionallyHandler(\n      final Class<E> exceptionType,\n      final Function<E, ? extends T> fn) {\n    return anyException -> {\n      if (exceptionType.isInstance(anyException)) {\n        return fn.apply(exceptionType.cast(anyException));\n      }\n      final Throwable unwrap = unwrap(anyException);\n      if (exceptionType.isInstance(unwrap)) {\n        return fn.apply(exceptionType.cast(unwrap));\n      }\n      throw wrap(anyException);\n    };\n  }\n\n  /**\n   * Create a handler suitable for use with {@link java.util.concurrent.CompletionStage#exceptionally} that converts\n   * exceptions of a specific type to another type.\n   *\n   * @param exceptionType The class of exception that will be handled\n   * @param fn            A function that marshals exceptions of type E to type F\n   * @param <T>           The type of the stage that will be mapped\n   * @param <E>           The type of the exception that will be handled\n   * @param <F>           The type of the exception that will be produced\n   * @return A function suitable for use with {@link java.util.concurrent.CompletionStage#exceptionally}\n   */\n  public static <T, E extends Throwable, F extends Throwable> Function<Throwable, ? extends T> marshal(\n      final Class<E> exceptionType,\n      final Function<E, F> fn) {\n    return exceptionallyHandler(exceptionType, e -> {\n      throw wrap(fn.apply(e));\n    });\n  }\n\n  /**\n   * Runs the supplier, throwing a checked exception if the supplier throws an exception that unwraps to the provided type\n   *\n   * @param exType The exception type to check for\n   * @param supplier A supplier that produces a T\n   * @return The result of the supplier\n   * @param <T> The supplier type\n   * @param <E> The checked exception type\n   * @throws E If the supplier throws E or a type that {@link #unwrap}s to E\n   */\n  public static <T, E extends Throwable> T unwrapSupply(Class<E> exType, Supplier<T> supplier) throws E {\n    try {\n      return supplier.get();\n    } catch (RuntimeException e) {\n      final Throwable ex = unwrap(e);\n      if (exType.isInstance(ex)) {\n        throw exType.cast(ex);\n      }\n      throw e;\n    }\n  }\n\n  /**\n   * Runs the supplier, throwing a checked exception if the supplier throws an exception that unwraps to the provided type\n   *\n   * @param exType The exception type to check for\n   * @param supplier A supplier that produces a T\n   * @param marshal A function that maps from the thrown type to another exception type\n   * @return The result of the supplier\n   * @param <T> The supplier type\n   * @param <E> The checked exception type that may be thrown from supplier\n   * @throws F If the supplier throws E or a type that {@link #unwrap}s to E\n   */\n  public static <T, E extends Throwable, F extends Throwable> T unwrapSupply(Class<E> exType, Supplier<T> supplier, Function<E, F> marshal) throws F {\n    try {\n      return supplier.get();\n    } catch (RuntimeException e) {\n      final Throwable ex = unwrap(e);\n      if (exType.isInstance(ex)) {\n        throw marshal.apply(exType.cast(ex));\n      }\n      throw e;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ExecutorUtil.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.util.Collection;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.Executor;\n\npublic class ExecutorUtil {\n\n  private ExecutorUtil() {\n  }\n\n  /**\n   * Submit all runnables to executorService and wait for them all to complete.\n   * <p>\n   * If any runnable completes exceptionally, after all runnables have completed the first exception will be thrown\n   *\n   * @param executor  The executor to run runnables\n   * @param runnables A collection of runnables to run\n   */\n  public static void runAll(Executor executor, Collection<Runnable> runnables) {\n    try {\n      CompletableFuture.allOf(runnables\n              .stream()\n              .map(runnable -> CompletableFuture.runAsync(runnable, executor))\n              .toArray(CompletableFuture[]::new))\n          .join();\n    } catch (CompletionException e) {\n      final Throwable cause = e.getCause();\n      // These exceptions should always be RuntimeExceptions because Runnable does not throw\n      if (cause instanceof RuntimeException re) {\n        throw re;\n      } else {\n        throw new IllegalStateException(cause);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/Futures.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.util.concurrent.CompletionStage;\nimport org.apache.commons.lang3.function.TriFunction;\n\npublic class Futures {\n\n  public static <T, U, V, R> CompletionStage<R> zipWith(\n      CompletionStage<T> futureT,\n      CompletionStage<U> futureU,\n      CompletionStage<V> futureV,\n      TriFunction<T, U, V, R> fun) {\n\n    return futureT.thenCompose(t -> futureU.thenCombine(futureV, (u, v) -> fun.apply(t, u, v)));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/GoogleApiUtil.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.google.api.core.ApiFuture;\nimport com.google.api.core.ApiFutureCallback;\nimport com.google.api.core.ApiFutures;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\n\npublic class GoogleApiUtil {\n\n  public static <T> CompletableFuture<T> toCompletableFuture(final ApiFuture<T> apiFuture, final Executor executor) {\n    final CompletableFuture<T> completableFuture = new CompletableFuture<>();\n\n    ApiFutures.addCallback(apiFuture, new ApiFutureCallback<>() {\n      @Override\n      public void onSuccess(final T value) {\n        completableFuture.complete(value);\n      }\n\n      @Override\n      public void onFailure(final Throwable throwable) {\n        completableFuture.completeExceptionally(throwable);\n      }\n    }, executor);\n\n    return completableFuture;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/HeaderUtils.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static java.util.Objects.requireNonNull;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.ProcessingException;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Optional;\nimport javax.annotation.Nonnull;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\n\npublic final class HeaderUtils {\n\n  private static final Logger logger = LoggerFactory.getLogger(HeaderUtils.class);\n\n  public static final String X_SIGNAL_AGENT = \"X-Signal-Agent\";\n\n  public static final String TIMESTAMP_HEADER = \"X-Signal-Timestamp\";\n\n  public static final String UNIDENTIFIED_ACCESS_KEY = \"Unidentified-Access-Key\";\n\n  public static final String GROUP_SEND_TOKEN = \"Group-Send-Token\";\n\n  private static final String INVALID_ACCEPT_LANGUAGE_COUNTER_NAME = MetricsUtil.name(HeaderUtils.class,\n      \"invalidAcceptLanguage\");\n\n  private HeaderUtils() {\n    // utility class\n  }\n\n  public static String basicAuthHeader(final ExternalServiceCredentials credentials) {\n    return basicAuthHeader(credentials.username(), credentials.password());\n  }\n\n  public static String basicAuthHeader(final String username, final String password) {\n    requireNonNull(username);\n    requireNonNull(password);\n    return \"Basic \" + Base64.getEncoder().encodeToString((username + \":\" + password).getBytes(StandardCharsets.UTF_8));\n  }\n\n  @Nonnull\n  public static String getTimestampHeader() {\n    return TIMESTAMP_HEADER + \":\" + System.currentTimeMillis();\n  }\n\n  /**\n   * Parses a Base64-encoded value of the `Authorization` header in the form of `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`. Note:\n   * parsing logic is copied from {@link io.dropwizard.auth.basic.BasicCredentialAuthFilter#getCredentials(String)}.\n   */\n  public static Optional<BasicCredentials> basicCredentialsFromAuthHeader(final String authHeader) {\n    final int space = authHeader.indexOf(' ');\n    if (space <= 0) {\n      return Optional.empty();\n    }\n\n    final String method = authHeader.substring(0, space);\n    if (!\"Basic\".equalsIgnoreCase(method)) {\n      return Optional.empty();\n    }\n\n    final String decoded;\n    try {\n      decoded = new String(Base64.getDecoder().decode(authHeader.substring(space + 1)), StandardCharsets.UTF_8);\n    } catch (IllegalArgumentException e) {\n      return Optional.empty();\n    }\n\n    // Decoded credentials is 'username:password'\n    final int i = decoded.indexOf(':');\n    if (i <= 0) {\n      return Optional.empty();\n    }\n\n    final String username = decoded.substring(0, i);\n    final String password = decoded.substring(i + 1);\n    return Optional.of(new BasicCredentials(username, password));\n  }\n\n  public static List<Locale> getAcceptableLanguagesForRequest(ContainerRequestContext containerRequestContext) {\n    try {\n      return containerRequestContext.getAcceptableLanguages();\n    } catch (final ProcessingException e) {\n      final String userAgent = containerRequestContext.getHeaderString(HttpHeaders.USER_AGENT);\n      Metrics.counter(INVALID_ACCEPT_LANGUAGE_COUNTER_NAME, Tags.of(\n              UserAgentTagUtil.getPlatformTag(userAgent),\n              Tag.of(\"path\", containerRequestContext.getUriInfo().getPath())))\n          .increment();\n      logger.debug(\"Could not get acceptable languages; Accept-Language: {}; User-Agent: {}\",\n          containerRequestContext.getHeaderString(HttpHeaders.ACCEPT_LANGUAGE),\n          userAgent,\n          e);\n\n      return List.of();\n    }\n  }\n\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/HmacUtils.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.util.HexFormat;\nimport javax.crypto.Mac;\nimport javax.crypto.spec.SecretKeySpec;\n\npublic final class HmacUtils {\n\n  private static final HexFormat HEX = HexFormat.of();\n\n  private static final String HMAC_SHA_256 = \"HmacSHA256\";\n\n  private static final ThreadLocal<Mac> THREAD_LOCAL_HMAC_SHA_256 = ThreadLocal.withInitial(() -> {\n    try {\n      return Mac.getInstance(HMAC_SHA_256);\n    } catch (NoSuchAlgorithmException e) {\n      throw new RuntimeException(e);\n    }\n  });\n\n  private static Mac initializedThreadLocalMac(final byte[] key) {\n    try {\n      final Mac mac = THREAD_LOCAL_HMAC_SHA_256.get();\n      mac.init(new SecretKeySpec(key, HMAC_SHA_256));\n      return mac;\n    } catch (final InvalidKeyException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public static byte[] hmac256(final byte[] key, final byte[] input) {\n    return initializedThreadLocalMac(key).doFinal(input);\n  }\n\n  public static byte[] hmac256(final byte[] key, final byte[]... inputs) {\n      final Mac mac = initializedThreadLocalMac(key);\n      for (byte[] input : inputs) {\n        mac.update(input);\n      }\n      return mac.doFinal();\n  }\n\n  public static byte[] hmac256(final byte[] key, final String input) {\n    return hmac256(key, input.getBytes(StandardCharsets.UTF_8));\n  }\n\n  public static String hmac256ToHexString(final byte[] key, final byte[] input) {\n    return HEX.formatHex(hmac256(key, input));\n  }\n\n  public static String hmac256ToHexString(final byte[] key, final String input) {\n    return hmac256ToHexString(key, input.getBytes(StandardCharsets.UTF_8));\n  }\n\n  public static byte[] hmac256Truncated(final byte[] key, final byte[] input, final int length) {\n    return Util.truncate(hmac256(key, input), length);\n  }\n\n  public static byte[] hmac256Truncated(final byte[] key, final String input, final int length) {\n    return hmac256Truncated(key, input.getBytes(StandardCharsets.UTF_8), length);\n  }\n\n  public static String hmac256TruncatedToHexString(final byte[] key, final byte[] input, final int length) {\n    return HEX.formatHex(Util.truncate(hmac256(key, input), length));\n  }\n\n  public static String hmac256TruncatedToHexString(final byte[] key, final String input, final int length) {\n    return hmac256TruncatedToHexString(key, input.getBytes(StandardCharsets.UTF_8), length);\n  }\n\n  public static boolean hmacHexStringsEqual(final String expectedAsHexString, final String actualAsHexString) {\n    try {\n      final byte[] aBytes = HEX.parseHex(expectedAsHexString);\n      final byte[] bBytes = HEX.parseHex(actualAsHexString);\n      return MessageDigest.isEqual(aBytes, bBytes);\n    } catch (final IllegalArgumentException e) {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/HostnameUtil.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport java.net.InetAddress;\nimport java.net.UnknownHostException;\nimport java.util.Locale;\n\npublic class HostnameUtil {\n\n  private static final Logger log = LoggerFactory.getLogger(HostnameUtil.class);\n\n  public static String getLocalHostname() {\n    try {\n      return InetAddress.getLocalHost().getHostName().toLowerCase(Locale.US);\n    } catch (final UnknownHostException e) {\n      log.warn(\"Failed to get hostname\", e);\n      return \"unknown\";\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/HttpServletRequestUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport jakarta.servlet.http.HttpServletRequest;\n\npublic class HttpServletRequestUtil {\n\n  /**\n   * Returns the remote address of the request, removing bracket (\"[…]\") host notation from IPv6 addresses present in\n   * some implementations, notably {@link org.eclipse.jetty.server.HttpChannel}.\n   */\n  public static String getRemoteAddress(final HttpServletRequest request) {\n    final String remoteAddr = request.getRemoteAddr();\n\n    if (remoteAddr.startsWith(\"[\")) {\n      return remoteAddr.substring(1, remoteAddr.length() - 1);\n    }\n\n    return remoteAddr;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/HttpUtils.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collection;\nimport java.util.Map;\nimport java.util.stream.Collectors;\n\npublic final class HttpUtils {\n\n  private HttpUtils() {\n    // utility class\n  }\n\n  public static boolean isSuccessfulResponse(final int statusCode) {\n    return statusCode >= 200 && statusCode < 300;\n  }\n\n  public static String queryParamString(final Collection<Map.Entry<String, String>> params) {\n    final StringBuilder sb = new StringBuilder();\n    if (params.isEmpty()) {\n      return sb.toString();\n    }\n    sb.append(\"?\");\n    sb.append(params.stream()\n        .map(e -> \"%s=%s\".formatted(\n            URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8),\n            URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)))\n        .collect(Collectors.joining(\"&\")));\n    return sb.toString();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/IdentityKeyAdapter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParseException;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.util.Base64;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.InvalidKeyException;\n\npublic class IdentityKeyAdapter {\n\n  public static class Serializer extends JsonSerializer<IdentityKey> {\n\n    @Override\n    public void serialize(final IdentityKey identityKey,\n        final JsonGenerator jsonGenerator,\n        final SerializerProvider serializers) throws IOException {\n\n      jsonGenerator.writeString(Base64.getEncoder().encodeToString(identityKey.serialize()));\n    }\n  }\n\n  public static class Deserializer extends JsonDeserializer<IdentityKey> {\n\n    @Override\n    public IdentityKey deserialize(final JsonParser parser, final DeserializationContext context) throws IOException {\n      final byte[] identityKeyBytes;\n\n      try {\n        identityKeyBytes = Base64.getDecoder().decode(parser.getValueAsString());\n      } catch (final IllegalArgumentException e) {\n        throw new JsonParseException(parser, \"Could not parse identity key as a base64-encoded value\", e);\n      }\n\n      if (identityKeyBytes.length == 0) {\n        return null;\n      }\n\n      try {\n        return new IdentityKey(identityKeyBytes);\n      } catch (final InvalidKeyException e) {\n        throw new JsonParseException(parser, \"Could not interpret identity key bytes as an EC public key\", e);\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ImpossiblePhoneNumberException.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\npublic class ImpossiblePhoneNumberException extends Exception {\n\n  public ImpossiblePhoneNumberException() {\n    super();\n  }\n\n  public ImpossiblePhoneNumberException(final Throwable cause) {\n    super(cause);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/InetAddressRange.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.InetAddresses;\nimport java.net.InetAddress;\nimport java.util.Arrays;\n\n/**\n * An InetAddressRange represents a contiguous range of IPv4 or IPv6 addresses.\n */\npublic class InetAddressRange {\n\n  private final InetAddress networkAddress;\n\n  private final byte[] networkAddressBytes;\n  private final byte[] prefixMask;\n\n  public InetAddressRange(final String cidrBlock) {\n    final String[] components = cidrBlock.split(\"/\");\n\n    if (components.length != 2) {\n      throw new IllegalArgumentException(\"Unexpected CIDR block notation: \" + cidrBlock);\n    }\n\n    final int prefixLength;\n\n    try {\n      networkAddress = InetAddresses.forString(components[0]);\n      prefixLength = Integer.parseInt(components[1]);\n\n      if (prefixLength > networkAddress.getAddress().length * 8) {\n        throw new IllegalArgumentException(\"Prefix length cannot exceed length of address\");\n      }\n    } catch (final NumberFormatException e) {\n      throw new IllegalArgumentException(\"Bad prefix length: \" + components[1]);\n    }\n\n    networkAddressBytes = networkAddress.getAddress();\n    prefixMask = generatePrefixMask(networkAddressBytes.length, prefixLength);\n  }\n\n  @VisibleForTesting\n  static byte[] generatePrefixMask(final int addressLengthBytes, final int prefixLengthBits) {\n    final byte[] prefixMask = new byte[addressLengthBytes];\n\n    for (int i = 0; i < addressLengthBytes; i++) {\n      final int bitsAvailable = Math.min(8, Math.max(0, prefixLengthBits - (i * 8)));\n      prefixMask[i] = (byte) (0xff << (8 - bitsAvailable));\n    }\n\n    return prefixMask;\n  }\n\n  public boolean contains(final String name) {\n    // InetAddresses.forString() throws \"IllegalArgumentException\" for anything that is not an IP address\n    return contains(InetAddresses.forString(name));\n  }\n\n  public boolean contains(final InetAddress inetAddress) {\n    if (!networkAddress.getClass().equals(inetAddress.getClass())) {\n      return false;\n    }\n\n    final byte[] addressBytes = inetAddress.getAddress();\n\n    for (int i = 0; i < addressBytes.length; i++) {\n      if (((addressBytes[i] ^ networkAddressBytes[i]) & prefixMask[i]) != 0) {\n        return false;\n      }\n    }\n\n    return true;\n  }\n\n  @Override\n  public boolean equals(final Object o) {\n    if (this == o) {\n      return true;\n    }\n    if (o == null || getClass() != o.getClass()) {\n      return false;\n    }\n\n    final InetAddressRange that = (InetAddressRange) o;\n\n    if (!networkAddress.equals(that.networkAddress)) {\n      return false;\n    }\n    return Arrays.equals(prefixMask, that.prefixMask);\n  }\n\n  @Override\n  public int hashCode() {\n    int result = networkAddress.hashCode();\n    result = 31 * result + Arrays.hashCode(prefixMask);\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/InstantAdapter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport java.io.IOException;\nimport java.time.Instant;\n\npublic class InstantAdapter {\n\n  public static class EpochSecondSerializer extends JsonSerializer<Instant> {\n\n    @Override\n    public void serialize(final Instant value, final JsonGenerator gen, final SerializerProvider serializers)\n        throws IOException {\n\n      gen.writeNumber(value.getEpochSecond());\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/JmxDumper.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport io.dropwizard.lifecycle.Managed;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport javax.management.MBeanServer;\nimport java.lang.management.ManagementFactory;\nimport java.util.concurrent.ScheduledExecutorService;\n\n/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npublic class JmxDumper implements Managed {\n  private static final Logger log = LoggerFactory.getLogger(JmxDumper.class);\n\n\n  private final ScheduledExecutorService executor;\n\n  public JmxDumper(final ScheduledExecutorService executor) {\n    this.executor = executor;\n  }\n\n  @Override\n  public void start() throws Exception {\n//    executor.schedule()\n  }\n\n  private void dump() {\n    MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();\n\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/KEMPublicKeyAdapter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\n\npublic class KEMPublicKeyAdapter {\n\n  public static class Serializer extends AbstractPublicKeySerializer<KEMPublicKey> {\n\n    @Override\n    protected byte[] serializePublicKey(final KEMPublicKey publicKey) {\n      return publicKey.serialize();\n    }\n  }\n\n  public static class Deserializer extends AbstractPublicKeyDeserializer<KEMPublicKey> {\n\n    @Override\n    protected KEMPublicKey deserializePublicKey(final byte[] publicKeyBytes) throws InvalidKeyException {\n      return new KEMPublicKey(publicKeyBytes);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/LinkDeviceToken.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.swagger.v3.oas.annotations.media.Schema;\n\npublic record LinkDeviceToken(\n    @Schema(description = \"\"\"\n        An opaque token to send to a new linked device that authorizes the new device to link itself to the account that\n        requested this token.\n        \"\"\")\n    @JsonProperty(\"verificationCode\") String token,\n\n    @Schema(description = \"\"\"\n        An opaque identifier for the generated token that the caller may use to watch for a new device to complete the\n        linking process.\n        \"\"\")\n    String tokenIdentifier) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ManagedAwsCrt.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport io.dropwizard.lifecycle.Managed;\nimport software.amazon.awssdk.crt.CRT;\n\n/**\n * The AWS CRT client registers its own JVM shutdown handler which makes sure asynchronous operations in the CRT don't\n * call back into the JVM after the JVM shuts down. Unfortunately, this hook kills all outstanding operations using the\n * CRT, even though we typically orchestrate a graceful shutdown where we give outstanding requests time to complete.\n *\n * The CRT lets you take over the shutdown sequencing by incrementing/decrementing a ref count, so we introduce a\n * lifecycle object that will be shutdown after our graceful shutdown process.\n */\npublic class ManagedAwsCrt implements Managed {\n\n  @Override\n  public void start() {\n    CRT.acquireShutdownRef();\n  }\n\n  @Override\n  public void stop() {\n    CRT.releaseShutdownRef();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ManagedExecutors.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.lifecycle.ExecutorServiceManager;\nimport io.dropwizard.util.Duration;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\n\n/**\n * Build Executor Services managed by dropwizard, supplementing executors provided by\n * {@link io.dropwizard.lifecycle.setup.LifecycleEnvironment#executorService}\n */\npublic class ManagedExecutors {\n\n  private static final Duration SHUTDOWN_DURATION = Duration.seconds(5);\n\n  private ManagedExecutors() {\n  }\n\n  public static ExecutorService newVirtualThreadPerTaskExecutor(\n      final String threadNamePrefix,\n      final int maxConcurrentThreads,\n      final Environment environment) {\n\n    final BoundedVirtualThreadFactory threadFactory =\n        new BoundedVirtualThreadFactory(threadNamePrefix, maxConcurrentThreads);\n    final ExecutorService virtualThreadExecutor = Executors.newThreadPerTaskExecutor(threadFactory);\n    environment.lifecycle()\n        .manage(new ExecutorServiceManager(virtualThreadExecutor, SHUTDOWN_DURATION, threadNamePrefix));\n    return virtualThreadExecutor;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/NoStackTraceException.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\n/**\n * An abstract base class for exceptions that do not include a stack trace. Stackless exceptions are generally intended\n * for internal error-handling cases where the error will never be logged or otherwise reported.\n */\npublic abstract class NoStackTraceException extends Exception {\n\n  public NoStackTraceException() {\n    super(null, null, true, false);\n  }\n\n  public NoStackTraceException(final String message) {\n    super(message, null, true, false);\n  }\n\n  public NoStackTraceException(final String message, final Throwable cause) {\n    super(message, cause, true, false);\n  }\n\n  public NoStackTraceException(final Throwable cause) {\n    super(null, cause, true, false);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/NoStackTraceRuntimeException.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\n/**\n * An abstract base class for runtime exceptions that do not include a stack trace. Stackless exceptions are generally\n * intended for internal error-handling cases where the error will never be logged or otherwise reported.\n */\npublic abstract class NoStackTraceRuntimeException extends RuntimeException {\n\n  public NoStackTraceRuntimeException() {\n    super(null, null, true, false);\n  }\n\n  public NoStackTraceRuntimeException(final String message) {\n    super(message, null, true, false);\n  }\n\n  public NoStackTraceRuntimeException(final String message, final Throwable cause) {\n    super(message, cause, true, false);\n  }\n\n  public NoStackTraceRuntimeException(final Throwable cause) {\n    super(null, cause, true, false);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/NonNormalizedPhoneNumberException.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\npublic class NonNormalizedPhoneNumberException extends Exception {\n\n  private final String originalNumber;\n  private final String normalizedNumber;\n\n  public NonNormalizedPhoneNumberException(final String originalNumber, final String normalizedNumber) {\n    this.originalNumber = originalNumber;\n    this.normalizedNumber = normalizedNumber;\n  }\n\n  public String getOriginalNumber() {\n    return originalNumber;\n  }\n\n  public String getNormalizedNumber() {\n    return normalizedNumber;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ObsoletePhoneNumberFormatException.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\npublic class ObsoletePhoneNumberFormatException extends Exception {\n\n  private final String regionCode;\n\n  public ObsoletePhoneNumberFormatException(final String regionCode) {\n    super(\"The provided format is obsolete in %s\".formatted(regionCode));\n    this.regionCode = regionCode;\n  }\n\n  public String getRegionCode() {\n    return regionCode;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/Optionals.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport java.util.Optional;\nimport java.util.function.BiFunction;\n\npublic class Optionals {\n\n  private Optionals() {}\n\n  /**\n   * Apply a function to two optional arguments, returning empty if either argument is empty\n   *\n   * @param optionalT Optional of type T\n   * @param optionalU Optional of type U\n   * @param fun       Function of T and U that returns R\n   * @return The function applied to the values of optionalT and optionalU, or empty\n   */\n  public static <T, U, R> Optional<R> zipWith(Optional<T> optionalT, Optional<U> optionalU, BiFunction<T, U, R> fun) {\n    return optionalT.flatMap(t -> optionalU.map(u -> fun.apply(t, u)));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/Pair.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.util.Map;\n\npublic record Pair<T1, T2>(T1 first, T2 second) {\n  public Pair(kotlin.Pair<T1, T2> p) {\n    this(p.getFirst(), p.getSecond());\n  }\n\n  public Pair(Map.Entry<T1, T2> e) {\n    this(e.getKey(), e.getValue());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ProfileHelper.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport javax.annotation.Nullable;\nimport java.security.SecureRandom;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\n\npublic class ProfileHelper {\n  public static int MAX_PROFILE_AVATAR_SIZE_BYTES = 10 * 1024 * 1024;\n  @VisibleForTesting\n  public static final Duration EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION = Duration.ofDays(7);\n\n  public static List<AccountBadge> mergeBadgeIdsWithExistingAccountBadges(\n      final Clock clock,\n      final Map<String, BadgeConfiguration> badgeConfigurationMap,\n      final List<String> badgeIds,\n      final List<AccountBadge> accountBadges) {\n    LinkedHashMap<String, AccountBadge> existingBadges = new LinkedHashMap<>(accountBadges.size());\n    for (final AccountBadge accountBadge : accountBadges) {\n      existingBadges.putIfAbsent(accountBadge.id(), accountBadge);\n    }\n\n    LinkedHashMap<String, AccountBadge> result = new LinkedHashMap<>(accountBadges.size());\n    for (final String badgeId : badgeIds) {\n\n      // duplicate in the list, ignore it\n      if (result.containsKey(badgeId)) {\n        continue;\n      }\n\n      // This is for testing badges and allows them to be added to an account at any time with an expiration of 1 day\n      // in the future.\n      BadgeConfiguration badgeConfiguration = badgeConfigurationMap.get(badgeId);\n      if (badgeConfiguration != null && badgeConfiguration.isTestBadge()) {\n        result.put(badgeId, new AccountBadge(badgeId, clock.instant().plus(Duration.ofDays(1)), true));\n        continue;\n      }\n\n      // reordering or making visible existing badges\n      if (existingBadges.containsKey(badgeId)) {\n        AccountBadge accountBadge = existingBadges.get(badgeId).withVisibility(true);\n        result.put(badgeId, accountBadge);\n      }\n    }\n\n    // take any remaining account badges and make them invisible\n    for (final Map.Entry<String, AccountBadge> entry : existingBadges.entrySet()) {\n      if (!result.containsKey(entry.getKey())) {\n        AccountBadge accountBadge = entry.getValue().withVisibility(false);\n        result.put(accountBadge.id(), accountBadge);\n      }\n    }\n\n    return new ArrayList<>(result.values());\n  }\n\n  public static String generateAvatarObjectName() {\n    final byte[] object = new byte[16];\n    new SecureRandom().nextBytes(object);\n\n    return \"profiles/\" + Base64.getUrlEncoder().encodeToString(object);\n  }\n\n  public static boolean isSelfProfileRequest(@Nullable final UUID requesterUuid, final ServiceIdentifier targetIdentifier) {\n    return targetIdentifier.uuid().equals(requesterUuid);\n  }\n\n  public static ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredential(\n      final byte[] encodedCredentialRequest,\n      final VersionedProfile profile,\n      final ServiceId.Aci accountIdentifier,\n      final ServerZkProfileOperations zkProfileOperations) throws InvalidInputException, VerificationFailedException {\n    final Instant expiration = Instant.now().plus(EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION).truncatedTo(ChronoUnit.DAYS);\n    final ProfileKeyCommitment commitment = new ProfileKeyCommitment(profile.commitment());\n    final ProfileKeyCredentialRequest request = new ProfileKeyCredentialRequest(\n        encodedCredentialRequest);\n\n    return zkProfileOperations.issueExpiringProfileKeyCredential(request, accountIdentifier, commitment, expiration);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/RedisClusterUtil.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport io.lettuce.core.cluster.SlotHash;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport java.util.HashSet;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.stream.Collectors;\n\npublic class RedisClusterUtil {\n\n    private static final String[] HASHES_BY_SLOT = new String[SlotHash.SLOT_COUNT];\n\n    static {\n        int slotsCovered = 0;\n        int i = 0;\n\n        while (slotsCovered < HASHES_BY_SLOT.length) {\n            final String hash = Integer.toString(i++, 36);\n            final int slot = SlotHash.getSlot(hash);\n\n            if (HASHES_BY_SLOT[slot] == null) {\n                HASHES_BY_SLOT[slot] = hash;\n                slotsCovered += 1;\n            }\n        }\n    }\n\n    /**\n     * Returns a Redis hash tag that maps to the given cluster slot.\n     *\n     * @param slot the Redis cluster slot for which to retrieve a hash tag\n     *\n     * @return a Redis hash tag that maps to the given cluster slot\n     *\n     * @see <a href=\"https://redis.io/topics/cluster-spec#keys-hash-tags\">Redis Cluster Specification - Keys hash tags</a>\n     */\n    public static String getMinimalHashTag(final int slot) {\n        return HASHES_BY_SLOT[slot];\n    }\n\n  /**\n   * Returns an array indicating which slots have moved as part of a {@link ClusterTopologyChangedEvent}. The elements\n   * of the array map to slots in the cluster; for example, if slot 1234 has changed, then element 1234 of the returned\n   * array will be {@code true}.\n   *\n   * @param clusterTopologyChangedEvent the event from which to derive an array of changed slots\n   *\n   * @return an array indicating which slots of changed\n   */\n  public static boolean[] getChangedSlots(final ClusterTopologyChangedEvent clusterTopologyChangedEvent) {\n      final Map<String, RedisClusterNode> beforeNodesById = clusterTopologyChangedEvent.before().stream()\n          .collect(Collectors.toMap(RedisClusterNode::getNodeId, node -> node));\n\n      final Map<String, RedisClusterNode> afterNodesById = clusterTopologyChangedEvent.after().stream()\n          .collect(Collectors.toMap(RedisClusterNode::getNodeId, node -> node));\n\n      final Set<String> nodeIds = new HashSet<>(beforeNodesById.keySet());\n      nodeIds.addAll(afterNodesById.keySet());\n\n      final boolean[] changedSlots = new boolean[SlotHash.SLOT_COUNT];\n\n      for (final String nodeId : nodeIds) {\n        if (beforeNodesById.containsKey(nodeId) && afterNodesById.containsKey(nodeId)) {\n          // This node was present before and after the topology change, but its slots may have changed\n          final boolean[] beforeSlots = new boolean[SlotHash.SLOT_COUNT];\n          beforeNodesById.get(nodeId).getSlots().forEach(slot -> beforeSlots[slot] = true);\n\n          final boolean[] afterSlots = new boolean[SlotHash.SLOT_COUNT];\n          afterNodesById.get(nodeId).getSlots().forEach(slot -> afterSlots[slot] = true);\n\n          for (int slot = 0; slot < SlotHash.SLOT_COUNT; slot++) {\n            changedSlots[slot] |= beforeSlots[slot] ^ afterSlots[slot];\n          }\n        } else if (beforeNodesById.containsKey(nodeId)) {\n          // The node was present before the topology change, but is gone now; all of its slots should be considered\n          // changed\n          beforeNodesById.get(nodeId).getSlots().forEach(slot -> changedSlots[slot] = true);\n        } else {\n          // The node was present after the change, but wasn't there before; all of its slots should be considered\n          // changed\n          afterNodesById.get(nodeId).getSlots().forEach(slot -> changedSlots[slot] = true);\n        }\n      }\n\n      return changedSlots;\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/RegistrationIdValidator.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n *\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class RegistrationIdValidator {\n  public static boolean validRegistrationId(int registrationId) {\n    return registrationId > 0 && registrationId <= Device.MAX_REGISTRATION_ID;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ResilienceUtil.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;\nimport io.github.resilience4j.retry.Retry;\nimport io.github.resilience4j.retry.RetryRegistry;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Gauge;\nimport io.micrometer.core.instrument.Meter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.ConcurrentMap;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.RetryConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\npublic class ResilienceUtil {\n\n  private static final CircuitBreakerRegistry CIRCUIT_BREAKER_REGISTRY =\n      CircuitBreakerRegistry.of(new CircuitBreakerConfiguration().toCircuitBreakerConfig());\n\n  private static final RetryRegistry RETRY_REGISTRY =\n      RetryRegistry.of(new RetryConfiguration().toRetryConfigBuilder().build());\n\n  private static final ConcurrentMap<String, Set<Meter.Id>> METER_IDS_BY_BREAKER_NAME = new ConcurrentHashMap<>();\n  private static final ConcurrentMap<String, Set<Meter.Id>> METER_IDS_BY_RETRY_NAME = new ConcurrentHashMap<>();\n\n  private static final String BREAKER_CALL_COUNTER_NAME = MetricsUtil.name(ResilienceUtil.class, \"breaker\", \"call\");\n  private static final String BREAKER_STATE_GAUGE_NAME = MetricsUtil.name(ResilienceUtil.class, \"breaker\", \"state\");\n  private static final String RETRY_CALL_COUNTER_NAME = MetricsUtil.name(ResilienceUtil.class, \"retry\", \"call\");\n\n  private static final String BREAKER_NAME_TAG_NAME = \"breakerName\";\n  private static final String RETRY_NAME_TAG = \"retryName\";\n  private static final String OUTCOME_TAG_NAME = \"outcome\";\n\n  // Include a random suffix to avoid accidental collisions\n  private static final String GENERAL_REDIS_CONFIGURATION_NAME =\n      \"redis-general-\" + RandomStringUtils.insecure().nextAlphanumeric(8);\n\n  static {\n    setGeneralRedisRetryConfiguration(new RetryConfiguration());\n\n    CIRCUIT_BREAKER_REGISTRY.getEventPublisher()\n        .onEntryAdded(event -> addMetrics(event.getAddedEntry()))\n        .onEntryRemoved(event -> removeMetrics(event.getRemovedEntry()))\n        .onEntryReplaced(event -> {\n          removeMetrics(event.getOldEntry());\n          addMetrics(event.getNewEntry());\n        });\n\n    RETRY_REGISTRY.getEventPublisher()\n        .onEntryAdded(event -> addMetrics(event.getAddedEntry()))\n        .onEntryRemoved(event -> removeMetrics(event.getRemovedEntry()))\n        .onEntryReplaced(event -> {\n          removeMetrics(event.getOldEntry());\n          addMetrics(event.getNewEntry());\n        });\n  }\n\n  public static CircuitBreakerRegistry getCircuitBreakerRegistry() {\n    return CIRCUIT_BREAKER_REGISTRY;\n  }\n\n  public static RetryRegistry getRetryRegistry() {\n    return RETRY_REGISTRY;\n  }\n\n  public static void setGeneralRedisRetryConfiguration(final RetryConfiguration retryConfiguration) {\n    RETRY_REGISTRY.addConfiguration(GENERAL_REDIS_CONFIGURATION_NAME, retryConfiguration.toRetryConfigBuilder()\n        .retryOnException(throwable -> throwable instanceof RedisCommandTimeoutException)\n        .build());\n  }\n\n  /// Generates a standardized name for a `CircuitBreaker` or `Retry`.\n  ///\n  /// @param clazz the class to which the circuit breaker or retry belongs\n  ///\n  /// @return a standardized name for a `CircuitBreaker` or `Retry`\n  public static String name(final Class<?> clazz) {\n    return name(clazz, null);\n  }\n\n  /// Generates a standardized name for a `CircuitBreaker` or `Retry`.\n  ///\n  /// @param clazz the class to which the circuit breaker or retry belongs\n  /// @param name the name of the circuit breaker or retry; may be `null``\n  ///\n  /// @return a standardized name for a `CircuitBreaker` or `Retry`\n  public static String name(final Class<?> clazz, @Nullable final String name) {\n    return name != null\n        ? clazz.getSimpleName() + \"/\" + name\n        : clazz.getSimpleName();\n  }\n\n  /// Returns a `Retry` instance with a default configuration suitable for general Redis operations.\n  ///\n  /// @param name The name of this `Retry`. Calls to this method with the same name will return the same `Retry`\n  /// instance, and the name is used to identify metrics tied to the returned `Retry` instance.\n  public static Retry getGeneralRedisRetry(final String name) {\n    return RETRY_REGISTRY.retry(\"redis/\" + name, GENERAL_REDIS_CONFIGURATION_NAME);\n  }\n\n  private static void addMetrics(final CircuitBreaker circuitBreaker) {\n    // Remove previous meters before registering new ones\n    final Set<Meter.Id> meterIds = new HashSet<>();\n    final List<Tag> additionalTags = toTags(circuitBreaker.getTags());\n\n    meterIds.add(Gauge.builder(BREAKER_STATE_GAUGE_NAME, circuitBreaker, breaker -> switch (breaker.getState()) {\n          case OPEN, HALF_OPEN, FORCED_OPEN -> 1;\n          default -> 0;\n        })\n        .tag(BREAKER_NAME_TAG_NAME, circuitBreaker.getName())\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry)\n        .getId());\n\n    final Counter successCounter = Counter.builder(BREAKER_CALL_COUNTER_NAME)\n        .tag(BREAKER_NAME_TAG_NAME, circuitBreaker.getName())\n        .tag(OUTCOME_TAG_NAME, \"success\")\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry);\n\n    final Counter failureCounter = Counter.builder(BREAKER_CALL_COUNTER_NAME)\n        .tag(BREAKER_NAME_TAG_NAME, circuitBreaker.getName())\n        .tag(OUTCOME_TAG_NAME, \"failure\")\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry);\n\n    final Counter unpermittedCounter = Counter.builder(BREAKER_CALL_COUNTER_NAME)\n        .tag(BREAKER_NAME_TAG_NAME, circuitBreaker.getName())\n        .tag(OUTCOME_TAG_NAME, \"unpermitted\")\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry);\n\n    circuitBreaker.getEventPublisher()\n        .onSuccess(_ -> successCounter.increment())\n        .onError(_ -> failureCounter.increment())\n        .onCallNotPermitted(_ -> unpermittedCounter.increment());\n\n    meterIds.add(successCounter.getId());\n    meterIds.add(failureCounter.getId());\n    meterIds.add(unpermittedCounter.getId());\n\n    METER_IDS_BY_BREAKER_NAME.put(circuitBreaker.getName(), meterIds);\n  }\n\n  private static void addMetrics(final Retry retry) {\n    final Set<Meter.Id> meterIds = new HashSet<>();\n    final List<Tag> additionalTags = toTags(retry.getTags());\n\n    final Counter successCounter = Counter.builder(RETRY_CALL_COUNTER_NAME)\n        .tag(RETRY_NAME_TAG, retry.getName())\n        .tag(OUTCOME_TAG_NAME, \"success\")\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry);\n\n    final Counter retryCounter = Counter.builder(RETRY_CALL_COUNTER_NAME)\n        .tag(RETRY_NAME_TAG, retry.getName())\n        .tag(OUTCOME_TAG_NAME, \"retry\")\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry);\n\n    final Counter errorCounter = Counter.builder(RETRY_CALL_COUNTER_NAME)\n        .tag(RETRY_NAME_TAG, retry.getName())\n        .tag(OUTCOME_TAG_NAME, \"error\")\n        .tags(additionalTags)\n        .register(Metrics.globalRegistry);\n\n    retry.getEventPublisher()\n        .onSuccess(_ -> successCounter.increment())\n        .onRetry(_ -> retryCounter.increment())\n        .onError(_ -> errorCounter.increment());\n\n    meterIds.add(successCounter.getId());\n    meterIds.add(retryCounter.getId());\n    meterIds.add(errorCounter.getId());\n\n    METER_IDS_BY_RETRY_NAME.put(retry.getName(), meterIds);\n  }\n\n  private static void removeMetrics(final CircuitBreaker circuitBreaker) {\n    removeMetrics(METER_IDS_BY_BREAKER_NAME.remove(circuitBreaker.getName()));\n  }\n\n  private static void removeMetrics(final Retry retry) {\n    removeMetrics(METER_IDS_BY_RETRY_NAME.remove(retry.getName()));\n  }\n\n  private static void removeMetrics(@Nullable final Set<Meter.Id> meterIds) {\n    if (meterIds != null) {\n      meterIds.forEach(Metrics.globalRegistry::remove);\n    }\n  }\n\n  private static List<Tag> toTags(final Map<String, String> tagMap) {\n    return tagMap.entrySet().stream()\n        .map(entry -> Tag.of(entry.getKey(), entry.getValue()))\n        .toList();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ServiceIdentifierAdapter.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.JsonDeserializer;\nimport java.io.IOException;\nimport com.fasterxml.jackson.databind.JsonSerializer;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\n\npublic class ServiceIdentifierAdapter {\n\n  public static class ServiceIdentifierSerializer extends JsonSerializer<ServiceIdentifier> {\n\n    @Override\n    public void serialize(final ServiceIdentifier identifier, final JsonGenerator jsonGenerator, final SerializerProvider serializers)\n        throws IOException {\n\n      jsonGenerator.writeString(identifier.toServiceIdentifierString());\n    }\n  }\n\n  public static class AciServiceIdentifierDeserializer extends JsonDeserializer<AciServiceIdentifier> {\n\n    @Override\n    public AciServiceIdentifier deserialize(final JsonParser parser, final DeserializationContext context)\n        throws IOException {\n\n      return AciServiceIdentifier.valueOf(parser.getValueAsString());\n    }\n  }\n\n  public static class PniServiceIdentifierDeserializer extends JsonDeserializer<PniServiceIdentifier> {\n\n    @Override\n    public PniServiceIdentifier deserialize(final JsonParser parser, final DeserializationContext context)\n        throws IOException {\n\n      return PniServiceIdentifier.valueOf(parser.getValueAsString());\n    }\n  }\n\n  public static class ServiceIdentifierDeserializer extends JsonDeserializer<ServiceIdentifier> {\n\n    @Override\n    public ServiceIdentifier deserialize(final JsonParser parser, final DeserializationContext context)\n        throws IOException {\n\n      return ServiceIdentifier.valueOf(parser.getValueAsString());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/SystemMapper.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.annotation.JsonAutoDetect;\nimport com.fasterxml.jackson.annotation.JsonFilter;\nimport com.fasterxml.jackson.annotation.PropertyAccessor;\nimport com.fasterxml.jackson.databind.DeserializationFeature;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.databind.ser.FilterProvider;\nimport com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;\nimport com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;\nimport com.fasterxml.jackson.dataformat.yaml.YAMLMapper;\nimport com.fasterxml.jackson.datatype.jdk8.Jdk8Module;\nimport com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Objects;\nimport javax.annotation.Nonnull;\nimport io.dropwizard.jackson.DiscoverableSubtypeResolver;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretsModule;\n\npublic class SystemMapper {\n\n  private static final ObjectMapper JSON_MAPPER = configureMapper(new ObjectMapper());\n\n  private static final ObjectMapper YAML_MAPPER = configureMapper(new YAMLMapper())\n      .setSubtypeResolver(new DiscoverableSubtypeResolver());\n\n\n  @Nonnull\n  public static ObjectMapper jsonMapper() {\n    return JSON_MAPPER;\n  }\n\n  @Nonnull\n  public static ObjectMapper yamlMapper() {\n    return YAML_MAPPER;\n  }\n\n  public static ObjectMapper configureMapper(final ObjectMapper mapper) {\n    return mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)\n        .setFilterProvider(new SimpleFilterProvider().setDefaultFilter(SimpleBeanPropertyFilter.serializeAll()))\n        .setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE)\n        .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)\n        .setVisibility(PropertyAccessor.CREATOR, JsonAutoDetect.Visibility.PUBLIC_ONLY)\n        .registerModules(\n            SecretsModule.INSTANCE,\n            new JavaTimeModule(),\n            new Jdk8Module());\n  }\n\n  public static FilterProvider excludingField(final Class<?> clazz, final List<String> fieldsToExclude) {\n    final String filterId = clazz.getSimpleName();\n\n    // validate that the target class is annotated with @JsonFilter,\n    final List<JsonFilter> jsonFilterAnnotations = Arrays.stream(clazz.getAnnotations())\n        .map(a -> a instanceof JsonFilter jsonFilter ? jsonFilter : null)\n        .filter(Objects::nonNull)\n        .toList();\n    if (jsonFilterAnnotations.size() != 1 || !jsonFilterAnnotations.get(0).value().equals(filterId)) {\n      throw new IllegalStateException(\"\"\"\n          Class `%1$s` must have a single annotation of type `JsonFilter` \n          with the value equal to the name of the class itself: `@JsonFilter(\"%1$s\")`\n          \"\"\".formatted(filterId));\n    }\n\n    return new SimpleFilterProvider()\n        .addFilter(filterId, SimpleBeanPropertyFilter.serializeAllExcept(fieldsToExclude.toArray(new String[0])));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ThrowingConsumer.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\n/// Represents an operation that accepts a single input argument and returns no result, but may throw a checked\n/// exception. Unlike most other functional interfaces, `ThrowingConsumer` is expected to operate via side-effects.\n@FunctionalInterface\npublic interface ThrowingConsumer<T, E extends Exception> {\n\n  /// Performs this operation on the given argument.\n  ///\n  /// @param t the input argument\n  ///\n  /// @throws E at the implementation's discretion\n  void accept(T t) throws E;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ThrowingSupplier.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\n/// Represents a supplier of results that may throw an exception.\n///\n/// There is no requirement that a new or distinct result be returned each time the supplier is invoked.\n@FunctionalInterface\npublic interface ThrowingSupplier<T, E extends Exception> {\n\n  /// Gets a result, potentially throwing an exception.\n  ///\n  /// @return a result\n  ///\n  /// @throws E at the discretion of the implementation\n  T get() throws E;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/UUIDUtil.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.protobuf.ByteString;\nimport java.nio.BufferUnderflowException;\nimport java.nio.ByteBuffer;\nimport java.util.UUID;\n\npublic final class UUIDUtil {\n\n  private UUIDUtil() {\n    // utility class\n  }\n\n  public static byte[] toBytes(final UUID uuid) {\n    return toByteBuffer(uuid).array();\n  }\n\n  public static ByteBuffer toByteBuffer(final UUID uuid) {\n    final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[16]);\n    byteBuffer.putLong(uuid.getMostSignificantBits());\n    byteBuffer.putLong(uuid.getLeastSignificantBits());\n    return byteBuffer.flip();\n  }\n\n  public static ByteString toByteString(final UUID uuid) {\n    return ByteString.copyFrom(toByteBuffer(uuid));\n  }\n\n  public static UUID fromByteString(final ByteString byteString) {\n    return fromBytes(byteString.toByteArray());\n  }\n\n  public static UUID fromBytes(final byte[] bytes) {\n    return fromByteBuffer(ByteBuffer.wrap(bytes));\n  }\n\n  public static UUID fromBytes(final byte[] bytes, final int offset) {\n    return fromByteBuffer(ByteBuffer.wrap(bytes, offset, 16));\n  }\n\n  public static UUID fromByteBuffer(final ByteBuffer byteBuffer) {\n    try {\n      final long mostSigBits = byteBuffer.getLong();\n      final long leastSigBits = byteBuffer.getLong();\n      if (byteBuffer.hasRemaining()) {\n        throw new IllegalArgumentException(\"unexpected byte array length; was greater than 16\");\n      }\n      return new UUID(mostSigBits, leastSigBits);\n    } catch (BufferUnderflowException e) {\n      throw new IllegalArgumentException(\"unexpected byte array length; was less than 16\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/UsernameHashZkProofVerifier.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.signal.libsignal.usernames.BaseUsernameException;\nimport org.signal.libsignal.usernames.Username;\n\npublic class UsernameHashZkProofVerifier {\n  public void verifyProof(final byte[] proof, final byte[] hash) throws BaseUsernameException {\n    Username.verifyProof(proof, hash);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/Util.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport com.google.i18n.phonenumbers.Phonenumber.PhoneNumber;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Locale.LanguageRange;\nimport java.util.Optional;\nimport java.util.Random;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Function;\nimport java.util.random.RandomGenerator;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.StringUtils;\n\npublic class Util {\n\n  private static final RandomGenerator RANDOM_GENERATOR = new Random();\n\n  private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();\n\n  public static final Runnable NOOP = () -> {};\n\n  // Use `CompletableFuture#thenApply(ASYNC_EMPTY_RESPONSE) to convert futures to\n  // CompletableFuture<Response> instead of using NOOP to convert them to CompletableFuture<Void>\n  // for jersey controllers; https://github.com/eclipse-ee4j/jersey/issues/3901 causes controllers\n  // returning Void futures to behave differently than synchronous controllers returning void\n  public static final Function<Object, Response> ASYNC_EMPTY_RESPONSE = ignored -> Response.noContent().build();\n\n  /**\n   * Checks that the given number is a valid, E164-normalized phone number.\n   *\n   * @param number the number to check\n   *\n   * @throws ImpossiblePhoneNumberException if the given number is not a valid phone number at all\n   * @throws NonNormalizedPhoneNumberException if the given number is a valid phone number, but isn't E164-normalized\n   */\n  public static void requireNormalizedNumber(final String number) throws ImpossiblePhoneNumberException, NonNormalizedPhoneNumberException {\n    if (!PHONE_NUMBER_UTIL.isPossibleNumber(number, null)) {\n      throw new ImpossiblePhoneNumberException();\n    }\n\n    try {\n      final PhoneNumber inputNumber = PHONE_NUMBER_UTIL.parse(number, null);\n\n      // For normalization, we want to format from a version parsed with the country code removed.\n      // This handles some cases of \"possible\", but non-normalized input numbers with a doubled country code, that is\n      // with the format \"+{country code} {country code} {national number}\"\n      final int countryCode = inputNumber.getCountryCode();\n      final String region = PHONE_NUMBER_UTIL.getRegionCodeForCountryCode(countryCode);\n\n      final PhoneNumber normalizedNumber = switch (region) {\n        // the country code has no associated region. Be lenient (and simple) and accept the input number\n        case \"ZZ\", \"001\" -> inputNumber;\n        default -> {\n          final String maybeLeadingZero =\n              inputNumber.hasItalianLeadingZero() && inputNumber.isItalianLeadingZero() ? \"0\" : \"\";\n          yield PHONE_NUMBER_UTIL.parse(\n              maybeLeadingZero + inputNumber.getNationalNumber(), region);\n        }\n      };\n\n      final String normalizedE164 = PHONE_NUMBER_UTIL.format(normalizedNumber,\n          PhoneNumberFormat.E164);\n\n      if (!number.equals(normalizedE164)) {\n        throw new NonNormalizedPhoneNumberException(number, normalizedE164);\n      }\n    } catch (final NumberParseException e) {\n      throw new ImpossiblePhoneNumberException(e);\n    }\n  }\n\n  public static String getCountryCode(String number) {\n    try {\n      return String.valueOf(PHONE_NUMBER_UTIL.parse(number, null).getCountryCode());\n    } catch (final NumberParseException e) {\n      return \"0\";\n    }\n  }\n\n  public static String getRegion(final String number) {\n    try {\n      final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null);\n      return StringUtils.defaultIfBlank(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber), \"ZZ\");\n    } catch (final NumberParseException e) {\n      return \"ZZ\";\n    }\n  }\n\n  /**\n   * Returns a list of equivalent phone numbers to the given phone number. This is useful in cases where a numbering\n   * authority has changed the numbering format for a region or in cases where multiple formats of a number may be valid\n   * in different circumstances. Numbers are considered equivalent if a call/message sent to each number will generally\n   * arrive at the same device.\n   *\n   * @apiNote This method is intended to support number format transitions in cases where we do not already have\n   * multiple accounts registered with different forms of the same number. As a result, this method does not cover all\n   * possible cases of equivalent formats, but instead focuses on the cases where we can and choose to prevent multiple\n   * accounts from using different formats of the same number.\n   *\n   * @param number the e164-formatted phone number for which to find equivalent forms\n   *\n   * @return a list of phone numbers equivalent to the given phone number, including the given number. The given number\n   * will always be the first element of the list.\n   */\n  public static List<String> getAlternateForms(final String number) {\n    try {\n      final PhoneNumber phoneNumber = PHONE_NUMBER_UTIL.parse(number, null);\n\n      // Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX on November 30, 2024\n      if (\"BJ\".equals(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber))) {\n        final String nationalSignificantNumber = PHONE_NUMBER_UTIL.getNationalSignificantNumber(phoneNumber);\n        final String alternateE164;\n\n        if (nationalSignificantNumber.length() == 10) {\n          // This is a new-format number; we can get the old-format version by stripping the leading \"01\" from the\n          // national number\n          alternateE164 = \"+229\" + StringUtils.removeStart(nationalSignificantNumber, \"01\");\n        } else {\n          // This is an old-format number; we can get the new-format version by adding a \"01\" prefix to the national\n          // number\n          alternateE164 = \"+22901\" + nationalSignificantNumber;\n        }\n\n        return List.of(number, alternateE164);\n      }\n\n      return List.of(number);\n    } catch (final NumberParseException e) {\n      return List.of(number);\n    }\n  }\n\n  /**\n   * Returns the preferred form of an e164 from a list of equivalents. Only use this when there is no other reason (such\n   * as the form specifically provided by a user) to prefer a particular form and we want to reduce nondeterminism.\n   *\n   * @apiNote This method is intended to support number format transitions in cases where we do not already have\n   * multiple accounts registered with different forms of the same number. As a result, this method does not cover all\n   * possible cases of equivalent formats, but instead focuses on the cases where we can and choose to prevent multiple\n   * accounts from using different formats of the same number.\n   *\n   * @param e164s a list of equivalent forms of a single phone number\n   *\n   * @return a single preferred canonical form for the number\n   */\n  public static Optional<String> getCanonicalNumber(List<String> e164s) {\n    if (e164s.size() <= 1) {\n      return e164s.stream().findFirst();\n    }\n    try {\n      final List<PhoneNumber> phoneNumbers = new ArrayList<>(e164s.size());\n      for (String e164 : e164s) {\n        phoneNumbers.add(PHONE_NUMBER_UTIL.parse(e164, null));\n      }\n      final Set<String> regions = phoneNumbers.stream().map(PHONE_NUMBER_UTIL::getRegionCodeForNumber).collect(Collectors.toSet());\n      if (regions.size() != 1) {\n        throw new IllegalArgumentException(\"Numbers from different countries cannot be equivalent alternate forms\");\n      }\n      if (regions.contains(\"BJ\")) {\n        // Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX on November 30, 2024\n        // We prefer the longest form for long-term stability\n        return e164s.stream().sorted(Comparator.comparingInt(String::length).reversed()).findFirst();\n      }\n      // No matching country; fall back to something that's at least stable\n      return e164s.stream().sorted().findFirst();\n    } catch (final NumberParseException e) {\n      return e164s.stream().sorted().findFirst();\n    }\n  }\n\n  /**\n   * Tests whether the decimal form of the given number (without leading zeroes) begins with the decimal form of the\n   * given prefix (without leading zeroes).\n   *\n   * @param number the number to check for the given prefix\n   * @param prefix the prefix\n   *\n   * @return {@code true} if the given number starts with the given prefix or {@code false} otherwise\n   *\n   * @throws IllegalArgumentException if {@code number} is negative or if {@code prefix} is zero or negative\n   */\n  public static boolean startsWithDecimal(final long number, final long prefix) {\n    if (number < 0) {\n      throw new IllegalArgumentException(\"Number must be non-negative\");\n    }\n\n    if (prefix <= 0) {\n      throw new IllegalArgumentException(\"Prefix must be positive\");\n    }\n\n    long workingCopy = number;\n\n    while (workingCopy > prefix) {\n      workingCopy /= 10;\n    }\n\n    return workingCopy == prefix;\n  }\n\n  /**\n   * Benin changed phone number formats from +229 XXXXXXXX to +229 01XXXXXXXX on November 30, 2024\n   *\n   * @param phoneNumber the phone number to check.\n   * @return whether the provided phone number is an old-format Benin phone number\n   */\n  public static boolean isOldFormatBeninPhoneNumber(final Phonenumber.PhoneNumber phoneNumber) {\n    return \"BJ\".equals(PHONE_NUMBER_UTIL.getRegionCodeForNumber(phoneNumber)) &&\n        PHONE_NUMBER_UTIL.getNationalSignificantNumber(phoneNumber).length() == 8;\n  }\n\n  /**\n   * If applicable, return the canonical form of the provided phone number.\n   * This is relevant in cases where a numbering authority has changed the numbering format for a region.\n   *\n   * @param phoneNumber the phone number to canonicalize.\n   * @return the canonical phone number if applicable, otherwise the original phone number.\n   */\n  public static Phonenumber.PhoneNumber canonicalizePhoneNumber(final Phonenumber.PhoneNumber phoneNumber)\n      throws NumberParseException, ObsoletePhoneNumberFormatException {\n    if (isOldFormatBeninPhoneNumber(phoneNumber)) {\n      throw new ObsoletePhoneNumberFormatException(\"bj\");\n    }\n    return phoneNumber;\n  }\n\n  public static byte[] truncate(byte[] element, int length) {\n    byte[] result = new byte[length];\n    System.arraycopy(element, 0, result, 0, result.length);\n\n    return result;\n  }\n\n  public static void sleep(long i) {\n    try {\n      Thread.sleep(i);\n    } catch (final InterruptedException ignored) {\n    }\n  }\n\n  public static long todayInMillis() {\n    return todayInMillis(Clock.systemUTC());\n  }\n\n  public static long todayInMillis(Clock clock) {\n    return TimeUnit.DAYS.toMillis(TimeUnit.MILLISECONDS.toDays(clock.millis()));\n  }\n\n  public static long todayInMillisGivenOffsetFromNow(Clock clock, Duration offset) {\n    final long ms = offset.toMillis() + clock.millis();\n    return TimeUnit.DAYS.toMillis(TimeUnit.MILLISECONDS.toDays(ms));\n  }\n\n  public static Optional<String> findBestLocale(List<LanguageRange> priorityList, Collection<String> supportedLocales) {\n    return Optional.ofNullable(Locale.lookupTag(priorityList, supportedLocales));\n  }\n\n  /**\n   * Map ints to non-negative ints.\n   * <br>\n   * Unlike Math.abs this method handles Integer.MIN_VALUE correctly.\n   *\n   * @param n any int value\n   * @return an int value guaranteed to be non-negative\n   */\n  public static int ensureNonNegativeInt(int n) {\n    return n == Integer.MIN_VALUE ? 0 : Math.abs(n);\n  }\n\n  /**\n   * Map longs to non-negative longs.\n   * <br>\n   * Unlike Math.abs this method handles Long.MIN_VALUE correctly.\n   *\n   * @param n any long value\n   * @return a long value guaranteed to be non-negative\n   */\n  public static long ensureNonNegativeLong(long n) {\n    return n == Long.MIN_VALUE ? 0 : Math.abs(n);\n  }\n\n  /**\n   * Chooses min(values.size(), n) random values in shuffled order.\n   * <br>\n   * Copies the input Array - use for small lists only or for when n/values.size() is near 1.\n   */\n  public static <E> List<E> randomNOfShuffled(List<E> values, int n) {\n    if(values == null || values.isEmpty()) {\n      return Collections.emptyList();\n    }\n\n    List<E> result = new ArrayList<>(values);\n    Collections.shuffle(result);\n\n    return result.stream().limit(n).toList();\n  }\n\n  /**\n   * Chooses min(values.size(), n) random values. Return value is in stable order from input values.\n   * Not uniform random, but good enough.\n   * <br>\n   * Does NOT copy the input Array.\n   */\n  public static <E> List<E> randomNOfStable(List<E> values, int n) {\n    if(values == null || values.isEmpty()) {\n      return Collections.emptyList();\n    }\n    if(n >= values.size()) {\n      return values;\n    }\n\n    Set<Integer> indices = new HashSet<>(RANDOM_GENERATOR.ints(0, values.size()).distinct().limit(n).boxed().toList());\n    List<E> result = new ArrayList<>(n);\n    for(int i = 0; i < values.size() && result.size() < n; i++) {\n      if(indices.contains(i)) {\n        result.add(values.get(i));\n      }\n    }\n\n    return result;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ValidBase64URLString.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static java.lang.annotation.ElementType.FIELD;\nimport static java.lang.annotation.ElementType.METHOD;\nimport static java.lang.annotation.ElementType.PARAMETER;\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\nimport jakarta.validation.Constraint;\nimport jakarta.validation.ConstraintValidator;\nimport jakarta.validation.ConstraintValidatorContext;\nimport jakarta.validation.Payload;\nimport java.lang.annotation.Documented;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\nimport java.util.Base64;\nimport java.util.Objects;\n\n/**\n * Constraint annotation that requires annotated entity is a valid url-base64 encoded string.\n */\n@Target({ FIELD, PARAMETER, METHOD })\n@Retention(RUNTIME)\n@Constraint(validatedBy = ValidBase64URLString.Validator.class)\n@Documented\npublic @interface ValidBase64URLString {\n\n  String message() default \"value is not a valid base64 string\";\n\n  Class<?>[] groups() default { };\n\n  Class<? extends Payload>[] payload() default { };\n\n  class Validator implements ConstraintValidator<ValidBase64URLString, String> {\n\n    @Override\n    public boolean isValid(final String value, final ConstraintValidatorContext context) {\n      if (Objects.isNull(value)) {\n        return true;\n      }\n      try {\n        Base64.getUrlDecoder().decode(value);\n        return true;\n      } catch (IllegalArgumentException e) {\n        return false;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ValidHexString.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static java.lang.annotation.ElementType.FIELD;\nimport static java.lang.annotation.ElementType.METHOD;\nimport static java.lang.annotation.ElementType.PARAMETER;\nimport static java.lang.annotation.RetentionPolicy.RUNTIME;\n\nimport jakarta.validation.Constraint;\nimport jakarta.validation.ConstraintValidator;\nimport jakarta.validation.ConstraintValidatorContext;\nimport jakarta.validation.Payload;\nimport java.lang.annotation.Documented;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.Target;\nimport java.util.HexFormat;\nimport java.util.Objects;\n\n/**\n * Constraint annotation that requires annotated entity is a valid hex encoded string.\n */\n@Target({ FIELD, PARAMETER, METHOD })\n@Retention(RUNTIME)\n@Constraint(validatedBy = ValidHexString.Validator.class)\n@Documented\npublic @interface ValidHexString {\n\n  String message() default \"value is not a valid hex string\";\n\n  Class<?>[] groups() default { };\n\n  Class<? extends Payload>[] payload() default { };\n\n  class Validator implements ConstraintValidator<ValidHexString, String> {\n\n    @Override\n    public boolean isValid(final String value, final ConstraintValidatorContext context) {\n      if (Objects.isNull(value)) {\n        return true;\n      }\n      try {\n        HexFormat.of().parseHex(value);\n        return true;\n      } catch (IllegalArgumentException e) {\n        return false;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/VirtualExecutorServiceProvider.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.util.List;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport org.glassfish.jersey.server.ManagedAsyncExecutor;\nimport org.glassfish.jersey.spi.ExecutorServiceProvider;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n@ManagedAsyncExecutor\npublic class VirtualExecutorServiceProvider implements ExecutorServiceProvider {\n\n  private static final Logger logger = LoggerFactory.getLogger(VirtualExecutorServiceProvider.class);\n\n\n  /**\n   * Default thread pool executor termination timeout in milliseconds.\n   */\n  public static final int TERMINATION_TIMEOUT = 5000;\n\n  private final String virtualThreadNamePrefix;\n  private final int maxConcurrentThreads;\n\n  public VirtualExecutorServiceProvider(\n      final String virtualThreadNamePrefix,\n      final int maxConcurrentThreads) {\n    this.virtualThreadNamePrefix = virtualThreadNamePrefix;\n    this.maxConcurrentThreads = maxConcurrentThreads;\n  }\n\n\n  @Override\n  public ExecutorService getExecutorService() {\n    logger.info(\"Creating executor service with virtual thread per task\");\n    final ExecutorService executor = Executors.newThreadPerTaskExecutor(\n        new BoundedVirtualThreadFactory(virtualThreadNamePrefix, maxConcurrentThreads));\n    return executor;\n  }\n\n  @Override\n  public void dispose(final ExecutorService executorService) {\n    logger.info(\"Shutting down virtual thread pool executor\");\n\n    executorService.shutdown();\n    boolean terminated = false;\n    try {\n      terminated = executorService.awaitTermination(TERMINATION_TIMEOUT, TimeUnit.MILLISECONDS);\n    } catch (InterruptedException e) {\n      Thread.currentThread().interrupt();\n    }\n\n    if (!terminated) {\n      // virtual thread per task executor has no queue, so shouldn't have any un-run tasks\n      final List<Runnable> unrunTasks = executorService.shutdownNow();\n      logger.info(\"Force terminated executor with {} un-run tasks\", unrunTasks.size());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/VirtualThreadPinEventMonitor.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.lifecycle.Managed;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Duration;\nimport java.util.Set;\nimport java.util.concurrent.ExecutorService;\nimport java.util.function.BiConsumer;\nimport java.util.function.Consumer;\nimport java.util.function.Supplier;\nimport java.util.stream.Collectors;\nimport jdk.jfr.consumer.RecordedEvent;\nimport jdk.jfr.consumer.RecordedFrame;\nimport jdk.jfr.consumer.RecordedStackTrace;\nimport jdk.jfr.consumer.RecordedThread;\nimport jdk.jfr.consumer.RecordingStream;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\n\n/**\n * Watches for JFR events indicating that a virtual thread was pinned\n */\npublic class VirtualThreadPinEventMonitor implements Managed {\n\n  private static final Logger logger = LoggerFactory.getLogger(VirtualThreadPinEventMonitor.class);\n  private static final String PIN_COUNTER_NAME = MetricsUtil.name(VirtualThreadPinEventMonitor.class,\n      \"virtualThreadPinned\");\n  private static final String JFR_THREAD_PINNED_EVENT_NAME = \"jdk.VirtualThreadPinned\";\n  private static final long MAX_JFR_REPOSITORY_SIZE = 1024 * 1024 * 4L; // 4MiB\n\n  private final ExecutorService executorService;\n  private final Duration pinEventThreshold;\n  private final RecordingStream recordingStream;\n\n  private final Consumer<RecordedEvent> pinEventConsumer;\n\n  @VisibleForTesting\n  VirtualThreadPinEventMonitor(\n      final ExecutorService executorService,\n      final Duration pinEventThreshold,\n      final Consumer<RecordedEvent> pinEventConsumer) {\n    this.executorService = executorService;\n    this.pinEventThreshold = pinEventThreshold;\n    this.pinEventConsumer = pinEventConsumer;\n    this.recordingStream = new RecordingStream();\n  }\n  public VirtualThreadPinEventMonitor(\n      final ExecutorService executorService,\n      final Duration pinEventThreshold) {\n    this(executorService, pinEventThreshold, VirtualThreadPinEventMonitor::processPinEvent);\n  }\n\n  @Override\n  public void start() {\n    recordingStream.setMaxSize(MAX_JFR_REPOSITORY_SIZE);\n    recordingStream.enable(JFR_THREAD_PINNED_EVENT_NAME).withThreshold(pinEventThreshold).withStackTrace();\n    recordingStream.onEvent(JFR_THREAD_PINNED_EVENT_NAME, pinEventConsumer);\n    executorService.submit(recordingStream::start);\n  }\n\n  @Override\n  public void stop() throws InterruptedException {\n    // flushes events and waits for callbacks to finish\n    try {\n      recordingStream.stop();\n    } catch (final IllegalStateException _) {\n      // The JFR recorder registers its own shutdown hook with the JVM.\n      // Since shutdown hook execution order is not guaranteed but JFR's hook usually runs early,\n      // this recording may already be stopped before our managed resource cleanup runs.\n      logger.info(\"RecordingStream already stopped\");\n    }\n    // immediately frees all resources\n    recordingStream.close();\n  }\n\n  private static void processPinEvent(final RecordedEvent event) {\n    logger.info(\"Long virtual thread pin event detected {}\", prettyEventString(event));\n    Metrics.counter(PIN_COUNTER_NAME).increment();\n  }\n\n  private static String prettyEventString(final RecordedEvent event) {\n    // event.toString() hard codes a stack depth of 5, which is not enough to\n    // determine the source of the event in most cases\n\n    return \"\"\"\n        %s {\n          startTime = %s\n          duration = %s\n          eventThread = %s\n          stackTrace = %s\n        }\"\"\".formatted(event.getEventType().getName(),\n        event.getStartTime(),\n        event.getDuration(),\n        prettyThreadString(event.getThread()),\n        prettyStackTraceString(event.getStackTrace(), \"  \"));\n  }\n\n  private static String prettyStackTraceString(final RecordedStackTrace st, final String indent) {\n    if (st == null) {\n      return \"n/a\";\n    }\n    // No need to put a limit, by default JFR stack traces are limited to 64 frames. They can be increased at jvm start\n    // with the FlightRecorderOptions stackdepth option\n    return \"[\\n\" + indent + indent + st.getFrames().stream()\n        .filter(RecordedFrame::isJavaFrame)\n        .map(frame -> \"%s.%s:%s\".formatted(frame.getMethod().getType().getName(), frame.getMethod().getName(), frame.getLineNumber()))\n        .collect(Collectors.joining(\",\\n\" + indent + indent))\n        + \"\\n\" + indent + \"]\";\n  }\n\n  private static String prettyThreadString(final RecordedThread thread) {\n    if (thread == null) {\n      return \"n/a\";\n    }\n    return \"%s (javaThreadId = %s)\".formatted(thread.getJavaName(), thread.getJavaThreadId()) ;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/WeightedRandomSelect.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport java.util.List;\nimport java.util.concurrent.ThreadLocalRandom;\n\n/**\n * Select a random item according to its weight\n *\n * @param <T> the type of the objects to select from\n */\npublic class WeightedRandomSelect<T> {\n\n  List<Pair<T, Long>> weightedItems;\n  long totalWeight;\n\n  public WeightedRandomSelect(List<Pair<T, Long>> weightedItems) throws IllegalArgumentException {\n    this.weightedItems = weightedItems;\n    this.totalWeight = weightedItems.stream().mapToLong(Pair::second).sum();\n\n    weightedItems.stream().map(Pair::second).filter(w -> w < 0).findFirst().ifPresent(invalid -> {\n      throw new IllegalArgumentException(\"Illegal selection weight \" + invalid);\n    });\n\n    if (weightedItems.isEmpty() || totalWeight == 0) {\n      throw new IllegalArgumentException(\"Cannot create an empty weighted random selector\");\n    }\n  }\n\n  public T select() {\n    if (weightedItems.size() == 1) {\n      return weightedItems.get(0).first();\n    }\n    long select = ThreadLocalRandom.current().nextLong(0, totalWeight);\n    long current = 0;\n    for (Pair<T, Long> item : weightedItems) {\n      /*\n        Accumulate weights for each item and select the first item whose\n        cumulative weight exceeds the selected value. nextLong() is exclusive,\n        so by the last item we're guaranteed to find a value as the\n        last item's weight is one more than the maximum value of select.\n      */\n      current += item.second();\n      if (current > select) {\n        return item.first();\n      }\n    }\n    throw new IllegalStateException(\"totalWeight \" + totalWeight + \" exceeds item weights\");\n  }\n\n  public static <T> T select(List<Pair<T, Long>> weightedItems) {\n    return new WeightedRandomSelect<T>(weightedItems).select();\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapper.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.jersey.errors.LoggingExceptionMapper;\nimport jakarta.inject.Provider;\nimport jakarta.ws.rs.core.Context;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.slf4j.Logger;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\n\n/**\n * Extends {@link LoggingExceptionMapper} to include the method and path in the log message, if they are available.\n */\npublic class LoggingUnhandledExceptionMapper extends LoggingExceptionMapper<Throwable> {\n\n  @Context\n  private Provider<ContainerRequest> request;\n\n  public LoggingUnhandledExceptionMapper() {\n    super();\n  }\n\n  @VisibleForTesting\n  LoggingUnhandledExceptionMapper(final Logger logger) {\n    super(logger);\n  }\n\n  @Override\n  protected String formatLogMessage(final long id, final Throwable exception) {\n    String requestMethod = \"unknown method\";\n    String userAgent = \"missing\";\n    String requestPath = \"/{unknown path}\";\n    try {\n      // request shouldn’t be `null`, but it is technically possible\n      requestMethod = request.get().getMethod();\n      requestPath = UriInfoUtil.getPathTemplate(request.get().getUriInfo());\n      userAgent = request.get().getHeaderString(HttpHeaders.USER_AGENT);\n\n      // streamline the user-agent if it is recognized\n      final UserAgent ua = UserAgentUtil.parseUserAgentString(userAgent);\n      userAgent = String.format(\"%s %s\", ua.platform(), ua.version());\n    } catch (final UnrecognizedUserAgentException ignored) {\n\n    } catch (final Exception e) {\n      logger.warn(\"Unexpected exception getting request details\", e);\n    }\n\n    return String.format(\"%s at %s %s (%s)\",\n        super.formatLogMessage(id, exception),\n        requestMethod,\n        requestPath,\n        userAgent) ;\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport ch.qos.logback.core.filter.Filter;\nimport ch.qos.logback.core.spi.FilterReply;\n\nclass RequestLogEnabledFilter<E> extends Filter<E> {\n\n    private volatile boolean requestLoggingEnabled = false;\n\n    @Override\n    public FilterReply decide(final E event) {\n        return requestLoggingEnabled ? FilterReply.NEUTRAL : FilterReply.DENY;\n    }\n\n    public void setRequestLoggingEnabled(final boolean requestLoggingEnabled) {\n        this.requestLoggingEnabled = requestLoggingEnabled;\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogEnabledFilterFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport ch.qos.logback.access.common.spi.IAccessEvent;\nimport ch.qos.logback.core.filter.Filter;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport io.dropwizard.logging.common.filter.FilterFactory;\n\n@JsonTypeName(\"requestLogEnabled\")\nclass RequestLogEnabledFilterFactory implements FilterFactory<IAccessEvent> {\n\n    @Override\n    public Filter<IAccessEvent> build() {\n        return RequestLogManager.getHttpRequestLogFilter();\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/RequestLogManager.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport ch.qos.logback.access.common.spi.IAccessEvent;\nimport ch.qos.logback.core.filter.Filter;\n\npublic class RequestLogManager {\n    private static final RequestLogEnabledFilter<IAccessEvent> HTTP_REQUEST_LOG_FILTER = new RequestLogEnabledFilter<>();\n\n    static Filter<IAccessEvent> getHttpRequestLogFilter() {\n        return HTTP_REQUEST_LOG_FILTER;\n    }\n\n    public static void setRequestLoggingEnabled(final boolean enabled) {\n        HTTP_REQUEST_LOG_FILTER.setRequestLoggingEnabled(enabled);\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UncaughtExceptionHandler.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport javax.annotation.Nullable;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class UncaughtExceptionHandler {\n\n  private static final Logger logger = LoggerFactory.getLogger(UncaughtExceptionHandler.class);\n\n  public static void register() {\n    @Nullable final Thread.UncaughtExceptionHandler current = Thread.getDefaultUncaughtExceptionHandler();\n\n    if (current != null) {\n      logger.warn(\"Uncaught exception handler already exists: {}\", current);\n      return;\n    }\n\n    Thread.setDefaultUncaughtExceptionHandler((t, e) -> logger.error(\"Uncaught exception on thread {}\", t, e));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UnknownKeepaliveOptionFilter.java",
    "content": "package org.whispersystems.textsecuregcm.util.logging;\n\nimport ch.qos.logback.classic.spi.ILoggingEvent;\nimport ch.qos.logback.core.filter.Filter;\nimport ch.qos.logback.core.spi.FilterReply;\n\n/**\n * Filters spurious warnings about setting a very specific channel option on local channels.\n * <p/>\n * gRPC unconditionally tries to set the {@code SO_KEEPALIVE} option on all of its channels, but local channels, which\n * are used by the Noise-over-WebSocket tunnel, do not support {@code SO_KEEPALIVE} and log a warning on each new\n * channel. We don't want to filter <em>all</em> warnings from the relevant logger, and so this custom filter denies\n * attempts to log the specific spurious message.\n */\npublic class UnknownKeepaliveOptionFilter extends Filter<ILoggingEvent> {\n\n  private static final String MESSAGE_PREFIX = \"Unknown channel option 'SO_KEEPALIVE'\";\n\n  @Override\n  public FilterReply decide(final ILoggingEvent event) {\n    final boolean loggerNameMatches = \"io.netty.bootstrap.Bootstrap\".equals(event.getLoggerName()) ||\n        \"io.netty.bootstrap.ServerBootstrap\".equals(event.getLoggerName());\n\n    return loggerNameMatches && event.getMessage().startsWith(MESSAGE_PREFIX) ? FilterReply.DENY : FilterReply.NEUTRAL;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UnknownKeepaliveOptionFilterFactory.java",
    "content": "package org.whispersystems.textsecuregcm.util.logging;\n\nimport ch.qos.logback.classic.spi.ILoggingEvent;\nimport ch.qos.logback.core.filter.Filter;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport io.dropwizard.logging.common.filter.FilterFactory;\n\n@JsonTypeName(\"unknownKeepaliveOption\")\npublic class UnknownKeepaliveOptionFilterFactory implements FilterFactory<ILoggingEvent> {\n\n  @Override\n  public Filter<ILoggingEvent> build() {\n    return new UnknownKeepaliveOptionFilter();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtil.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport org.glassfish.jersey.server.ExtendedUriInfo;\n\npublic class UriInfoUtil {\n\n  public static String getPathTemplate(final ExtendedUriInfo uriInfo) {\n      final StringBuilder pathBuilder = new StringBuilder();\n\n      for (int i = uriInfo.getMatchedTemplates().size() - 1; i >= 0; i--) {\n          pathBuilder.append(uriInfo.getMatchedTemplates().get(i).getTemplate());\n      }\n\n      return pathBuilder.toString();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ua/ClientPlatform.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.ua;\n\npublic enum ClientPlatform {\n    ANDROID,\n    DESKTOP,\n    IOS;\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UnrecognizedUserAgentException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.ua;\n\npublic class UnrecognizedUserAgentException extends Exception {\n\n    public UnrecognizedUserAgentException() {\n    }\n\n    public UnrecognizedUserAgentException(final String message) {\n        super(message);\n    }\n\n    public UnrecognizedUserAgentException(final Throwable cause) {\n        super(cause);\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgent.java",
    "content": "/*\n * Copyright 2013-2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.ua;\n\nimport com.vdurmont.semver4j.Semver;\nimport javax.annotation.Nullable;\nimport java.util.Objects;\n\npublic record UserAgent(ClientPlatform platform, Semver version, @Nullable String additionalSpecifiers) {\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtil.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.ua;\n\nimport com.vdurmont.semver4j.Semver;\nimport java.util.regex.Matcher;\nimport java.util.regex.Pattern;\nimport org.apache.commons.lang3.StringUtils;\n\npublic class UserAgentUtil {\n\n  private static final Pattern STANDARD_UA_PATTERN = Pattern.compile(\"^Signal-(Android|Desktop|iOS)/([^ ]+)( (.+))?$\", Pattern.CASE_INSENSITIVE);\n\n  public static UserAgent parseUserAgentString(final String userAgentString) throws UnrecognizedUserAgentException {\n    if (StringUtils.isBlank(userAgentString)) {\n      throw new UnrecognizedUserAgentException(\"User-Agent string is blank\");\n    }\n\n    try {\n      final Matcher matcher = STANDARD_UA_PATTERN.matcher(userAgentString);\n\n      if (matcher.matches()) {\n        return new UserAgent(ClientPlatform.valueOf(matcher.group(1).toUpperCase()), new Semver(matcher.group(2)), StringUtils.stripToNull(matcher.group(4)));\n      }\n    } catch (final Exception e) {\n      throw new UnrecognizedUserAgentException(e);\n    }\n\n    throw new UnrecognizedUserAgentException();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListener.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.util.Optional;\nimport java.util.function.Supplier;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.metrics.OpenWebSocketCounter;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.push.ReceiptSender;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.websocket.WebSocketClient;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\nimport org.whispersystems.websocket.setup.WebSocketConnectListener;\nimport reactor.core.scheduler.Scheduler;\n\npublic class AuthenticatedConnectListener implements WebSocketConnectListener {\n\n  private static final Logger log = LoggerFactory.getLogger(AuthenticatedConnectListener.class);\n\n  private final AccountsManager accountsManager;\n  private final DisconnectionRequestManager disconnectionRequestManager;\n  private final WebSocketConnectionBuilder webSocketConnectionBuilder;\n\n  private final OpenWebSocketCounter openAuthenticatedWebSocketCounter;\n  private final OpenWebSocketCounter openUnauthenticatedWebSocketCounter;\n\n  @VisibleForTesting\n  @FunctionalInterface\n  interface WebSocketConnectionBuilder {\n    WebSocketConnection buildWebSocketConnection(Account account, Device device, WebSocketClient client);\n  }\n\n  public AuthenticatedConnectListener(\n      final AccountsManager accountsManager,\n      final ReceiptSender receiptSender,\n      final MessagesManager messagesManager,\n      final MessageMetrics messageMetrics,\n      final PushNotificationManager pushNotificationManager,\n      final PushNotificationScheduler pushNotificationScheduler,\n      final DisconnectionRequestManager disconnectionRequestManager,\n      final Scheduler messageDeliveryScheduler,\n      final Supplier<AsnInfoProvider> asnInfoProviderSupplier,\n      final ClientReleaseManager clientReleaseManager,\n      final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) {\n\n    this(accountsManager,\n        disconnectionRequestManager,\n        asnInfoProviderSupplier,\n        clientReleaseManager,\n        (account, device, client) -> new WebSocketConnection(receiptSender,\n            messagesManager,\n            messageMetrics,\n            pushNotificationManager,\n            pushNotificationScheduler,\n            account,\n            device,\n            client,\n            messageDeliveryScheduler,\n            clientReleaseManager,\n            messageDeliveryLoopMonitor,\n            experimentEnrollmentManager)\n    );\n  }\n\n  @VisibleForTesting AuthenticatedConnectListener(\n      final AccountsManager accountsManager,\n      final DisconnectionRequestManager disconnectionRequestManager,\n      final Supplier<AsnInfoProvider> asnInfoProviderSupplier,\n      final ClientReleaseManager clientReleaseManager,\n      final WebSocketConnectionBuilder webSocketConnectionBuilder) {\n\n    this.accountsManager = accountsManager;\n    this.disconnectionRequestManager = disconnectionRequestManager;\n    this.webSocketConnectionBuilder = webSocketConnectionBuilder;\n\n    this.openAuthenticatedWebSocketCounter = new OpenWebSocketCounter(\"rpc-authenticated\", asnInfoProviderSupplier, clientReleaseManager);\n    this.openUnauthenticatedWebSocketCounter = new OpenWebSocketCounter(\"rpc-unauthenticated\", asnInfoProviderSupplier, clientReleaseManager);\n  }\n\n  @Override\n  public void onWebSocketConnect(final WebSocketSessionContext context) {\n\n    final boolean authenticated = (context.getAuthenticated() != null);\n\n    (authenticated ? openAuthenticatedWebSocketCounter : openUnauthenticatedWebSocketCounter).countOpenWebSocket(context);\n\n    if (authenticated) {\n      final AuthenticatedDevice auth = context.getAuthenticated(AuthenticatedDevice.class);\n\n      final Optional<Account> maybeAuthenticatedAccount =\n          accountsManager.getByAccountIdentifier(auth.accountIdentifier());\n\n      final Optional<Device> maybeAuthenticatedDevice =\n          maybeAuthenticatedAccount.flatMap(account -> account.getDevice(auth.deviceId()));\n\n      if (maybeAuthenticatedAccount.isEmpty() || maybeAuthenticatedDevice.isEmpty()) {\n        log.warn(\"{}:{} not found when opening authenticated WebSocket\", auth.accountIdentifier(), auth.deviceId());\n\n        context.getClient().close(1011, \"Unexpected error initializing connection\");\n        return;\n      }\n\n      final WebSocketConnection connection =\n          webSocketConnectionBuilder.buildWebSocketConnection(maybeAuthenticatedAccount.get(),\n              maybeAuthenticatedDevice.get(),\n              context.getClient());\n\n      disconnectionRequestManager.addListener(maybeAuthenticatedAccount.get().getIdentifier(IdentityType.ACI),\n          maybeAuthenticatedDevice.get().getId(),\n          connection);\n\n      context.addWebsocketClosedListener((_, _, _) -> {\n        disconnectionRequestManager.removeListener(maybeAuthenticatedAccount.get().getIdentifier(IdentityType.ACI),\n            maybeAuthenticatedDevice.get().getId(),\n            connection);\n\n        connection.stop();\n      });\n\n      try {\n        connection.start();\n      } catch (final Exception e) {\n        log.warn(\"Failed to initialize websocket\", e);\n        context.getClient().close(1011, \"Unexpected error initializing connection\");\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/websocket/InvalidWebsocketAddressException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\npublic class InvalidWebsocketAddressException extends Exception {\n  public InvalidWebsocketAddressException(String serialized) {\n    super(serialized);\n  }\n\n  public InvalidWebsocketAddressException(Exception e) {\n    super(e);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/websocket/NoContextTakeoverPerMessageDeflateExtension.java",
    "content": "package org.whispersystems.textsecuregcm.websocket;\n\nimport org.eclipse.jetty.websocket.core.ExtensionConfig;\nimport org.eclipse.jetty.websocket.core.WebSocketComponents;\nimport org.eclipse.jetty.websocket.core.internal.PerMessageDeflateExtension;\n\n/// A variant of the Jetty {@link PerMessageDeflateExtension} that always negotiates the [server_no_context_takeover\n/// extension parameter](https://datatracker.ietf.org/doc/html/rfc7692#section-7.1.1.1)\npublic final class NoContextTakeoverPerMessageDeflateExtension extends PerMessageDeflateExtension {\n\n  @Override\n  public void init(ExtensionConfig config, WebSocketComponents components) {\n    config.setParameter(\"server_no_context_takeover\");\n    super.init(config, components);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListener.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.security.SecureRandom;\nimport java.time.Duration;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Supplier;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.controllers.ProvisioningController;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.entities.ProvisioningMessage;\nimport org.whispersystems.textsecuregcm.metrics.OpenWebSocketCounter;\nimport org.whispersystems.textsecuregcm.push.ProvisioningManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.PubSubProtos;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\nimport org.whispersystems.websocket.setup.WebSocketConnectListener;\n\n/**\n * A \"provisioning WebSocket\" provides a mechanism for sending a caller-defined provisioning message from the primary\n * device associated with a Signal account to a new device that is not yet associated with a Signal account. Generally,\n * the message contains key material and credentials the new device needs to associate itself with the primary device's\n * Signal account.\n * <p>\n * New devices initiate the provisioning process by opening a provisioning WebSocket. The server assigns the new device\n * a random, temporary \"provisioning address,\" which it transmits via the newly-opened WebSocket. From there, the new\n * device generally displays the provisioning address (and a public key) as a QR code. After that, the primary device\n * will scan the QR code and send an encrypted provisioning message to the new device via\n * {@link ProvisioningController#sendProvisioningMessage(AuthenticatedDevice, String, ProvisioningMessage, String)}.\n * Once the server receives the message from the primary device, it sends the message to the new device via the open\n * WebSocket, then closes the WebSocket connection.\n */\npublic class ProvisioningConnectListener implements WebSocketConnectListener {\n\n  private final ProvisioningManager provisioningManager;\n  private final OpenWebSocketCounter openWebSocketCounter;\n  private final ScheduledExecutorService timeoutExecutor;\n  private final Duration timeout;\n\n  public ProvisioningConnectListener(final ProvisioningManager provisioningManager,\n      final Supplier<AsnInfoProvider> asnInfoProviderSupplier,\n      final ClientReleaseManager clientReleaseManager,\n      final ScheduledExecutorService timeoutExecutor,\n      final Duration timeout) {\n    this.provisioningManager = provisioningManager;\n    this.timeoutExecutor = timeoutExecutor;\n    this.timeout = timeout;\n    this.openWebSocketCounter = new OpenWebSocketCounter(\"provisioning\", asnInfoProviderSupplier, clientReleaseManager);\n  }\n\n  @Override\n  public void onWebSocketConnect(WebSocketSessionContext context) {\n    openWebSocketCounter.countOpenWebSocket(context);\n\n    final ScheduledFuture<?> timeoutFuture = timeoutExecutor.schedule(() ->\n            context.getClient().close(1000, \"Timeout\"), timeout.toSeconds(), TimeUnit.SECONDS);\n\n    final String provisioningAddress = generateProvisioningAddress();\n\n    context.addWebsocketClosedListener((_, _, _) -> {\n      provisioningManager.removeListener(provisioningAddress);\n      timeoutFuture.cancel(false);\n    });\n\n    provisioningManager.addListener(provisioningAddress, message -> {\n      assert message.getType() == PubSubProtos.PubSubMessage.Type.DELIVER;\n\n      final Optional<byte[]> body = Optional.of(message.getContent().toByteArray());\n\n      context.getClient().sendRequest(\"PUT\", \"/v1/message\", List.of(HeaderUtils.getTimestampHeader()), body)\n          .whenComplete((_, _) -> context.getClient().close(1000, \"Closed\"));\n    });\n\n    context.getClient().sendRequest(\"PUT\", \"/v1/address\", List.of(HeaderUtils.getTimestampHeader()),\n        Optional.of(MessageProtos.ProvisioningAddress.newBuilder()\n            .setAddress(provisioningAddress)\n            .build().toByteArray()));\n  }\n\n  @VisibleForTesting\n  public static String generateProvisioningAddress() {\n    final byte[] provisioningAddress = new byte[16];\n    new SecureRandom().nextBytes(provisioningAddress);\n\n    return Base64.getUrlEncoder().encodeToString(provisioningAddress);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketAccountAuthenticator.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport static org.whispersystems.textsecuregcm.util.HeaderUtils.basicCredentialsFromAuthHeader;\n\nimport com.google.common.net.HttpHeaders;\nimport javax.annotation.Nullable;\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport org.eclipse.jetty.websocket.api.UpgradeRequest;\nimport org.whispersystems.textsecuregcm.auth.AccountAuthenticator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.websocket.auth.InvalidCredentialsException;\nimport org.whispersystems.websocket.auth.WebSocketAuthenticator;\nimport java.util.Optional;\n\n\npublic class WebSocketAccountAuthenticator implements WebSocketAuthenticator<AuthenticatedDevice> {\n\n  private final AccountAuthenticator accountAuthenticator;\n\n  public WebSocketAccountAuthenticator(final AccountAuthenticator accountAuthenticator) {\n    this.accountAuthenticator = accountAuthenticator;\n  }\n\n  @Override\n  public Optional<AuthenticatedDevice> authenticate(final UpgradeRequest request)\n      throws InvalidCredentialsException {\n\n    @Nullable final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);\n\n    if (authHeader == null) {\n      return Optional.empty();\n    }\n\n    final BasicCredentials credentials = basicCredentialsFromAuthHeader(authHeader)\n        .orElseThrow(InvalidCredentialsException::new);\n\n    final AuthenticatedDevice authenticatedDevice = accountAuthenticator.authenticate(credentials)\n        .orElseThrow(InvalidCredentialsException::new);\n\n    return Optional.of(authenticatedDevice);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnection.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeoutException;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.concurrent.atomic.LongAdder;\nimport org.apache.commons.lang3.StringUtils;\nimport org.eclipse.jetty.util.StaticException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestListener;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.push.ReceiptSender;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.ConflictingMessageConsumerException;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessageStream;\nimport org.whispersystems.textsecuregcm.storage.MessageStreamEntry;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.websocket.WebSocketClient;\nimport org.whispersystems.websocket.WebSocketResourceProvider;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport reactor.adapter.JdkFlowAdapter;\nimport reactor.core.Disposable;\nimport reactor.core.observability.micrometer.Micrometer;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\n\npublic class WebSocketConnection implements DisconnectionRequestListener {\n\n  private static final Counter sendMessageCounter = Metrics.counter(name(WebSocketConnection.class, \"sendMessage\"));\n  private static final Counter bytesSentCounter = Metrics.counter(name(WebSocketConnection.class, \"bytesSent\"));\n  private static final Counter sendFailuresCounter = Metrics.counter(name(WebSocketConnection.class, \"sendFailures\"));\n\n  private static final String INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME = name(WebSocketConnection.class,\n      \"initialQueueLength\");\n  private static final String INITIAL_QUEUE_DRAIN_TIMER_NAME = name(WebSocketConnection.class, \"drainInitialQueue\");\n  private static final String SLOW_QUEUE_DRAIN_COUNTER_NAME = name(WebSocketConnection.class, \"slowQueueDrain\");\n  private static final String DISPLACEMENT_COUNTER_NAME = name(WebSocketConnection.class, \"displacement\");\n  private static final String NON_SUCCESS_RESPONSE_COUNTER_NAME = name(WebSocketConnection.class,\n      \"clientNonSuccessResponse\");\n  private static final String SEND_MESSAGES_FLUX_NAME = MetricsUtil.name(WebSocketConnection.class,\n      \"sendMessages\");\n  private static final String SEND_MESSAGE_ERROR_COUNTER = MetricsUtil.name(WebSocketConnection.class,\n      \"sendMessageError\");\n  private static final String SEND_MESSAGE_DURATION_TIMER_NAME = name(WebSocketConnection.class, \"sendMessageDuration\");\n\n  private static final String STATUS_CODE_TAG = \"status\";\n  private static final String STATUS_MESSAGE_TAG = \"message\";\n  private static final String ERROR_TYPE_TAG = \"errorType\";\n  private static final String EXCEPTION_TYPE_TAG = \"exceptionType\";\n  private static final String CONNECTED_ELSEWHERE_TAG = \"connectedElsewhere\";\n\n  private static final Duration SLOW_DRAIN_THRESHOLD = Duration.ofSeconds(10);\n\n  @VisibleForTesting\n  static final int MESSAGE_PUBLISHER_LIMIT_RATE = 100;\n\n  @VisibleForTesting\n  static final int MESSAGE_SENDER_MAX_CONCURRENCY = 256;\n\n  private static final Duration CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY = Duration.ofMinutes(1);\n\n  private static final Logger logger = LoggerFactory.getLogger(WebSocketConnection.class);\n\n  private final ReceiptSender receiptSender;\n  private final MessagesManager messagesManager;\n  private final MessageMetrics messageMetrics;\n  private final PushNotificationManager pushNotificationManager;\n  private final PushNotificationScheduler pushNotificationScheduler;\n  private final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor;\n  private final ExperimentEnrollmentManager experimentEnrollmentManager;\n\n  private final Account authenticatedAccount;\n  private final Device authenticatedDevice;\n  private final MessageStream messageStream;\n  private final WebSocketClient client;\n  private final Tags platformTag;\n\n  private final LongAdder sentMessageCounter = new LongAdder();\n  private final AtomicReference<Disposable> messageSubscription = new AtomicReference<>();\n\n  private final Scheduler messageDeliveryScheduler;\n\n  private final ClientReleaseManager clientReleaseManager;\n\n  public WebSocketConnection(final ReceiptSender receiptSender,\n      final MessagesManager messagesManager,\n      final MessageMetrics messageMetrics,\n      final PushNotificationManager pushNotificationManager,\n      final PushNotificationScheduler pushNotificationScheduler,\n      final Account authenticatedAccount,\n      final Device authenticatedDevice,\n      final WebSocketClient client,\n      final Scheduler messageDeliveryScheduler,\n      final ClientReleaseManager clientReleaseManager,\n      final MessageDeliveryLoopMonitor messageDeliveryLoopMonitor,\n      final ExperimentEnrollmentManager experimentEnrollmentManager) {\n\n    this.receiptSender = receiptSender;\n    this.messagesManager = messagesManager;\n    this.messageMetrics = messageMetrics;\n    this.pushNotificationManager = pushNotificationManager;\n    this.pushNotificationScheduler = pushNotificationScheduler;\n    this.authenticatedAccount = authenticatedAccount;\n    this.authenticatedDevice = authenticatedDevice;\n    this.client = client;\n    this.messageDeliveryScheduler = messageDeliveryScheduler;\n    this.clientReleaseManager = clientReleaseManager;\n    this.messageDeliveryLoopMonitor = messageDeliveryLoopMonitor;\n    this.experimentEnrollmentManager = experimentEnrollmentManager;\n\n    this.messageStream =\n        messagesManager.getMessages(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice);\n\n    this.platformTag = Tags.of(UserAgentTagUtil.getPlatformTag(client.getUserAgent()));\n  }\n\n  public void start() {\n    pushNotificationManager.handleMessagesRetrieved(authenticatedAccount, authenticatedDevice, client.getUserAgent());\n\n    final long queueDrainStartNanos = System.nanoTime();\n    final AtomicBoolean hasSentFirstMessage = new AtomicBoolean();\n\n    messageSubscription.set(JdkFlowAdapter.flowPublisherToFlux(messageStream.getMessages())\n        .name(SEND_MESSAGES_FLUX_NAME)\n        .tap(Micrometer.metrics(Metrics.globalRegistry))\n        .limitRate(MESSAGE_PUBLISHER_LIMIT_RATE)\n        // We want to handle conflicting connections as soon as possible, and so do this before we start processing\n        // messages in the `flatMapSequential` stage below. If we didn't do this first, then we'd wait for clients to\n        // process messages before sending the \"connected elsewhere\" signal, and while that's ultimately not harmful,\n        // it's also not ideal.\n        .doOnError(ConflictingMessageConsumerException.class, _ -> {\n          Metrics.counter(DISPLACEMENT_COUNTER_NAME, platformTag.and(CONNECTED_ELSEWHERE_TAG, \"true\")).increment();\n          client.close(4409, \"Connected elsewhere\");\n        })\n        .doOnNext(entry -> {\n          if (entry instanceof MessageStreamEntry.Envelope(final Envelope message)) {\n            if (hasSentFirstMessage.compareAndSet(false, true)) {\n              messageDeliveryLoopMonitor.recordDeliveryAttempt(authenticatedAccount.getIdentifier(IdentityType.ACI),\n                  authenticatedDevice.getId(),\n                  UUID.fromString(message.getServerGuid()),\n                  client.getUserAgent(),\n                  \"websocket\");\n            }\n          }\n        })\n        .flatMapSequential(entry -> switch (entry) {\n          case MessageStreamEntry.Envelope envelope -> Mono.fromFuture(() -> sendMessage(envelope.message())).thenReturn(entry);\n          case MessageStreamEntry.QueueEmpty _ -> Mono.just(entry);\n        }, MESSAGE_SENDER_MAX_CONCURRENCY)\n        .subscribeOn(messageDeliveryScheduler)\n        .subscribe(\n            entry -> {\n              if (entry instanceof MessageStreamEntry.QueueEmpty) {\n                final Duration drainDuration = Duration.ofNanos(System.nanoTime() - queueDrainStartNanos);\n\n                Metrics.summary(INITIAL_QUEUE_LENGTH_DISTRIBUTION_NAME, platformTag).record(sentMessageCounter.sum());\n                Metrics.timer(INITIAL_QUEUE_DRAIN_TIMER_NAME, platformTag).record(drainDuration);\n\n                if (drainDuration.compareTo(SLOW_DRAIN_THRESHOLD) > 0) {\n                  Metrics.counter(SLOW_QUEUE_DRAIN_COUNTER_NAME, platformTag).increment();\n                }\n\n                client.sendRequest(\"PUT\", \"/api/v1/queue/empty\",\n                    Collections.singletonList(HeaderUtils.getTimestampHeader()), Optional.empty());\n              }\n            },\n            throwable -> {\n              // `ConflictingMessageConsumerException` is handled before processing messages\n              if (throwable instanceof ConflictingMessageConsumerException) {\n                return;\n              }\n\n              measureSendMessageErrors(throwable);\n\n              if (!client.isOpen()) {\n                logger.debug(\"Client disconnected before queue cleared\");\n                return;\n              }\n\n              client.close(1011, \"Failed to retrieve messages\");\n            }\n        ));\n  }\n\n  public void stop() {\n    final Disposable subscription = messageSubscription.get();\n    if (subscription != null) {\n      subscription.dispose();\n    }\n\n    client.close(1000, \"OK\");\n\n    messagesManager.mayHaveMessages(authenticatedAccount.getIdentifier(IdentityType.ACI), authenticatedDevice)\n        .thenAccept(mayHaveMessages -> {\n          if (mayHaveMessages) {\n            pushNotificationScheduler.scheduleDelayedNotification(authenticatedAccount,\n                authenticatedDevice,\n                CLOSE_WITH_PENDING_MESSAGES_NOTIFICATION_DELAY);\n          }\n        });\n  }\n\n  private CompletableFuture<Void> sendMessage(final Envelope message) {\n    if (message.getStory() && !client.shouldDeliverStories()) {\n      return messageStream.acknowledgeMessage(message);\n    }\n\n    final Optional<byte[]> body = Optional.of(serializeMessage(message));\n\n    sendMessageCounter.increment();\n    sentMessageCounter.increment();\n    bytesSentCounter.increment(body.map(bytes -> bytes.length).orElse(0));\n    messageMetrics.measureAccountEnvelopeUuidMismatches(authenticatedAccount, message);\n\n    final Timer.Sample sample = Timer.start();\n\n    return client.sendRequest(\"PUT\", \"/api/v1/message\",\n            List.of(HeaderUtils.getTimestampHeader()), body)\n        .whenComplete((ignored, throwable) -> {\n          if (throwable != null) {\n            sendFailuresCounter.increment();\n          } else {\n            messageMetrics.measureOutgoingMessageLatency(message.getServerTimestamp(),\n                \"websocket\",\n                authenticatedDevice.isPrimary(),\n                message.getUrgent(),\n                message.getEphemeral(),\n                client.getUserAgent(),\n                clientReleaseManager);\n          }\n        }).thenCompose(response -> {\n          final CompletableFuture<Void> result;\n          if (isSuccessResponse(response)) {\n\n            result = messageStream.acknowledgeMessage(message);\n\n            if (message.getType() != Envelope.Type.SERVER_DELIVERY_RECEIPT) {\n              sendDeliveryReceiptFor(message);\n            }\n          } else {\n            Tags tags = platformTag.and(STATUS_CODE_TAG, String.valueOf(response.getStatus()));\n\n            // TODO Remove this once we've identified the cause of message rejections from desktop clients\n            if (StringUtils.isNotBlank(response.getMessage())) {\n              tags = tags.and(Tag.of(STATUS_MESSAGE_TAG, response.getMessage()));\n            }\n\n            Metrics.counter(NON_SUCCESS_RESPONSE_COUNTER_NAME, tags).increment();\n\n            result = CompletableFuture.completedFuture(null);\n          }\n\n          return result;\n        })\n        .thenRun(() -> sample.stop(Timer.builder(SEND_MESSAGE_DURATION_TIMER_NAME)\n            .tags(platformTag)\n            .register(Metrics.globalRegistry)));\n  }\n\n  @VisibleForTesting\n  static byte[] serializeMessage(final Envelope message) {\n    return message.toBuilder().clearEphemeral().build().toByteArray();\n  }\n\n  private void sendDeliveryReceiptFor(Envelope message) {\n    if (!message.hasSourceServiceId()) {\n      return;\n    }\n\n    try {\n      receiptSender.sendReceipt(ServiceIdentifier.valueOf(message.getDestinationServiceId()),\n          authenticatedDevice.getId(), AciServiceIdentifier.valueOf(message.getSourceServiceId()),\n          message.getClientTimestamp());\n    } catch (final IllegalArgumentException e) {\n      logger.error(\"Could not parse UUID: {}\", message.getSourceServiceId());\n    } catch (final Exception e) {\n      logger.warn(\"Failed to send receipt\", e);\n    }\n  }\n\n  private static boolean isSuccessResponse(final WebSocketResponseMessage response) {\n    return response != null && response.getStatus() >= 200 && response.getStatus() < 300;\n  }\n\n  private void measureSendMessageErrors(final Throwable e) {\n    final String errorType;\n\n    if (e instanceof TimeoutException) {\n      errorType = \"timeout\";\n    } else if (isConnectionClosedException(e)) {\n      errorType = \"connectionClosed\";\n    } else {\n      logger.warn(\"Send message failed\", e);\n      errorType = \"other\";\n    }\n\n    Metrics.counter(SEND_MESSAGE_ERROR_COUNTER,\n            platformTag.and(ERROR_TYPE_TAG, errorType, EXCEPTION_TYPE_TAG, e.getClass().getSimpleName()))\n        .increment();\n  }\n\n  @VisibleForTesting\n  static boolean isConnectionClosedException(final Throwable throwable) {\n    return throwable instanceof java.nio.channels.ClosedChannelException ||\n        throwable == WebSocketResourceProvider.CONNECTION_CLOSED_EXCEPTION ||\n        throwable instanceof org.eclipse.jetty.io.EofException ||\n        (throwable instanceof StaticException staticException && \"Closed\".equals(staticException.getMessage()));\n  }\n\n  @Override\n  public void handleDisconnectionRequest() {\n    Metrics.counter(DISPLACEMENT_COUNTER_NAME, platformTag.and(CONNECTED_ELSEWHERE_TAG, \"false\")).increment();\n    client.close(4401, \"Reauthentication required\");\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/AbstractCommandWithDependencies.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.cli.Cli;\nimport io.dropwizard.core.cli.EnvironmentCommand;\nimport io.dropwizard.core.setup.Environment;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;\n\n/**\n * Base class for one-shot commands that use {@link CommandDependencies}.\n * <p>\n * Override {@link #run(Environment, Namespace, WhisperServerConfiguration, CommandDependencies)} in a child class to\n * let the parent class handle common initialization of dependencies, metrics, and logging.\n */\npublic abstract class AbstractCommandWithDependencies extends EnvironmentCommand<WhisperServerConfiguration> {\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  protected AbstractCommandWithDependencies(final Application<WhisperServerConfiguration> application,\n      final String name, final String description) {\n    super(application, name, description);\n  }\n\n  /**\n   * Run the command with the given initialized {@link CommandDependencies}\n   */\n  protected abstract void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception;\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration) throws Exception {\n    UncaughtExceptionHandler.register();\n    final CommandDependencies commandDependencies = CommandDependencies.build(getName(), environment, configuration);\n    MetricsUtil.configureRegistries(configuration, environment, commandDependencies.dynamicConfigurationManager());\n\n    try {\n      logger.info(\"Starting command dependencies\");\n      environment.lifecycle().getManagedObjects().forEach(managedObject -> {\n        try {\n          managedObject.start();\n        } catch (final Exception e) {\n          logger.error(\"Failed to start managed object\", e);\n          throw new RuntimeException(e);\n        }\n      });\n\n      run(environment, namespace, configuration, commandDependencies);\n\n    } finally {\n      logger.info(\"Stopping command dependencies\");\n      environment.lifecycle().getManagedObjects().reversed().forEach(managedObject -> {\n        try {\n          managedObject.stop();\n        } catch (final Exception e) {\n          logger.error(\"Failed to stop managed object\", e);\n        }\n      });\n    }\n  }\n\n  @Override\n  public void onError(final Cli cli, final Namespace namespace, final Throwable throwable) {\n    logger.error(\"Unhandled error\", throwable);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/AbstractSinglePassCrawlAccountsCommand.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.cli.Cli;\nimport io.dropwizard.core.cli.EnvironmentCommand;\nimport io.dropwizard.core.setup.Environment;\nimport java.util.Objects;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Schedulers;\n\npublic abstract class AbstractSinglePassCrawlAccountsCommand extends AbstractCommandWithDependencies {\n\n  private CommandDependencies commandDependencies;\n  private Namespace namespace;\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  private static final String SEGMENT_COUNT = \"segments\";\n\n  public AbstractSinglePassCrawlAccountsCommand(final String name, final String description) {\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, name, description);\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--segments\")\n        .type(Integer.class)\n        .dest(SEGMENT_COUNT)\n        .required(false)\n        .setDefault(1)\n        .help(\"The total number of segments for a DynamoDB scan\");\n  }\n\n  protected CommandDependencies getCommandDependencies() {\n    return commandDependencies;\n  }\n\n  protected Namespace getNamespace() {\n    return namespace;\n  }\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception {\n    this.namespace = namespace;\n    this.commandDependencies = commandDependencies;\n\n    final int segments = Objects.requireNonNull(namespace.getInt(SEGMENT_COUNT));\n\n    logger.info(\"Crawling accounts with {} segments and {} processors\",\n        segments,\n        Runtime.getRuntime().availableProcessors());\n\n    crawlAccounts(commandDependencies.accountsManager().streamAllFromDynamo(segments, Schedulers.parallel()));\n  }\n\n  protected abstract void crawlAccounts(final Flux<Account> accounts);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/BackupMetricsCommand.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.DistributionSummary;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Objects;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\n\npublic class BackupMetricsCommand extends AbstractCommandWithDependencies {\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  private static final String SEGMENT_COUNT_ARGUMENT = \"segments\";\n  private static final int DEFAULT_SEGMENT_COUNT = 1;\n\n  private final Clock clock;\n\n  public BackupMetricsCommand(final Clock clock) {\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, \"backup-metrics\", \"Reports metrics about backups\");\n    this.clock = clock;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--segments\")\n        .type(Integer.class)\n        .dest(SEGMENT_COUNT_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_SEGMENT_COUNT)\n        .help(\"The total number of segments for a DynamoDB scan\");\n  }\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception {\n\n    final int segments = Objects.requireNonNull(namespace.getInt(SEGMENT_COUNT_ARGUMENT));\n    logger.info(\"Crawling backups for metrics with {} segments and {} processors\",\n        segments,\n        Runtime.getRuntime().availableProcessors());\n\n    final DistributionSummary timeSinceLastRefresh = Metrics.summary(name(getClass(),\n        \"timeSinceLastRefresh\"));\n    final DistributionSummary timeSinceLastMediaRefresh = Metrics.summary(name(getClass(),\n        \"timeSinceLastMediaRefresh\"));\n    final DistributionSummary numMediaObjects = Metrics.summary(name(getClass(),\n        \"numObjects\"));\n    final DistributionSummary mediaBytesUsed = Metrics.summary(name(getClass(),\n        \"bytesUsed\"));\n\n    final Counter freeTierBackups = Metrics.counter(name(getClass(), \"backups\"), \"tier\", \"free\");\n    final Counter paidTierBackups = Metrics.counter(name(getClass(), \"backups\"), \"tier\", \"paid\");\n\n\n    final BackupManager backupManager = commandDependencies.backupManager();\n    final Long backupsCrawled = backupManager\n        .listBackupAttributes(segments)\n        .doOnNext(backupMetadata -> {\n          timeSinceLastRefresh.record(timeSince(backupMetadata.lastRefresh()).getSeconds());\n          timeSinceLastMediaRefresh.record(timeSince(backupMetadata.lastMediaRefresh()).getSeconds());\n\n          final boolean hasMediaTier = Duration\n              .between(backupMetadata.lastMediaRefresh(), backupMetadata.lastRefresh())\n              .abs()\n              .compareTo(Duration.ofDays(1)) < 1;\n          if (hasMediaTier) {\n            numMediaObjects.record(backupMetadata.numObjects());\n            mediaBytesUsed.record(backupMetadata.bytesUsed());\n            paidTierBackups.increment();\n          } else {\n            freeTierBackups.increment();\n          }\n        })\n        .count()\n        .block();\n    logger.info(\"Crawled {} backups\", backupsCrawled);\n  }\n\n  private Duration timeSince(Instant t) {\n    final Duration between = Duration.between(t, clock.instant());\n    if (between.isNegative()) {\n      return Duration.ZERO;\n    }\n    return between;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/BackupUsageRecalculationCommand.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.Metrics;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Schedulers;\n\nimport java.util.Objects;\n\npublic class BackupUsageRecalculationCommand extends AbstractCommandWithDependencies {\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  private static final String SEGMENT_COUNT_ARGUMENT = \"segments\";\n  private static final int DEFAULT_SEGMENT_COUNT = 1;\n\n  private static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n  private static final int DEFAULT_MAX_CONCURRENCY = 4;\n\n  private static final String RECALCULATION_COUNT_COUNTER_NAME =\n      MetricsUtil.name(BackupUsageRecalculationCommand.class, \"countRecalculations\");\n  private static final String RECALCULATION_BYTE_COUNTER_NAME =\n      MetricsUtil.name(BackupUsageRecalculationCommand.class, \"byteRecalculations\");\n\n\n  public BackupUsageRecalculationCommand() {\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, \"backup-usage-recalculation\", \"Recalculate the usage of backups\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--segments\")\n        .type(Integer.class)\n        .dest(SEGMENT_COUNT_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_SEGMENT_COUNT)\n        .help(\"The total number of segments for a DynamoDB scan\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n  }\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception {\n\n    final int segments = Objects.requireNonNull(namespace.getInt(SEGMENT_COUNT_ARGUMENT));\n    final int recalculationConcurrency = Objects.requireNonNull(namespace.getInt(MAX_CONCURRENCY_ARGUMENT));\n    logger.info(\"Crawling to recalculate usage with {} segments and {} processors\",\n        segments,\n        Runtime.getRuntime().availableProcessors());\n\n    final BackupManager backupManager = commandDependencies.backupManager();\n    final Long backupsConsidered = backupManager\n        .listBackupAttributes(segments)\n        .flatMap(attrs -> Mono.fromCompletionStage(() -> backupManager.recalculateQuota(attrs)).doOnNext(maybeRecalculationResult -> maybeRecalculationResult.ifPresent(recalculationResult -> {\n              if (!recalculationResult.newUsage().equals(recalculationResult.oldUsage())) {\n                logger.info(\"Recalculated usage. oldUsage={}, newUsage={}, lastRefresh={}, lastMediaRefresh={}\",\n                    recalculationResult.oldUsage(),\n                    recalculationResult.newUsage(),\n                    attrs.lastRefresh(),\n                    attrs.lastMediaRefresh());\n              }\n\n              Metrics.counter(RECALCULATION_COUNT_COUNTER_NAME,\n                      \"delta\", DeltaType.deltaType(\n                          recalculationResult.oldUsage().numObjects(),\n                          recalculationResult.newUsage().numObjects()).name())\n                  .increment();\n\n              Metrics.counter(RECALCULATION_BYTE_COUNTER_NAME,\n                      \"delta\", DeltaType.deltaType(\n                          recalculationResult.oldUsage().bytesUsed(),\n                          recalculationResult.newUsage().bytesUsed()).name())\n                  .increment();\n\n            }\n        )), recalculationConcurrency)\n        .count()\n        .block();\n    logger.info(\"Crawled {} backups\", backupsConsidered);\n  }\n\n  private enum DeltaType {\n    REDUCED,\n    SAME,\n    INCREASED;\n\n    static DeltaType deltaType(long oldv, long newv) {\n      return switch (Long.signum(newv - oldv)) {\n        case -1 -> REDUCED;\n        case 0 -> SAME;\n        case 1 -> INCREASED;\n        default -> throw new IllegalStateException(\"Unexpected value: \" + (newv - oldv));\n      };\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/CertificateCommand.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.base.MoreObjects;\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.core.cli.Command;\nimport io.dropwizard.core.setup.Bootstrap;\nimport java.security.InvalidKeyException;\nimport java.util.Base64;\nimport java.util.Set;\nimport net.sourceforge.argparse4j.impl.Arguments;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPrivateKey;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\n\npublic class CertificateCommand extends Command {\n\n  private static final Set<Integer> RESERVED_CERTIFICATE_IDS = Set.of(\n    // Reserved for testing in libsignal.\n    0xdeadc357, // \"dead cert\", one considered revoked\n    0x7357c357  // \"test cert\", one signed with a testing trust root\n  );\n\n  public CertificateCommand() {\n    super(\"certificate\", \"Generates server certificates for unidentified delivery\");\n  }\n\n  @Override\n  public void configure(Subparser subparser) {\n    subparser.addArgument(\"-ca\", \"--ca\")\n             .dest(\"ca\")\n             .action(Arguments.storeTrue())\n             .setDefault(Boolean.FALSE)\n             .help(\"Generate CA parameters\");\n\n    subparser.addArgument(\"-k\", \"--key\")\n             .dest(\"key\")\n             .type(String.class)\n             .help(\"The CA private signing key\");\n\n    subparser.addArgument(\"-i\", \"--id\")\n             .dest(\"keyId\")\n             .type(Integer.class)\n             .help(\"The key ID to create\");\n  }\n\n  @Override\n  public void run(Bootstrap<?> bootstrap, Namespace namespace) throws Exception {\n    if (MoreObjects.firstNonNull(namespace.getBoolean(\"ca\"), false)) runCaCommand();\n    else                                                                  runCertificateCommand(namespace);\n  }\n\n  private void runCaCommand() {\n    ECKeyPair keyPair = ECKeyPair.generate();\n    System.out.println(\"Public key : \" + Base64.getEncoder().encodeToString(keyPair.getPublicKey().serialize()));\n    System.out.println(\"Private key: \" + Base64.getEncoder().encodeToString(keyPair.getPrivateKey().serialize()));\n  }\n\n  private void runCertificateCommand(Namespace namespace) throws InvalidKeyException, org.signal.libsignal.protocol.InvalidKeyException {\n    if (namespace.getString(\"key\") == null) {\n      System.out.println(\"No key specified!\");\n      return;\n    }\n\n    if (namespace.getInt(\"keyId\") == null) {\n      System.out.print(\"No key id specified!\");\n      return;\n    }\n\n    ECPrivateKey key   = new ECPrivateKey(Base64.getDecoder().decode(namespace.getString(\"key\")));\n    int          keyId = namespace.getInt(\"keyId\");\n\n    if (RESERVED_CERTIFICATE_IDS.contains(keyId)) {\n      throw new IllegalArgumentException(\n          String.format(\"Key ID %08x has been reserved or revoked and may not be used in new certificates.\", keyId));\n    }\n\n    ECKeyPair keyPair = ECKeyPair.generate();\n\n    byte[] certificate = MessageProtos.ServerCertificate.Certificate.newBuilder()\n                                                                    .setId(keyId)\n                                                                    .setKey(ByteString.copyFrom(keyPair.getPublicKey().serialize()))\n                                                                    .build()\n                                                                    .toByteArray();\n\n    byte[] signature;\n    signature = key.calculateSignature(certificate);\n\n    byte[] signedCertificate = MessageProtos.ServerCertificate.newBuilder()\n                                                              .setCertificate(ByteString.copyFrom(certificate))\n                                                              .setSignature(ByteString.copyFrom(signature))\n                                                              .build()\n                                                              .toByteArray();\n\n    System.out.println(\"Certificate: \" + Base64.getEncoder().encodeToString(signedCertificate));\n    System.out.println(\"Private key: \" + Base64.getEncoder().encodeToString(keyPair.getPrivateKey().serialize()));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/CheckDynamicConfigurationCommand.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.cli.Command;\nimport io.dropwizard.core.setup.Bootstrap;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.ArrayList;\nimport java.util.List;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\npublic class CheckDynamicConfigurationCommand extends Command {\n\n  public CheckDynamicConfigurationCommand() {\n    super(\"check-dynamic-config\", \"Check validity of a dynamic configuration file\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    subparser.addArgument(\"file\")\n        .type(String.class)\n        .required(true)\n        .help(\"Dynamic configuration file to check\");\n\n    subparser.addArgument(\"-c\", \"--class\")\n        .type(String.class)\n        .nargs(\"*\")\n        .setDefault(DynamicConfiguration.class.getCanonicalName());\n  }\n\n  private boolean isValid(final Class<?> configurationClass, final String yamlConfig) {\n    return DynamicConfigurationManager.parseConfiguration(yamlConfig, configurationClass).isPresent();\n  }\n\n  /**\n   * Throw to exit the command cleanly but with a non-zero exit code\n   */\n  private static class CommandFailedException extends RuntimeException {\n    @Override\n    public synchronized Throwable fillInStackTrace() {\n      return this;\n    }\n  }\n\n  @Override\n  public void run(final Bootstrap<?> bootstrap, final Namespace namespace) throws Exception {\n    final Path path = Path.of(namespace.getString(\"file\"));\n\n    final List<Class<?>> configurationClasses;\n\n    if (namespace.get(\"class\") instanceof List) {\n      final List<Class<?>> classesFromArguments = new ArrayList<>();\n\n      for (final Object object : namespace.getList(\"class\")) {\n        classesFromArguments.add(Class.forName(object.toString()));\n      }\n\n      configurationClasses = classesFromArguments;\n    } else {\n      configurationClasses = List.of(Class.forName(namespace.getString(\"class\")));\n    }\n\n    final String yamlConfig = Files.readString(path);\n    final boolean allValid = configurationClasses.stream()\n        .allMatch(cls -> {\n          final boolean valid = isValid(cls, yamlConfig);\n          if (valid) {\n            System.out.println(cls.getSimpleName() + \": dynamic configuration file at \" + path + \" is valid\");\n          } else {\n            System.err.println(cls.getSimpleName() + \": dynamic configuration file at \" + path + \" is not valid\");\n          }\n          return valid;\n        });\n\n    if (!allValid) {\n      throw new CommandFailedException();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/ClearIssuedReceiptRedemptionsCommand.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport java.time.Clock;\nimport java.util.Base64;\nimport java.util.Optional;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;\nimport org.whispersystems.textsecuregcm.storage.SubscriberCredentials;\nimport org.whispersystems.textsecuregcm.storage.SubscriptionManager;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentProcessor;\n\npublic class ClearIssuedReceiptRedemptionsCommand extends AbstractCommandWithDependencies {\n\n  private final Logger logger = LoggerFactory.getLogger(ClearIssuedReceiptRedemptionsCommand.class);\n\n  public ClearIssuedReceiptRedemptionsCommand() {\n    super(new Application<>() {\n      @Override\n      public void run(WhisperServerConfiguration configuration, Environment environment) {\n\n      }\n    }, \"clear-issued-receipt-redemptions\", \"Clear issued receipt redemptions\");\n  }\n\n  @Override\n  public void configure(Subparser subparser) {\n    super.configure(subparser);\n    subparser.addArgument(\"-s\", \"--subscriber-id\")\n        .dest(\"subscriberId\")\n        .type(String.class)\n        .required(true)\n        .help(\"The subscriber-id whose receipt redemptions should be clear\");\n  }\n\n  @Override\n  protected void run(Environment environment, Namespace namespace, WhisperServerConfiguration configuration,\n      CommandDependencies deps) throws Exception {\n    try {\n      final String subscriberId = namespace.getString(\"subscriberId\");\n\n      final SubscriberCredentials creds = SubscriberCredentials\n          .process(Optional.empty(), subscriberId, Clock.systemUTC());\n\n      final IssuedReceiptsManager issuedReceiptsManager = deps.issuedReceiptsManager();\n      final SubscriptionManager subscriptionManager = deps.subscriptionManager();\n\n      final Subscriptions.Record subscriber = subscriptionManager.getSubscriber(creds);\n      final PaymentProvider processorType = subscriber.getProcessorCustomer()\n          .orElseThrow(() -> new IllegalArgumentException(\"susbcriber did not have a subscription\"))\n          .processor();\n      final SubscriptionPaymentProcessor processor = switch (processorType) {\n        case APPLE_APP_STORE -> deps.appleAppStoreManager();\n        case GOOGLE_PLAY_BILLING -> deps.googlePlayBillingManager();\n        default ->\n            throw new IllegalStateException(\"Cannot clear issued receipts for a non-IAP processor: \" + processorType);\n      };\n      final SubscriptionPaymentProcessor.ReceiptItem receiptItem = processor.getReceiptItem(subscriber.subscriptionId);\n      final boolean deleted = issuedReceiptsManager.clearIssuance(receiptItem.itemId(), processorType).join();\n      logger.info(\"Deleted issuances for receiptItem: {}, subscriberId: {}, hadExistingIssuances: {}\",\n          receiptItem.itemId(), subscriberId, deleted);\n    } catch (Exception ex) {\n      logger.warn(\"Removal Exception\", ex);\n      throw new RuntimeException(ex);\n    }\n  }\n\n  public static void main(String[] args) throws Exception {\n    final String subscriberId = \"7ywqmymkSMBkBi9v06Iy4AN8DiN_lg8gHXM8TSpO0Z0\";\n\n    final SubscriberCredentials creds = SubscriberCredentials\n        .process(Optional.empty(), subscriberId, Clock.systemUTC());\n    System.out.println(Base64.getEncoder().encodeToString(creds.subscriberUser()));\n\n    final String pc = \"AWN1c19TV3hDUEhlWDBldzB0UA==\";\n    final byte[] bc = Base64.getDecoder().decode(pc);\n    System.out.println(bc[0]);\n    System.out.println(new String(bc, 1, bc.length - 1));\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/CommandDependencies.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport com.fasterxml.jackson.databind.DeserializationFeature;\nimport io.dropwizard.core.setup.Environment;\nimport io.lettuce.core.resource.ClientResources;\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.GeneralSecurityException;\nimport java.time.Clock;\nimport java.util.List;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.SynchronousQueue;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.WhisperServerService.ExecutorServiceBuilder;\nimport org.whispersystems.textsecuregcm.WhisperServerService.ScheduledExecutorServiceBuilder;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.BackupsDb;\nimport org.whispersystems.textsecuregcm.backup.Cdn3BackupCredentialGenerator;\nimport org.whispersystems.textsecuregcm.backup.Cdn3RemoteStorageManager;\nimport org.whispersystems.textsecuregcm.backup.SecureValueRecoveryBCredentialsGeneratorFactory;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.SecureStorageController;\nimport org.whispersystems.textsecuregcm.controllers.SecureValueRecovery2Controller;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.metrics.MicrometerAwsSdkMetricPublisher;\nimport org.whispersystems.textsecuregcm.push.APNSender;\nimport org.whispersystems.textsecuregcm.push.FcmSender;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.storage.AccountLockManager;\nimport org.whispersystems.textsecuregcm.storage.Accounts;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbRecoveryManager;\nimport org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.storage.MessagesCache;\nimport org.whispersystems.textsecuregcm.storage.MessagesDynamoDb;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.storage.PagedSingleUseKEMPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.Profiles;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswords;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.RepeatedUseECSignedPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.RepeatedUseKEMSignedPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageDynamoDb;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageManager;\nimport org.whispersystems.textsecuregcm.storage.SingleUseECPreKeyStore;\nimport org.whispersystems.textsecuregcm.storage.SubscriptionManager;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreClient;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;\nimport org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;\nimport org.whispersystems.textsecuregcm.util.ManagedAwsCrt;\nimport org.whispersystems.textsecuregcm.util.ManagedExecutors;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.s3.S3AsyncClient;\n\n/**\n * Construct utilities commonly used by worker commands\n */\npublic record CommandDependencies(\n    AccountsManager accountsManager,\n    ProfilesManager profilesManager,\n    ReportMessageManager reportMessageManager,\n    MessagesCache messagesCache,\n    MessagesManager messagesManager,\n    KeysManager keysManager,\n    RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager,\n    APNSender apnSender,\n    FcmSender fcmSender,\n    PushNotificationManager pushNotificationManager,\n    PushNotificationExperimentSamples pushNotificationExperimentSamples,\n    FaultTolerantRedisClusterClient cacheCluster,\n    FaultTolerantRedisClusterClient pushSchedulerCluster,\n    ClientResources.Builder redisClusterClientResourcesBuilder,\n    BackupManager backupManager,\n    IssuedReceiptsManager issuedReceiptsManager,\n    GooglePlayBillingManager googlePlayBillingManager,\n    AppleAppStoreManager appleAppStoreManager,\n    SubscriptionManager subscriptionManager,\n    DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager,\n    DynamoDbAsyncClient dynamoDbAsyncClient,\n    PhoneNumberIdentifiers phoneNumberIdentifiers,\n    DynamoDbRecoveryManager dynamoDbRecoveryManager) {\n\n  static CommandDependencies build(\n      final String name,\n      final Environment environment,\n      final WhisperServerConfiguration configuration)\n      throws IOException, GeneralSecurityException, InvalidInputException {\n    Clock clock = Clock.systemUTC();\n\n    MetricsUtil.configureLogging(configuration, environment);\n\n    environment.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);\n\n    final AwsCredentialsProvider awsCredentialsProvider = configuration.getAwsCredentialsConfiguration().build();\n\n    ScheduledExecutorService dynamicConfigurationExecutor = ScheduledExecutorServiceBuilder.of(environment, \"dynamicConfiguration\")\n        .threads(1).build();\n\n    DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        new DynamicConfigurationManager<>(\n            configuration.getDynamicConfig().build(awsCredentialsProvider, dynamicConfigurationExecutor), DynamicConfiguration.class);\n    dynamicConfigurationManager.start();\n    ExperimentEnrollmentManager experimentEnrollmentManager =\n        new ExperimentEnrollmentManager(dynamicConfigurationManager);\n\n    final ClientResources.Builder redisClientResourcesBuilder = ClientResources.builder();\n\n    FaultTolerantRedisClusterClient cacheCluster = configuration.getCacheClusterConfiguration()\n        .build(\"main_cache\", redisClientResourcesBuilder);\n    FaultTolerantRedisClusterClient pushSchedulerCluster = configuration.getPushSchedulerCluster()\n        .build(\"push_scheduler\", redisClientResourcesBuilder);\n    FaultTolerantRedisClient pubsubClient =\n        configuration.getRedisPubSubConfiguration().build(\"pubsub\", redisClientResourcesBuilder.build());\n\n    Scheduler messageDeliveryScheduler = Schedulers.fromExecutorService(\n        environment.lifecycle().executorService(\"messageDelivery\").minThreads(4).maxThreads(4).build());\n    ExecutorService messageDeletionExecutor = ExecutorServiceBuilder.of(environment, \"messageDeletion\")\n        .minThreads(4).maxThreads(4).build();\n    ExecutorService secureValueRecoveryServiceExecutor = ExecutorServiceBuilder.of(environment, \"secureValueRecoveryService\")\n        .maxThreads(8).minThreads(8).build();\n    ExecutorService storageServiceExecutor = ExecutorServiceBuilder.of(environment, \"storageService\")\n        .maxThreads(8).minThreads(8).build();\n    ExecutorService accountLockExecutor = ExecutorServiceBuilder.of(environment, \"accountLock\")\n        .minThreads(8).maxThreads(8).build();\n    ExecutorService remoteStorageHttpExecutor = ExecutorServiceBuilder.of(environment, \"remoteStorage\")\n\n        .minThreads(0).maxThreads(Integer.MAX_VALUE).workQueue(new SynchronousQueue<>())\n        .keepAliveTime(io.dropwizard.util.Duration.seconds(60L)).build();\n    ExecutorService apnSenderExecutor = ExecutorServiceBuilder.of(environment, \"apnSender\")\n        .maxThreads(1).minThreads(1).build();\n    ExecutorService fcmSenderExecutor = ExecutorServiceBuilder.of(environment, \"fcmSender\")\n        .maxThreads(16).minThreads(16).build();\n    ExecutorService clientEventExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n      \"clientEvent\", configuration.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(), environment);\n    ExecutorService asyncOperationQueueingExecutor = ExecutorServiceBuilder.of(environment, \"asyncOperationQueueing\")\n        .minThreads(1).maxThreads(1).build();\n    ExecutorService disconnectionRequestListenerExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"disconnectionRequest\",\n        configuration.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n\n    final ScheduledExecutorService messagePollExecutor = ScheduledExecutorServiceBuilder.of(environment, \"messagePollExecutor\")\n      .threads(1).build();\n    final ScheduledExecutorService retryExecutor = ScheduledExecutorServiceBuilder.of(environment, \"retry\")\n      .threads(1).build();\n\n    ExternalServiceCredentialsGenerator storageCredentialsGenerator = SecureStorageController.credentialsGenerator(\n        configuration.getSecureStorageServiceConfiguration());\n    ExternalServiceCredentialsGenerator secureValueRecovery2CredentialsGenerator = SecureValueRecovery2Controller.credentialsGenerator(\n        configuration.getSvr2Configuration());\n    ExternalServiceCredentialsGenerator secureValueRecoveryBCredentialsGenerator =\n        SecureValueRecoveryBCredentialsGeneratorFactory.svrbCredentialsGenerator(configuration.getSvrbConfiguration());\n\n    final ExecutorService awsSdkMetricsExecutor = ManagedExecutors.newVirtualThreadPerTaskExecutor(\n        \"awsSdkMetrics\",\n        configuration.getVirtualThreadConfiguration().maxConcurrentThreadsPerExecutor(),\n        environment);\n\n    DynamoDbAsyncClient dynamoDbAsyncClient = configuration.getDynamoDbClientConfiguration()\n        .buildAsyncClient(awsCredentialsProvider, new MicrometerAwsSdkMetricPublisher(awsSdkMetricsExecutor, \"dynamoDbAsyncCommand\"));\n\n    DynamoDbClient dynamoDbClient = configuration.getDynamoDbClientConfiguration()\n        .buildSyncClient(awsCredentialsProvider, new MicrometerAwsSdkMetricPublisher(awsSdkMetricsExecutor, \"dynamoDbSyncCommand\"));\n\n    final AwsCredentialsProvider cdnCredentialsProvider = configuration.getCdnConfiguration().credentials().build();\n    final S3AsyncClient asyncCdnS3Client = S3AsyncClient.builder()\n        .credentialsProvider(cdnCredentialsProvider)\n        .region(Region.of(configuration.getCdnConfiguration().region()))\n        .build();\n\n\n    RegistrationRecoveryPasswords registrationRecoveryPasswords = new RegistrationRecoveryPasswords(\n        configuration.getDynamoDbTables().getRegistrationRecovery().getTableName(),\n        configuration.getDynamoDbTables().getRegistrationRecovery().getExpiration(),\n        dynamoDbAsyncClient,\n        clock);\n\n    Accounts accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        dynamoDbAsyncClient,\n        configuration.getDynamoDbTables().getAccounts().getTableName(),\n        configuration.getDynamoDbTables().getAccounts().getPhoneNumberTableName(),\n        configuration.getDynamoDbTables().getAccounts().getPhoneNumberIdentifierTableName(),\n        configuration.getDynamoDbTables().getAccounts().getUsernamesTableName(),\n        configuration.getDynamoDbTables().getDeletedAccounts().getTableName(),\n        configuration.getDynamoDbTables().getAccounts().getUsedLinkDeviceTokensTableName());\n    PhoneNumberIdentifiers phoneNumberIdentifiers = new PhoneNumberIdentifiers(dynamoDbAsyncClient,\n        configuration.getDynamoDbTables().getPhoneNumberIdentifiers().getTableName());\n    Profiles profiles = new Profiles(dynamoDbClient, dynamoDbAsyncClient,\n        configuration.getDynamoDbTables().getProfiles().getTableName());\n    S3AsyncClient asyncKeysS3Client = S3AsyncClient.builder()\n        .credentialsProvider(awsCredentialsProvider)\n        .region(Region.of(configuration.getPagedSingleUseKEMPreKeyStore().region()))\n        .build();\n    PagedSingleUseKEMPreKeyStore pagedSingleUseKEMPreKeyStore = new PagedSingleUseKEMPreKeyStore(\n        dynamoDbAsyncClient, asyncKeysS3Client,\n        configuration.getDynamoDbTables().getPagedKemKeys().getTableName(),\n        configuration.getPagedSingleUseKEMPreKeyStore().bucket());\n    KeysManager keys = new KeysManager(\n        new SingleUseECPreKeyStore(dynamoDbAsyncClient, configuration.getDynamoDbTables().getEcKeys().getTableName()),\n        pagedSingleUseKEMPreKeyStore,\n        new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,\n            configuration.getDynamoDbTables().getEcSignedPreKeys().getTableName()),\n        new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,\n            configuration.getDynamoDbTables().getKemLastResortKeys().getTableName()));\n    MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(dynamoDbClient, dynamoDbAsyncClient,\n        configuration.getDynamoDbTables().getMessages().getTableName(),\n        configuration.getDynamoDbTables().getMessages().getExpiration(),\n        messageDeletionExecutor, experimentEnrollmentManager);\n    FaultTolerantRedisClusterClient messagesCluster = configuration.getMessageCacheConfiguration()\n        .getRedisClusterConfiguration().build(\"messages\", redisClientResourcesBuilder);\n    FaultTolerantRedisClusterClient rateLimitersCluster = configuration.getRateLimitersCluster().build(\"rate_limiters\",\n        redisClientResourcesBuilder);\n    SecureValueRecoveryClient secureValueRecovery2Client = new SecureValueRecoveryClient(\n        secureValueRecovery2CredentialsGenerator,\n        secureValueRecoveryServiceExecutor,\n        retryExecutor,\n        configuration.getSvr2Configuration(),\n        () -> dynamicConfigurationManager.getConfiguration().getSvr2StatusCodesToIgnoreForAccountDeletion());\n    SecureValueRecoveryClient secureValueRecoveryBClient = new SecureValueRecoveryClient(\n        secureValueRecoveryBCredentialsGenerator,\n        secureValueRecoveryServiceExecutor,\n        retryExecutor,\n        configuration.getSvrbConfiguration(),\n        () -> dynamicConfigurationManager.getConfiguration().getSvrbStatusCodesToIgnoreForAccountDeletion());\n    SecureStorageClient secureStorageClient = new SecureStorageClient(storageCredentialsGenerator,\n        storageServiceExecutor, retryExecutor, configuration.getSecureStorageServiceConfiguration());\n    DisconnectionRequestManager disconnectionRequestManager = new DisconnectionRequestManager(pubsubClient,\n        disconnectionRequestListenerExecutor, retryExecutor);\n    MessagesCache messagesCache = new MessagesCache(messagesCluster,\n        messageDeliveryScheduler, messageDeletionExecutor, retryExecutor, Clock.systemUTC(), experimentEnrollmentManager);\n    ProfilesManager profilesManager = new ProfilesManager(profiles, cacheCluster, retryExecutor, asyncCdnS3Client,\n        configuration.getCdnConfiguration().bucket());\n    ReportMessageDynamoDb reportMessageDynamoDb = new ReportMessageDynamoDb(dynamoDbClient, dynamoDbAsyncClient,\n        configuration.getDynamoDbTables().getReportMessage().getTableName(),\n        configuration.getReportMessageConfiguration().getReportTtl());\n    ReportMessageManager reportMessageManager = new ReportMessageManager(reportMessageDynamoDb, rateLimitersCluster,\n        configuration.getReportMessageConfiguration().getCounterTtl());\n    RedisMessageAvailabilityManager redisMessageAvailabilityManager =\n        new RedisMessageAvailabilityManager(messagesCluster, clientEventExecutor, asyncOperationQueueingExecutor);\n    MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,\n        reportMessageManager, messageDeletionExecutor, Clock.systemUTC());\n    AccountLockManager accountLockManager = new AccountLockManager(dynamoDbClient,\n        configuration.getDynamoDbTables().getDeletedAccountsLock().getTableName());\n    RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n        new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);\n    AccountsManager accountsManager = new AccountsManager(accounts, phoneNumberIdentifiers, cacheCluster,\n        pubsubClient, accountLockManager, keys, messagesManager, profilesManager,\n        secureStorageClient, secureValueRecovery2Client, disconnectionRequestManager,\n        registrationRecoveryPasswordsManager, accountLockExecutor, messagePollExecutor,\n        retryExecutor, clock, configuration.getLinkDeviceSecretConfiguration().secret().value());\n    RateLimiters rateLimiters = RateLimiters.create(dynamicConfigurationManager, rateLimitersCluster, retryExecutor);\n    final BackupsDb backupsDb =\n        new BackupsDb(dynamoDbAsyncClient, configuration.getDynamoDbTables().getBackups().getTableName(), clock);\n    final GenericServerSecretParams backupsGenericZkSecretParams;\n    try {\n      backupsGenericZkSecretParams =\n          new GenericServerSecretParams(configuration.getBackupsZkConfig().serverSecret().value());\n    } catch (InvalidInputException e) {\n      throw new IllegalArgumentException(e);\n    }\n    final BackupManager backupManager = new BackupManager(\n        backupsDb,\n        backupsGenericZkSecretParams,\n        rateLimiters,\n        new TusAttachmentGenerator(configuration.getTus()),\n        new Cdn3BackupCredentialGenerator(configuration.getTus()),\n        new Cdn3RemoteStorageManager(\n            remoteStorageHttpExecutor,\n            retryExecutor,\n            configuration.getCdn3StorageManagerConfiguration()),\n        secureValueRecoveryBCredentialsGenerator,\n        secureValueRecoveryBClient,\n        clock,\n        dynamicConfigurationManager);\n\n    final IssuedReceiptsManager issuedReceiptsManager = new IssuedReceiptsManager(\n        configuration.getDynamoDbTables().getIssuedReceipts().getTableName(),\n        configuration.getDynamoDbTables().getIssuedReceipts().getExpiration(),\n        dynamoDbAsyncClient,\n        configuration.getDynamoDbTables().getIssuedReceipts().getGenerator(),\n        configuration.getDynamoDbTables().getIssuedReceipts().getmaxIssuedReceiptsPerPaymentId());\n\n    final ServerSecretParams zkSecretParams = new ServerSecretParams(configuration.getZkConfig().serverSecret().value());\n    final ServerZkReceiptOperations zkReceiptOperations = new ServerZkReceiptOperations(zkSecretParams);\n    GooglePlayBillingManager googlePlayBillingManager = new GooglePlayBillingManager(\n        new ByteArrayInputStream(configuration.getGooglePlayBilling().credentialsJson().getBytes(StandardCharsets.UTF_8)),\n        configuration.getGooglePlayBilling().packageName(),\n        configuration.getGooglePlayBilling().applicationName(),\n        configuration.getGooglePlayBilling().productIdToLevel());\n    AppleAppStoreManager appleAppStoreManager = new AppleAppStoreManager(\n        new AppleAppStoreClient(\n            configuration.getAppleAppStore().env(),\n            configuration.getAppleAppStore().bundleId(),\n            configuration.getAppleAppStore().appAppleId(),\n            configuration.getAppleAppStore().issuerId(),\n            configuration.getAppleAppStore().keyId(),\n            configuration.getAppleAppStore().encodedKey().value(),\n            configuration.getAppleAppStore().appleRootCerts(),\n            configuration.getAppleAppStore().retryConfigurationName()),\n        configuration.getAppleAppStore().subscriptionGroupId(),\n        configuration.getAppleAppStore().productIdToLevel());\n    final SubscriptionManager subscriptionManager = new SubscriptionManager(\n        new Subscriptions(configuration.getDynamoDbTables().getSubscriptions().getTableName(), dynamoDbAsyncClient),\n        List.of(googlePlayBillingManager, appleAppStoreManager),\n        zkReceiptOperations,\n        issuedReceiptsManager);\n\n    APNSender apnSender = new APNSender(apnSenderExecutor, configuration.getApnConfiguration());\n    FcmSender fcmSender = new FcmSender(fcmSenderExecutor, configuration.getFcmConfiguration().credentials().value());\n    PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(pushSchedulerCluster,\n        apnSender, fcmSender, accountsManager, 0, 0, retryExecutor);\n    PushNotificationManager pushNotificationManager = new PushNotificationManager(accountsManager,\n        apnSender, fcmSender, pushNotificationScheduler);\n    PushNotificationExperimentSamples pushNotificationExperimentSamples =\n        new PushNotificationExperimentSamples(dynamoDbAsyncClient,\n            configuration.getDynamoDbTables().getPushNotificationExperimentSamples().getTableName(),\n            Clock.systemUTC());\n\n    final DynamoDbRecoveryManager dynamoDbRecoveryManager =\n        new DynamoDbRecoveryManager(accounts, phoneNumberIdentifiers);\n\n    environment.lifecycle().manage(apnSender);\n    environment.lifecycle().manage(disconnectionRequestManager);\n    environment.lifecycle().manage(redisMessageAvailabilityManager);\n    environment.lifecycle().manage(new ManagedAwsCrt());\n\n    return new CommandDependencies(\n        accountsManager,\n        profilesManager,\n        reportMessageManager,\n        messagesCache,\n        messagesManager,\n        keys,\n        registrationRecoveryPasswordsManager,\n        apnSender,\n        fcmSender,\n        pushNotificationManager,\n        pushNotificationExperimentSamples,\n        cacheCluster,\n        pushSchedulerCluster,\n        redisClientResourcesBuilder,\n        backupManager,\n        issuedReceiptsManager,\n        googlePlayBillingManager,\n        appleAppStoreManager,\n        subscriptionManager,\n        dynamicConfigurationManager,\n        dynamoDbAsyncClient,\n        phoneNumberIdentifiers,\n        dynamoDbRecoveryManager\n    );\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/DeleteUserCommand.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport java.util.Optional;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager.DeletionReason;\n\npublic class DeleteUserCommand extends AbstractCommandWithDependencies {\n\n  private final Logger logger = LoggerFactory.getLogger(DeleteUserCommand.class);\n\n  public DeleteUserCommand() {\n    super(new Application<>() {\n      @Override\n      public void run(WhisperServerConfiguration configuration, Environment environment) {\n\n      }\n    }, \"rmuser\", \"remove user\");\n  }\n\n  @Override\n  public void configure(Subparser subparser) {\n    super.configure(subparser);\n    subparser.addArgument(\"-u\", \"--user\")\n        .dest(\"user\")\n        .type(String.class)\n        .required(true)\n        .help(\"The user to remove\");\n  }\n\n  @Override\n  protected void run(Environment environment, Namespace namespace, WhisperServerConfiguration configuration,\n      CommandDependencies deps) throws Exception {\n    try {\n      String[] users = namespace.getString(\"user\").split(\",\");\n      AccountsManager accountsManager = deps.accountsManager();\n\n      for (String user : users) {\n        Optional<Account> account = accountsManager.getByE164(user);\n\n        if (account.isPresent()) {\n          accountsManager.delete(account.get(), DeletionReason.ADMIN_DELETED);\n          logger.warn(\"Removed \" + account.get().getNumber());\n        } else {\n          logger.warn(\"Account not found\");\n        }\n      }\n    } catch (Exception ex) {\n      logger.warn(\"Removal Exception\", ex);\n      throw new RuntimeException(ex);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/DiscardPushNotificationExperimentSamplesCommand.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;\n\npublic class DiscardPushNotificationExperimentSamplesCommand extends AbstractCommandWithDependencies {\n\n  private final PushNotificationExperimentFactory<?> experimentFactory;\n\n  private static final int DEFAULT_MAX_CONCURRENCY = 16;\n\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  private static final Logger log = LoggerFactory.getLogger(DiscardPushNotificationExperimentSamplesCommand.class);\n\n  public DiscardPushNotificationExperimentSamplesCommand(final String name,\n      final String description,\n      final PushNotificationExperimentFactory<?> experimentFactory) {\n\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, name, description);\n\n    this.experimentFactory = experimentFactory;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n  }\n\n  @Override\n  protected void run(final Environment environment,\n      final Namespace namespace,\n      final WhisperServerConfiguration configuration,\n      final CommandDependencies commandDependencies) throws Exception {\n\n    final PushNotificationExperiment<?> experiment =\n        experimentFactory.buildExperiment(commandDependencies, configuration);\n\n    final int maxConcurrency = namespace.getInt(MAX_CONCURRENCY_ARGUMENT);\n\n    commandDependencies.pushNotificationExperimentSamples()\n        .discardSamples(experiment.getExperimentName(), maxConcurrency).join();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/FinishPushNotificationExperimentCommand.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSample;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.retry.Retry;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport java.time.Duration;\n\npublic class FinishPushNotificationExperimentCommand<T> extends AbstractCommandWithDependencies {\n\n  private final PushNotificationExperimentFactory<T> experimentFactory;\n\n  private static final int DEFAULT_MAX_CONCURRENCY = 16;\n\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  private static final String SAMPLES_READ_COUNTER_NAME =\n      MetricsUtil.name(FinishPushNotificationExperimentCommand.class, \"samplesRead\");\n\n  private static final Counter ACCOUNT_READ_COUNTER =\n      Metrics.counter(MetricsUtil.name(FinishPushNotificationExperimentCommand.class, \"accountRead\"));\n\n  private static final Counter FINAL_SAMPLE_STORED_COUNTER =\n      Metrics.counter(MetricsUtil.name(FinishPushNotificationExperimentCommand.class, \"finalSampleStored\"));\n\n  private static final Logger log = LoggerFactory.getLogger(FinishPushNotificationExperimentCommand.class);\n\n  public FinishPushNotificationExperimentCommand(final String name,\n      final String description,\n      final PushNotificationExperimentFactory<T> experimentFactory) {\n\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, name, description);\n\n    this.experimentFactory = experimentFactory;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n  }\n\n  @Override\n  protected void run(final Environment environment,\n      final Namespace namespace,\n      final WhisperServerConfiguration configuration,\n      final CommandDependencies commandDependencies) throws Exception {\n\n    final PushNotificationExperiment<T> experiment =\n        experimentFactory.buildExperiment(commandDependencies, configuration);\n\n    final int maxConcurrency = namespace.getInt(MAX_CONCURRENCY_ARGUMENT);\n\n    log.info(\"Finishing \\\"{}\\\" with max concurrency: {}\", experiment.getExperimentName(), maxConcurrency);\n\n    final AccountsManager accountsManager = commandDependencies.accountsManager();\n    final PushNotificationExperimentSamples pushNotificationExperimentSamples = commandDependencies.pushNotificationExperimentSamples();\n\n    final Flux<PushNotificationExperimentSample<T>> finishedSamples =\n        pushNotificationExperimentSamples.getSamples(experiment.getExperimentName(), experiment.getStateClass())\n            .doOnNext(sample -> Metrics.counter(SAMPLES_READ_COUNTER_NAME, \"final\", String.valueOf(sample.finalState() != null)).increment())\n            .flatMap(sample -> {\n              if (sample.finalState() == null) {\n                // We still need to record a final state for this sample\n                return Mono.fromFuture(() -> accountsManager.getByAccountIdentifierAsync(sample.accountIdentifier()))\n                    .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n                    .doOnNext(ignored -> ACCOUNT_READ_COUNTER.increment())\n                    .flatMap(maybeAccount -> {\n                      final T finalState = experiment.getState(maybeAccount.orElse(null),\n                          maybeAccount.flatMap(account -> account.getDevice(sample.deviceId())).orElse(null));\n\n                      return Mono.fromFuture(\n                              () -> pushNotificationExperimentSamples.recordFinalState(sample.accountIdentifier(),\n                                  sample.deviceId(),\n                                  experiment.getExperimentName(),\n                                  finalState))\n                          .onErrorResume(ConditionalCheckFailedException.class, throwable -> Mono.empty())\n                          .onErrorResume(JsonProcessingException.class, throwable -> {\n                            log.error(\"Failed to parse sample state JSON\", throwable);\n                            return Mono.empty();\n                          })\n                          .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n                          .onErrorResume(throwable -> {\n                            log.warn(\"Failed to record final state for {}:{} in experiment {}\",\n                                sample.accountIdentifier(), sample.deviceId(), experiment.getExperimentName(), throwable);\n\n                            return Mono.empty();\n                          })\n                          .doOnSuccess(ignored -> FINAL_SAMPLE_STORED_COUNTER.increment());\n                    });\n              } else {\n                return Mono.just(sample);\n              }\n            }, maxConcurrency);\n\n    experiment.analyzeResults(finishedSamples);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/IdleDeviceNotificationSchedulerFactory.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler;\nimport org.whispersystems.textsecuregcm.scheduler.JobScheduler;\nimport java.time.Clock;\n\npublic class IdleDeviceNotificationSchedulerFactory implements JobSchedulerFactory {\n\n  @Override\n  public JobScheduler buildJobScheduler(final CommandDependencies commandDependencies,\n      final WhisperServerConfiguration configuration) {\n\n    return new IdleDeviceNotificationScheduler(commandDependencies.accountsManager(),\n        commandDependencies.pushNotificationManager(),\n        commandDependencies.dynamoDbAsyncClient(),\n        configuration.getDynamoDbTables().getScheduledJobs().getTableName(),\n        configuration.getDynamoDbTables().getScheduledJobs().getExpiration(),\n        Clock.systemUTC());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityChecker.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.concurrent.CompletableFuture;\nimport org.apache.commons.lang3.StringUtils;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport reactor.core.publisher.Mono;\n\n/**\n * Checks if a device may benefit from receiving a push notification\n */\npublic class IdleWakeupEligibilityChecker {\n\n  @VisibleForTesting\n  static final Duration MIN_SHORT_IDLE_DURATION = Duration.ofDays(3);\n\n  @VisibleForTesting\n  static final Duration MAX_SHORT_IDLE_DURATION = Duration.ofDays(30);\n\n  @VisibleForTesting\n  static final Duration MIN_LONG_IDLE_DURATION = Duration.ofDays(60);\n\n  @VisibleForTesting\n  static final Duration MAX_LONG_IDLE_DURATION = Duration.ofDays(75);\n\n  private final MessagesManager messagesManager;\n  private final Clock clock;\n\n  public IdleWakeupEligibilityChecker(final Clock clock, final MessagesManager messagesManager) {\n    this.messagesManager = messagesManager;\n    this.clock = clock;\n  }\n\n  /**\n   * Determine whether the device may benefit from a push notification.\n   *\n   * @param account The account to check\n   * @param device  The device to check\n   * @return true if the device may benefit from a push notification, otherwise false\n   * @implNote There are two populations that may benefit from a wakeup:\n   * <ol>\n   * <li> Devices that have only been idle for a little while, but have messages that they don't seem to be retrieving\n   * <li> Devices that have been idle for a long time, but don't have any messages\n   * </ol>\n   * We think the first group sometimes just needs a little nudge to wake up and get their messages, and the latter\n   * group generally WOULD get their messages if they had any. We want to notify the first group to prompt them to\n   * actually get their messages and the latter group to prevent them from getting deleted due to inactivity (since they\n   * are otherwise healthy installations that just aren't getting much traffic).\n   */\n  public CompletableFuture<Boolean> isDeviceEligible(final Account account, final Device device) {\n\n    if (!hasPushToken(device)) {\n      return CompletableFuture.completedFuture(false);\n    }\n\n    if (isShortIdle(device, clock)) {\n      return messagesManager.mayHaveUrgentPersistedMessages(account.getIdentifier(IdentityType.ACI), device);\n    } else if (isLongIdle(device, clock)) {\n      return messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device)\n          .thenApply(mayHavePersistedMessages -> !mayHavePersistedMessages);\n    } else {\n      return CompletableFuture.completedFuture(false);\n    }\n  }\n\n  @VisibleForTesting\n  static boolean isShortIdle(final Device device, final Clock clock) {\n    final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant());\n\n    return idleDuration.compareTo(MIN_SHORT_IDLE_DURATION) >= 0 && idleDuration.compareTo(MAX_SHORT_IDLE_DURATION) < 0;\n  }\n\n  @VisibleForTesting\n  static boolean isLongIdle(final Device device, final Clock clock) {\n    final Duration idleDuration = Duration.between(Instant.ofEpochMilli(device.getLastSeen()), clock.instant());\n\n    return idleDuration.compareTo(MIN_LONG_IDLE_DURATION) >= 0 && idleDuration.compareTo(MAX_LONG_IDLE_DURATION) < 0;\n  }\n\n  @VisibleForTesting\n  static boolean hasPushToken(final Device device) {\n    return !StringUtils.isAllBlank(device.getApnId(), device.getGcmId());\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/JobSchedulerFactory.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.scheduler.JobScheduler;\n\npublic interface JobSchedulerFactory {\n\n  JobScheduler buildJobScheduler(CommandDependencies commandDependencies, WhisperServerConfiguration configuration);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/MessagePersisterServiceCommand.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.cli.ServerCommand;\nimport io.dropwizard.core.server.DefaultServerFactory;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.jetty.HttpsConnectorFactory;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.MessagePersister;\nimport org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;\nimport reactor.core.publisher.Hooks;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport javax.annotation.Nullable;\n\npublic class MessagePersisterServiceCommand extends ServerCommand<WhisperServerConfiguration> {\n\n  @Nullable\n  private ExecutorService persistQueueExecutorService;\n\n  @Nullable\n  private Scheduler persistQueueScheduler;\n\n  private static final String MAX_CONCURRENCY = \"maxConcurrency\";\n  private static final String SCAN_COUNT = \"scanCount\";\n\n  private static final Logger logger = LoggerFactory.getLogger(MessagePersisterServiceCommand.class);\n\n  public MessagePersisterServiceCommand() {\n    super(new Application<>() {\n            @Override\n            public void run(WhisperServerConfiguration configuration, Environment environment) {\n\n            }\n          }, \"message-persister-service\",\n        \"Starts a persistent service to persist undelivered messages from Redis to Dynamo DB\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    // This is a deliberate misnomer for consistency with other service commands that expect a worker count\n    subparser.addArgument(\"--workers\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY)\n        .required(true)\n        .help(\"The maximum number of concurrent Redis/DynamoDB operations\");\n\n    subparser.addArgument(\"--scan-count\")\n        .type(Integer.class)\n        .dest(SCAN_COUNT)\n        .required(false)\n        .setDefault(1024)\n        .help(\"The COUNT argument for the Redis SCAN operation that finds message queues\");\n  }\n\n  @Override\n  protected void run(Environment environment, Namespace namespace, WhisperServerConfiguration configuration)\n      throws Exception {\n\n    UncaughtExceptionHandler.register();\n    Hooks.onErrorDropped(e -> logger.warn(\"Dropped message persistence error\", e));\n\n    final CommandDependencies deps = CommandDependencies.build(\"message-persister-service\", environment, configuration);\n    MetricsUtil.configureRegistries(configuration, environment, deps.dynamicConfigurationManager());\n\n    if (configuration.getServerFactory() instanceof DefaultServerFactory defaultServerFactory) {\n      defaultServerFactory.getApplicationConnectors()\n          .forEach(connectorFactory -> {\n            if (connectorFactory instanceof HttpsConnectorFactory h) {\n              h.setKeyStorePassword(configuration.getTlsKeyStoreConfiguration().password().value());\n            }\n          });\n    }\n\n    persistQueueExecutorService = Executors.newVirtualThreadPerTaskExecutor();\n    persistQueueScheduler = Schedulers.fromExecutorService(persistQueueExecutorService, \"persistQueue\");\n\n    final MessagePersister messagePersister = new MessagePersister(deps.messagesCache(),\n        deps.messagesManager(),\n        deps.accountsManager(),\n        deps.dynamicConfigurationManager(),\n        persistQueueScheduler,\n        Clock.systemUTC(),\n        Duration.ofMinutes(configuration.getMessageCacheConfiguration().getPersistDelayMinutes()),\n        namespace.getInt(MAX_CONCURRENCY),\n        namespace.getInt(SCAN_COUNT));\n\n    environment.lifecycle().manage(messagePersister);\n\n    super.run(environment, namespace, configuration);\n  }\n\n  @Override\n  protected void cleanup() {\n    super.cleanup();\n\n    Hooks.resetOnErrorDropped();\n\n    if (persistQueueScheduler != null) {\n      persistQueueScheduler.dispose();\n    }\n\n    if (persistQueueExecutorService != null) {\n      persistQueueExecutorService.shutdown();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommand.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.configuration.DynamoDbTables;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.function.Tuples;\nimport reactor.util.retry.Retry;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.LocalTime;\n\npublic class NotifyIdleDevicesCommand extends AbstractSinglePassCrawlAccountsCommand {\n\n  private static final int DEFAULT_MAX_CONCURRENCY = 16;\n\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n\n  @VisibleForTesting\n  static final LocalTime PREFERRED_NOTIFICATION_TIME = LocalTime.of(14, 0);\n\n  private static final Counter DEVICE_INSPECTED_COUNTER =\n      Metrics.counter(MetricsUtil.name(NotifyIdleDevicesCommand.class, \"deviceInspected\"));\n\n  private static final String SCHEDULED_NOTIFICATION_COUNTER_NAME =\n      MetricsUtil.name(NotifyIdleDevicesCommand.class, \"scheduleNotification\");\n\n  private static final String DRY_RUN_TAG_NAME = \"dryRun\";\n\n  private static final Logger log = LoggerFactory.getLogger(NotifyIdleDevicesCommand.class);\n\n  public NotifyIdleDevicesCommand() {\n    super(\"notify-idle-devices\", \"Schedules push notifications for idle devices\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don't actually schedule notifications\");\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accounts) {\n    final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT);\n    final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n\n    final MessagesManager messagesManager = getCommandDependencies().messagesManager();\n    final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler = buildIdleDeviceNotificationScheduler();\n    final Clock clock = getClock();\n    final IdleWakeupEligibilityChecker idleWakeupEligibilityChecker = new IdleWakeupEligibilityChecker(clock, messagesManager);\n\n    accounts\n        .flatMap(account -> Flux.fromIterable(account.getDevices()).map(device -> Tuples.of(account, device)))\n        .doOnNext(ignored -> DEVICE_INSPECTED_COUNTER.increment())\n        .flatMap(accountAndDevice -> Mono.fromFuture(() ->\n                    idleWakeupEligibilityChecker.isDeviceEligible(accountAndDevice.getT1(), accountAndDevice.getT2()))\n                .mapNotNull(eligible -> eligible ? accountAndDevice : null)\n                .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n                .onErrorResume(throwable -> {\n                  log.warn(\"Failed to check eligibility for {}:{}\",\n                      accountAndDevice.getT1().getIdentifier(IdentityType.ACI),\n                      accountAndDevice.getT2().getId(),\n                      throwable);\n\n                  return Mono.empty();\n                }),\n            maxConcurrency)\n        .flatMap(accountAndDevice -> {\n          final Account account = accountAndDevice.getT1();\n          final Device device = accountAndDevice.getT2();\n\n          final Mono<Void> scheduleNotificationMono = dryRun\n              ? Mono.empty()\n              : Mono.fromFuture(() -> idleDeviceNotificationScheduler.scheduleNotification(account, device, PREFERRED_NOTIFICATION_TIME))\n                  .onErrorResume(throwable -> {\n                    log.warn(\"Failed to schedule notification for {}:{}\",\n                        account.getIdentifier(IdentityType.ACI),\n                        device.getId(),\n                        throwable);\n\n                    return Mono.empty();\n                  });\n\n          return scheduleNotificationMono\n              .doOnSuccess(ignored -> Metrics.counter(SCHEDULED_NOTIFICATION_COUNTER_NAME,\n                  DRY_RUN_TAG_NAME, String.valueOf(dryRun))\n                  .increment());\n        }, maxConcurrency)\n        .then()\n        .block();\n  }\n\n  @VisibleForTesting\n  protected Clock getClock() {\n    return Clock.systemUTC();\n  }\n\n  @VisibleForTesting\n  protected IdleDeviceNotificationScheduler buildIdleDeviceNotificationScheduler() {\n    final DynamoDbTables.TableWithExpiration tableConfiguration = getConfiguration().getDynamoDbTables().getScheduledJobs();\n\n    return new IdleDeviceNotificationScheduler(\n        getCommandDependencies().accountsManager(),\n        getCommandDependencies().pushNotificationManager(),\n        getCommandDependencies().dynamoDbAsyncClient(),\n        tableConfiguration.getTableName(),\n        tableConfiguration.getExpiration(),\n        Clock.systemUTC());\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/ProcessScheduledJobsServiceCommand.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.cli.ServerCommand;\nimport io.dropwizard.core.server.DefaultServerFactory;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.jetty.HttpsConnectorFactory;\nimport io.dropwizard.lifecycle.Managed;\nimport io.dropwizard.util.Duration;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.scheduler.JobScheduler;\nimport org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;\nimport reactor.core.Disposable;\nimport reactor.core.Disposables;\n\npublic class ProcessScheduledJobsServiceCommand extends ServerCommand<WhisperServerConfiguration> {\n\n  private final String name;\n  private final JobSchedulerFactory jobSchedulerFactory;\n\n  private static final String FIXED_DELAY_SECONDS_ARGUMENT = \"fixedDelay\";\n  private static final int DEFAULT_FIXED_DELAY_SECONDS = 60;\n  private static final String SHUTDOWN_WAIT_SECONDS_ARGUMENT = \"shutdownWait\";\n  private static final int DEFAULT_SHUTDOWN_WAIT_SECONDS = 60;\n\n  private static final Logger log = LoggerFactory.getLogger(ProcessScheduledJobsServiceCommand.class);\n\n  @VisibleForTesting\n  static class ScheduledJobProcessor implements Managed {\n\n    private final JobScheduler jobScheduler;\n\n    private final ScheduledExecutorService scheduledExecutorService;\n    private final int fixedDelaySeconds;\n\n    private ScheduledFuture<?> processJobsFuture;\n    private Disposable processAvailableJobsDisposableReference = Disposables.disposed();\n    private boolean stopped = false;\n\n    @VisibleForTesting\n    ScheduledJobProcessor(final JobScheduler jobScheduler,\n        final ScheduledExecutorService scheduledExecutorService,\n        final int fixedDelaySeconds) {\n\n      this.jobScheduler = jobScheduler;\n      this.scheduledExecutorService = scheduledExecutorService;\n      this.fixedDelaySeconds = fixedDelaySeconds;\n    }\n\n    @Override\n    public void start() {\n      processJobsFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> {\n        final CountDownLatch latch = new CountDownLatch(1);\n\n        synchronized (this) {\n          if (stopped) {\n            return;\n          }\n\n          processAvailableJobsDisposableReference = jobScheduler.processAvailableJobs()\n              // this CountDownLatch pattern is how Mono.block() is implemented\n              .doOnCancel(latch::countDown)\n              .doOnTerminate(latch::countDown)\n              .doOnError(e ->\n                  log.warn(\"Failed to process available jobs for scheduler: {}\", jobScheduler.getSchedulerName(), e))\n              .subscribe();\n        }\n\n        try {\n          latch.await();\n\n        } catch (final InterruptedException e) {\n          log.warn(\"Failed to process available jobs for scheduler: {}\", jobScheduler.getSchedulerName(), e);\n        }\n      }, 0, fixedDelaySeconds, TimeUnit.SECONDS);\n    }\n\n    @Override\n    public synchronized void stop() {\n      stopped = true;\n\n      if (processJobsFuture != null) {\n        processJobsFuture.cancel(false);\n      }\n\n      processAvailableJobsDisposableReference.dispose();\n\n      processJobsFuture = null;\n    }\n  }\n\n  public ProcessScheduledJobsServiceCommand(final String name,\n      final String description,\n      final JobSchedulerFactory jobSchedulerFactory) {\n\n    super(new Application<>() {\n            @Override\n            public void run(WhisperServerConfiguration configuration, Environment environment) {\n            }\n          }, name,\n        description);\n\n    this.name = name;\n    this.jobSchedulerFactory = jobSchedulerFactory;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--fixed-delay\")\n        .type(Integer.class)\n        .dest(FIXED_DELAY_SECONDS_ARGUMENT)\n        .setDefault(DEFAULT_FIXED_DELAY_SECONDS)\n        .help(\"The delay, in seconds, between queries for jobs to process\");\n\n    subparser.addArgument(\"--shutdown-wait\")\n        .type(Integer.class)\n        .dest(SHUTDOWN_WAIT_SECONDS_ARGUMENT)\n        .setDefault(DEFAULT_SHUTDOWN_WAIT_SECONDS)\n        .help(\"The duration, in seconds, to wait for in-flight jobs to finish at shutdown\");\n  }\n\n  @Override\n  protected void run(final Environment environment,\n      final Namespace namespace,\n      final WhisperServerConfiguration configuration)\n      throws Exception {\n\n    UncaughtExceptionHandler.register();\n\n    final CommandDependencies commandDependencies = CommandDependencies.build(name, environment, configuration);\n\n    final int fixedDelaySeconds = namespace.getInt(FIXED_DELAY_SECONDS_ARGUMENT);\n    final int shutdownWaitSeconds = namespace.getInt(SHUTDOWN_WAIT_SECONDS_ARGUMENT);\n\n    MetricsUtil.configureRegistries(configuration, environment, commandDependencies.dynamicConfigurationManager());\n\n    // Even though we're not actually serving traffic, `ServerCommand` subclasses need a valid server configuration, and\n    // that means they need to be able to decrypt the TLS keystore.\n    if (configuration.getServerFactory() instanceof DefaultServerFactory defaultServerFactory) {\n      defaultServerFactory.getApplicationConnectors()\n          .forEach(connectorFactory -> {\n            if (connectorFactory instanceof HttpsConnectorFactory h) {\n              h.setKeyStorePassword(configuration.getTlsKeyStoreConfiguration().password().value());\n            }\n          });\n    }\n\n    final ScheduledExecutorService scheduledExecutorService =\n        environment.lifecycle().scheduledExecutorService(\"scheduled-job-processor-%d\", false)\n            .shutdownTime(Duration.seconds(shutdownWaitSeconds))\n            .build();\n\n    final JobScheduler jobScheduler = jobSchedulerFactory.buildJobScheduler(commandDependencies, configuration);\n\n    environment.lifecycle().manage(new ScheduledJobProcessor(jobScheduler, scheduledExecutorService, fixedDelaySeconds));\n\n    super.run(environment, namespace, configuration);\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/PushNotificationExperimentFactory.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;\n\npublic interface PushNotificationExperimentFactory<T> {\n\n  PushNotificationExperiment<T> buildExperiment(CommandDependencies commandDependencies,\n      WhisperServerConfiguration configuration);\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/RegenerateSecondaryDynamoDbTableDataCommand.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Duration;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbRecoveryManager;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.retry.Retry;\n\npublic class RegenerateSecondaryDynamoDbTableDataCommand extends AbstractSinglePassCrawlAccountsCommand {\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  @VisibleForTesting\n  static final String RETRIES_ARGUMENT = \"retries\";\n\n  private static final String PROCESSED_ACCOUNTS_COUNTER_NAME =\n      MetricsUtil.name(RegenerateSecondaryDynamoDbTableDataCommand.class, \"processedAccounts\");\n\n  public RegenerateSecondaryDynamoDbTableDataCommand() {\n    super(\"regenerate-secondary-dynamodb-table-data\", \"Regenerates secondary DynamoDB table data from core tables\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don’t actually write constraint data\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(16)\n        .help(\"Max concurrency for DynamoDB operations\");\n\n    subparser.addArgument(\"--retries\")\n        .type(Integer.class)\n        .dest(RETRIES_ARGUMENT)\n        .setDefault(8)\n        .help(\"Maximum number of DynamoDB retries permitted per account\");\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accountRecords) {\n    final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n    final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT);\n    final int maxRetries = getNamespace().getInt(RETRIES_ARGUMENT);\n\n    final DynamoDbRecoveryManager dynamoDbRecoveryManager = getCommandDependencies().dynamoDbRecoveryManager();\n\n    final Counter processedAccountsCounter = Metrics.counter(PROCESSED_ACCOUNTS_COUNTER_NAME,\n        \"dryRun\", String.valueOf(dryRun));\n\n    accountRecords\n        .doOnNext(ignored -> processedAccountsCounter.increment())\n        .flatMap(account -> dryRun\n                ? Mono.empty()\n                : Mono.fromFuture(() -> dynamoDbRecoveryManager.regenerateData(account))\n                    .retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1)).maxBackoff(Duration.ofSeconds(4))\n                        .onRetryExhaustedThrow((spec, rs) -> rs.failure())),\n            maxConcurrency)\n        .then()\n        .block();\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredAccountsCommand.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport reactor.core.publisher.Mono;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.util.retry.Retry;\n\npublic class RemoveExpiredAccountsCommand extends AbstractSinglePassCrawlAccountsCommand {\n\n  private final Clock clock;\n\n  public static final Duration MAX_IDLE_DURATION = Duration.ofDays(120);\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n\n  private static final int MAX_CONCURRENCY = 16;\n\n  private static final String DELETED_ACCOUNT_COUNTER_NAME =\n      name(RemoveExpiredAccountsCommand.class, \"deletedAccounts\");\n\n  private static final Logger log = LoggerFactory.getLogger(RemoveExpiredAccountsCommand.class);\n\n  public RemoveExpiredAccountsCommand(final Clock clock) {\n    super(\"remove-expired-accounts\", \"Removes all accounts that have been idle for more than a set period of time\");\n\n    this.clock = clock;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don't actually delete accounts\");\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accounts) {\n    final boolean isDryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n    final Counter deletedAccountCounter =\n        Metrics.counter(DELETED_ACCOUNT_COUNTER_NAME, \"dryRun\", String.valueOf(isDryRun));\n\n    accounts.filter(this::isExpired)\n        .flatMap(expiredAccount -> {\n          final Mono<Void> deleteAccountMono = isDryRun\n              ? Mono.empty()\n              : Mono.fromRunnable(() -> getCommandDependencies().accountsManager().delete(expiredAccount, AccountsManager.DeletionReason.EXPIRED))\n                  .subscribeOn(Schedulers.boundedElastic())\n                  .then();\n\n          return deleteAccountMono\n              .doOnSuccess(ignored -> deletedAccountCounter.increment())\n              .retryWhen(Retry.backoff(8, Duration.ofSeconds(1)).maxBackoff(Duration.ofSeconds(4)))\n              .onErrorResume(throwable -> {\n                log.warn(\"Failed to delete account {}\", expiredAccount.getUuid(), throwable);\n                return Mono.empty();\n              });\n        }, MAX_CONCURRENCY)\n        .then()\n        .block();\n  }\n\n  @VisibleForTesting\n  boolean isExpired(final Account account) {\n    return Instant.ofEpochMilli(account.getLastSeen()).plus(MAX_IDLE_DURATION).isBefore(clock.instant());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredBackupsCommand.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.HexFormat;\nimport java.util.Objects;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.ExpiredBackup;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.util.retry.Retry;\n\npublic class RemoveExpiredBackupsCommand extends AbstractCommandWithDependencies {\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  private static final String SEGMENT_COUNT_ARGUMENT = \"segments\";\n  private static final String DRY_RUN_ARGUMENT = \"dry-run\";\n  private static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n  private static final String GRACE_PERIOD_ARGUMENT = \"grace-period\";\n\n  // A backup that has not been refreshed after a grace period is eligible for deletion\n  private static final Duration DEFAULT_GRACE_PERIOD = RemoveExpiredAccountsCommand.MAX_IDLE_DURATION;\n  private static final int DEFAULT_SEGMENT_COUNT = 1;\n  private static final int DEFAULT_CONCURRENCY = 16;\n\n  private static final String EXPIRED_BACKUPS_COUNTER_NAME = MetricsUtil.name(RemoveExpiredBackupsCommand.class,\n      \"expiredBackups\");\n\n  private final Clock clock;\n\n  public RemoveExpiredBackupsCommand(final Clock clock) {\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, \"remove-expired-backups\", \"Removes backups that have expired\");\n    this.clock = clock;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--segments\")\n        .type(Integer.class)\n        .dest(SEGMENT_COUNT_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_SEGMENT_COUNT)\n        .help(\"The total number of segments for a DynamoDB scan\");\n\n    subparser.addArgument(\"--grace-period\")\n        .type(Long.class)\n        .dest(GRACE_PERIOD_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_GRACE_PERIOD.getSeconds())\n        .help(\"The number of seconds after which a backup is eligible for removal\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_CONCURRENCY)\n        .help(\"Max concurrency for backup expirations. Each expiration may do multiple cdn operations\");\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don’t actually remove expired backups\");\n  }\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception {\n    final int segments = Objects.requireNonNull(namespace.getInt(SEGMENT_COUNT_ARGUMENT));\n    final int concurrency = Objects.requireNonNull(namespace.getInt(MAX_CONCURRENCY_ARGUMENT));\n    final boolean dryRun = namespace.getBoolean(DRY_RUN_ARGUMENT);\n    final Duration gracePeriod = Duration.ofSeconds(Objects.requireNonNull(namespace.getLong(GRACE_PERIOD_ARGUMENT)));\n\n    logger.info(\"Crawling backups with {} segments and {} processors, grace period {}\",\n        segments,\n        Runtime.getRuntime().availableProcessors(),\n        gracePeriod);\n\n    final BackupManager backupManager = commandDependencies.backupManager();\n    final long backupsExpired = backupManager\n        .getExpiredBackups(segments, Schedulers.parallel(), clock.instant().minus(gracePeriod))\n        .flatMap(expiredBackup -> removeExpiredBackup(backupManager, expiredBackup, dryRun), concurrency)\n        .filter(Boolean.TRUE::equals)\n        .count()\n        .block();\n    logger.info(\"Expired {} backups\", backupsExpired);\n  }\n\n  private Mono<Boolean> removeExpiredBackup(\n      final BackupManager backupManager, final ExpiredBackup expiredBackup,\n      final boolean dryRun) {\n\n    final Mono<Boolean> mono;\n    if (dryRun) {\n      mono = Mono.just(true);\n    } else {\n      mono = Mono.fromCompletionStage(() -> backupManager.expireBackup(expiredBackup)).thenReturn(true);\n    }\n\n    return mono\n        .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n        .doOnSuccess(ignored -> {\n          logger.trace(\"Successfully expired {} for {}\",\n              expiredBackup.expirationType(),\n              HexFormat.of().formatHex(expiredBackup.hashedBackupId()));\n          Metrics\n              .counter(EXPIRED_BACKUPS_COUNTER_NAME,\n                  \"tier\", expiredBackup.expirationType().name(),\n                  \"dryRun\", String.valueOf(dryRun))\n              .increment();\n        })\n        .onErrorResume(throwable -> {\n          logger.warn(\"Failed to remove tier {} for backup {}\",\n              expiredBackup.expirationType(),\n              HexFormat.of().formatHex(expiredBackup.hashedBackupId()));\n          return Mono.just(false);\n        });\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredLinkedDevicesCommand.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.function.Function;\nimport java.util.function.Predicate;\nimport java.util.stream.Collectors;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.function.Tuple2;\nimport reactor.util.function.Tuples;\nimport reactor.util.retry.Retry;\n\npublic class RemoveExpiredLinkedDevicesCommand extends AbstractSinglePassCrawlAccountsCommand {\n\n  private static final int DEFAULT_MAX_CONCURRENCY = 16;\n  private static final int DEFAULT_BUFFER_SIZE = 16_384;\n  private static final int DEFAULT_RETRIES = 3;\n\n  private static final String DRY_RUN_ARGUMENT = \"dry-run\";\n  private static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n  private static final String BUFFER_ARGUMENT = \"buffer\";\n  private static final String RETRIES_ARGUMENT = \"retries\";\n\n  private static final String REMOVED_DEVICES_COUNTER_NAME = name(RemoveExpiredLinkedDevicesCommand.class,\n      \"removedDevices\");\n\n  private static final String RETRIED_UPDATES_COUNTER_NAME = name(RemoveExpiredLinkedDevicesCommand.class,\n      \"retries\");\n\n  private static final String FAILED_UPDATES_COUNTER_NAME = name(RemoveExpiredLinkedDevicesCommand.class,\n      \"failedUpdates\");\n  private static final Logger logger = LoggerFactory.getLogger(RemoveExpiredLinkedDevicesCommand.class);\n\n  public RemoveExpiredLinkedDevicesCommand() {\n    super(\"remove-expired-devices\", \"Removes expired linked devices\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don’t actually modify accounts with expired linked devices\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n\n    subparser.addArgument(\"--buffer\")\n        .type(Integer.class)\n        .dest(BUFFER_ARGUMENT)\n        .setDefault(DEFAULT_BUFFER_SIZE)\n        .help(\"Accounts to buffer\");\n\n    subparser.addArgument(\"--retries\")\n        .type(Integer.class)\n        .dest(RETRIES_ARGUMENT)\n        .setDefault(DEFAULT_RETRIES)\n        .help(\"Maximum number of retries permitted per device\");\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accounts) {\n\n    final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n    final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT);\n    final int bufferSize = getNamespace().getInt(BUFFER_ARGUMENT);\n    final int maxRetries = getNamespace().getInt(RETRIES_ARGUMENT);\n\n    final Counter successCounter = Metrics.counter(REMOVED_DEVICES_COUNTER_NAME, \"dryRun\", String.valueOf(dryRun));\n\n    accounts.map(a -> Tuples.of(a, getExpiredLinkedDeviceIds(a.getDevices())))\n        .filter(accountAndExpiredDevices -> !accountAndExpiredDevices.getT2().isEmpty())\n        .buffer(bufferSize)\n        .map(source -> {\n          final List<Tuple2<Account, Set<Byte>>> shuffled = new ArrayList<>(source);\n          Collections.shuffle(shuffled);\n          return shuffled;\n        })\n        .limitRate(2)\n        .flatMapIterable(Function.identity())\n        .flatMap(accountAndExpiredDevices -> {\n          final Account account = accountAndExpiredDevices.getT1();\n          final Set<Byte> expiredDevices = accountAndExpiredDevices.getT2();\n\n          final Mono<Long> accountUpdate = dryRun\n              ? Mono.just((long) expiredDevices.size())\n              : deleteDevices(account, expiredDevices, maxRetries);\n\n          return accountUpdate\n              .doOnNext(successCounter::increment)\n              .onErrorResume(t -> {\n                logger.warn(\"Failed to remove expired linked devices for {}\", account.getUuid(), t);\n                return Mono.empty();\n              });\n        }, maxConcurrency)\n        .then()\n        .block();\n  }\n\n  private Mono<Long> deleteDevices(final Account account, final Set<Byte> expiredDevices, final int maxRetries) {\n\n    final Counter retryCounter = Metrics.counter(RETRIED_UPDATES_COUNTER_NAME);\n    final Counter errorCounter = Metrics.counter(FAILED_UPDATES_COUNTER_NAME);\n\n    return Flux.fromIterable(expiredDevices)\n        .flatMap(deviceId ->\n                Mono.fromRunnable(() -> getCommandDependencies().accountsManager().removeDevice(account, deviceId))\n                    .retryWhen(Retry.backoff(maxRetries, Duration.ofSeconds(1))\n                        .doAfterRetry(ignored -> retryCounter.increment())\n                        .onRetryExhaustedThrow((spec, rs) -> rs.failure()))\n                    .onErrorResume(t -> {\n                      logger.info(\"Failed to remove expired linked device {}.{}\", account.getUuid(), deviceId, t);\n                      errorCounter.increment();\n                      return Mono.empty();\n                    }),\n            // limit concurrency to avoid contested updates\n            1)\n        .count();\n  }\n\n  @VisibleForTesting\n  protected static Set<Byte> getExpiredLinkedDeviceIds(List<Device> devices) {\n    return devices.stream()\n        // linked devices\n        .filter(Predicate.not(Device::isPrimary))\n        // that are expired\n        .filter(Device::isExpired)\n        .map(Device::getId)\n        .collect(Collectors.toSet());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredUsernameHoldsCommand.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.whispersystems.textsecuregcm.metrics.MetricsUtil.name;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.concurrent.atomic.AtomicLong;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.util.retry.Retry;\n\npublic class RemoveExpiredUsernameHoldsCommand extends AbstractSinglePassCrawlAccountsCommand {\n\n  private final Clock clock;\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  private static final int DEFAULT_MAX_CONCURRENCY = 16;\n\n  private static final String DELETED_HOLDS_COUNTER_NAME =\n      name(RemoveExpiredUsernameHoldsCommand.class, \"expiredHolds\");\n\n  private static final String INSPECTED_ACCOUNTS_COUNTER_NAME =\n      name(RemoveExpiredUsernameHoldsCommand.class, \"inspectedAccounts\");\n\n  private static final Logger log = LoggerFactory.getLogger(RemoveExpiredUsernameHoldsCommand.class);\n\n  public RemoveExpiredUsernameHoldsCommand(final Clock clock) {\n    super(\"remove-expired-username-holds\", \"Removes expired username holds from account records\");\n    this.clock = clock;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don't actually delete holds\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accounts) {\n    final boolean isDryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n    final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT);\n\n    final Counter deletedHoldsCounter =\n        Metrics.counter(DELETED_HOLDS_COUNTER_NAME, \"dryRun\", String.valueOf(isDryRun));\n\n    final AccountsManager accountManager = getCommandDependencies().accountsManager();\n    final AtomicLong accountsInspected = new AtomicLong();\n    accounts.flatMap(account -> {\n          accountsInspected.incrementAndGet();\n          final List<Account.UsernameHold> holds = new ArrayList<>(account.getUsernameHolds());\n          final int holdsToRemove = removeExpired(holds);\n          final Mono<Void> purgeMono = isDryRun || holdsToRemove == 0\n              ? Mono.empty()\n              : Mono.fromRunnable(() -> accountManager.update(account, a -> a.setUsernameHolds(holds)))\n                  .subscribeOn(Schedulers.boundedElastic())\n                  .then();\n          Metrics.counter(INSPECTED_ACCOUNTS_COUNTER_NAME,\n                  \"dryRun\", String.valueOf(isDryRun),\n                  \"expiredHolds\", String.valueOf(holdsToRemove > 0))\n              .increment();\n          return purgeMono\n              .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n              .doOnSuccess(ignored -> deletedHoldsCounter.increment(holdsToRemove))\n              .onErrorResume(throwable -> {\n                log.warn(\"Failed to purge {} expired holds on account {}\", holdsToRemove, account.getUuid());\n                return Mono.empty();\n              });\n        }, maxConcurrency)\n        .then().block();\n    log.info(\"Finished crawl of {} accounts\", accountsInspected.get());\n  }\n\n  @VisibleForTesting\n  int removeExpired(final List<Account.UsernameHold> holds) {\n    final Instant now = this.clock.instant();\n    int holdsToRemove = 0;\n    for (Iterator<Account.UsernameHold> it = holds.iterator(); it.hasNext(); ) {\n      if (it.next().expirationSecs() < now.getEpochSecond()) {\n        holdsToRemove++;\n        it.remove();\n      }\n    }\n    return holdsToRemove;\n  }\n}\n\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/RemoveOrphanedPreKeyPagesCommand.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.DeviceKEMPreKeyPages;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.retry.Retry;\n\npublic class RemoveOrphanedPreKeyPagesCommand extends AbstractCommandWithDependencies {\n\n  private final Logger logger = LoggerFactory.getLogger(getClass());\n\n  private static final String PAGE_CONSIDERED_COUNTER_NAME = MetricsUtil.name(RemoveOrphanedPreKeyPagesCommand.class,\n      \"pageConsidered\");\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n\n  @VisibleForTesting\n  static final String CONCURRENCY_ARGUMENT = \"concurrency\";\n  private static final int DEFAULT_CONCURRENCY = 10;\n\n  @VisibleForTesting\n  static final String MINIMUM_ORPHAN_AGE_ARGUMENT = \"orphan-age\";\n  private static final Duration DEFAULT_MINIMUM_ORPHAN_AGE = Duration.ofDays(7);\n\n\n\n  private final Clock clock;\n\n  public RemoveOrphanedPreKeyPagesCommand(final Clock clock) {\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration configuration, final Environment environment) {\n      }\n    }, \"remove-orphaned-pre-key-pages\", \"Remove pre-key pages that are unreferenced\");\n    this.clock = clock;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--concurrency\")\n        .type(Integer.class)\n        .dest(CONCURRENCY_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_CONCURRENCY)\n        .help(\"The maximum number of parallel dynamodb operations to process concurrently\");\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don't actually remove orphaned pre-key pages\");\n\n    subparser.addArgument(\"--minimum-orphan-age\")\n        .type(String.class)\n        .dest(MINIMUM_ORPHAN_AGE_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_MINIMUM_ORPHAN_AGE.toString())\n        .help(\"Only remove orphans that are at least this old. Provide as an ISO-8601 duration string\");\n  }\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration, final CommandDependencies commandDependencies) throws Exception {\n\n    final int concurrency = Objects.requireNonNull(namespace.getInt(CONCURRENCY_ARGUMENT));\n    final boolean dryRun = Objects.requireNonNull(namespace.getBoolean(DRY_RUN_ARGUMENT));\n    final Duration orphanAgeMinimum =\n        Duration.parse(Objects.requireNonNull(namespace.getString(MINIMUM_ORPHAN_AGE_ARGUMENT)));\n    final Instant olderThan = clock.instant().minus(orphanAgeMinimum);\n\n    logger.info(\"Crawling preKey page store with concurrency={}, processors={}, dryRun={}. Removing orphans written before={}\",\n        concurrency,\n        Runtime.getRuntime().availableProcessors(),\n        dryRun,\n        olderThan);\n\n    final KeysManager keysManager = commandDependencies.keysManager();\n    final int deletedPages = keysManager.listStoredKEMPreKeyPages(concurrency)\n        .flatMap(storedPages -> Flux.fromStream(getDetetablePages(storedPages, olderThan))\n            .concatMap(pageId -> dryRun\n                ? Mono.just(0)\n                : Mono.fromCompletionStage(() ->\n                        keysManager.pruneDeadPage(storedPages.identifier(), storedPages.deviceId(), pageId))\n                    .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)))\n                    .thenReturn(1)), concurrency)\n        .reduce(0, Integer::sum)\n        .block();\n    logger.info(\"Deleted {} orphaned pages\", deletedPages);\n  }\n\n  private static Stream<UUID> getDetetablePages(final DeviceKEMPreKeyPages storedPages, final Instant olderThan) {\n    return storedPages.pageIdToLastModified()\n        .entrySet()\n        .stream()\n        .filter(page -> {\n          final UUID pageId = page.getKey();\n          final Instant lastModified = page.getValue();\n          return shouldDeletePage(storedPages.currentPage(), pageId, olderThan, lastModified);\n        })\n        .map(Map.Entry::getKey);\n  }\n\n  @VisibleForTesting\n  static boolean shouldDeletePage(\n      final Optional<UUID> currentPage, final UUID page,\n      final Instant deleteBefore, final Instant lastModified) {\n    final boolean isCurrentPageForDevice = currentPage.map(uuid -> uuid.equals(page)).orElse(false);\n    final boolean isStale = lastModified.isBefore(deleteBefore);\n    Metrics.counter(PAGE_CONSIDERED_COUNTER_NAME,\n            \"isCurrentPageForDevice\", Boolean.toString(isCurrentPageForDevice),\n            \"stale\", Boolean.toString(isStale))\n        .increment();\n    return !isCurrentPageForDevice && isStale;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/ScheduledApnPushNotificationSenderServiceCommand.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static com.codahale.metrics.MetricRegistry.name;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.cli.ServerCommand;\nimport io.dropwizard.core.server.DefaultServerFactory;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.jetty.HttpsConnectorFactory;\nimport java.util.concurrent.ScheduledExecutorService;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.util.logging.UncaughtExceptionHandler;\n\npublic class ScheduledApnPushNotificationSenderServiceCommand extends ServerCommand<WhisperServerConfiguration> {\n\n  private static final String WORKER_COUNT = \"workers\";\n  private static final String MAX_CONCURRENCY = \"max_concurrency\";\n  private static final String RETRY_THREADS = \"retry_threads\";\n\n  public ScheduledApnPushNotificationSenderServiceCommand() {\n    super(new Application<>() {\n            @Override\n            public void run(WhisperServerConfiguration configuration, Environment environment) {\n\n            }\n          }, \"scheduled-apn-push-notification-sender-service\",\n        \"Starts a persistent service to send scheduled APNs push notifications\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--workers\")\n        .type(Integer.class)\n        .dest(WORKER_COUNT)\n        .required(true)\n        .help(\"The number of worker threads\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY)\n        .required(false)\n        .setDefault(16)\n        .help(\"The number of concurrent operations per worker thread\");\n\n    subparser.addArgument(\"--retry-threads\")\n        .type(Integer.class)\n        .dest(RETRY_THREADS)\n        .required(false)\n        .setDefault(4)\n        .help(\"The number of threads to use in the retry executor's pool\");\n  }\n\n  @Override\n  protected void run(Environment environment, Namespace namespace, WhisperServerConfiguration configuration)\n      throws Exception {\n\n    UncaughtExceptionHandler.register();\n\n    final CommandDependencies deps = CommandDependencies.build(\"scheduled-apn-sender\", environment, configuration);\n    MetricsUtil.configureRegistries(configuration, environment, deps.dynamicConfigurationManager());\n\n    if (configuration.getServerFactory() instanceof DefaultServerFactory defaultServerFactory) {\n      defaultServerFactory.getApplicationConnectors()\n          .forEach(connectorFactory -> {\n            if (connectorFactory instanceof HttpsConnectorFactory h) {\n              h.setKeyStorePassword(configuration.getTlsKeyStoreConfiguration().password().value());\n            }\n          });\n    }\n\n    final ScheduledExecutorService retryExecutor = environment.lifecycle()\n        .scheduledExecutorService(name(ScheduledApnPushNotificationSenderServiceCommand.class, \"retry-%d\"))\n        .threads(namespace.getInt(RETRY_THREADS))\n        .build();\n\n    final PushNotificationScheduler pushNotificationScheduler = new PushNotificationScheduler(\n        deps.pushSchedulerCluster(),\n        deps.apnSender(),\n        deps.fcmSender(),\n        deps.accountsManager(),\n        namespace.getInt(WORKER_COUNT),\n        namespace.getInt(MAX_CONCURRENCY),\n        retryExecutor\n    );\n\n    environment.lifecycle().manage(pushNotificationScheduler);\n\n    super.run(environment, namespace, configuration);\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/ServerVersionCommand.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.cli.Command;\nimport io.dropwizard.core.setup.Bootstrap;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.whispersystems.textsecuregcm.WhisperServerVersion;\n\npublic class ServerVersionCommand extends Command {\n\n  public ServerVersionCommand() {\n    super(\"version\", \"Print the version of the service\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n  }\n\n  @Override\n  public void run(final Bootstrap<?> bootstrap, final Namespace namespace) throws Exception {\n    System.out.println(WhisperServerVersion.getServerVersion());\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/SetRequestLoggingEnabledTask.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.servlets.tasks.Task;\nimport org.whispersystems.textsecuregcm.util.logging.RequestLogManager;\n\nimport java.io.PrintWriter;\nimport java.util.List;\nimport java.util.Map;\n\npublic class SetRequestLoggingEnabledTask extends Task {\n\n    public SetRequestLoggingEnabledTask() {\n        super(\"set-request-logging-enabled\");\n    }\n\n    @Override\n    public void execute(final Map<String, List<String>> parameters, final PrintWriter out) {\n        if (parameters.containsKey(\"enabled\") && parameters.get(\"enabled\").size() == 1) {\n            final boolean enabled = Boolean.parseBoolean(parameters.get(\"enabled\").get(0));\n\n            RequestLogManager.setRequestLoggingEnabled(enabled);\n\n            if (enabled) {\n                out.println(\"Request logging now enabled\");\n            } else {\n                out.println(\"Request logging now disabled\");\n            }\n        } else {\n            out.println(\"Usage: set-request-logging-enabled?enabled=[true|false]\");\n        }\n    }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/SetUserDiscoverabilityCommand.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport java.util.Optional;\nimport java.util.UUID;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\n\npublic class SetUserDiscoverabilityCommand extends AbstractCommandWithDependencies {\n\n  public SetUserDiscoverabilityCommand() {\n\n    super(new Application<>() {\n      @Override\n      public void run(final WhisperServerConfiguration whisperServerConfiguration, final Environment environment) {\n      }\n    }, \"set-discoverability\", \"sets whether a user should be discoverable in CDS\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"-u\", \"--user\")\n        .dest(\"user\")\n        .type(String.class)\n        .required(true)\n        .help(\"the user (UUID or E164) for whom to change discoverability\");\n\n    subparser.addArgument(\"-d\", \"--discoverable\")\n        .dest(\"discoverable\")\n        .type(Boolean.class)\n        .required(true)\n        .help(\"whether the user should be discoverable in CDS\");\n  }\n\n  @Override\n  protected void run(final Environment environment,\n      final Namespace namespace,\n      final WhisperServerConfiguration configuration,\n      final CommandDependencies deps) throws Exception {\n\n    try {\n      final AccountsManager accountsManager = deps.accountsManager();\n      Optional<Account> maybeAccount;\n\n      try {\n        maybeAccount = accountsManager.getByAccountIdentifier(UUID.fromString(namespace.getString(\"user\")));\n      } catch (final IllegalArgumentException e) {\n        maybeAccount = accountsManager.getByE164(namespace.getString(\"user\"));\n      }\n\n      maybeAccount.ifPresentOrElse(account -> {\n            final boolean initiallyDiscoverable = account.isDiscoverableByPhoneNumber();\n            accountsManager.update(account, a -> a.setDiscoverableByPhoneNumber(namespace.getBoolean(\"discoverable\")));\n\n            System.out.format(\"Set discoverability flag for %s to %s (was previously %s)\\n\",\n                namespace.getString(\"user\"),\n                namespace.getBoolean(\"discoverable\"),\n                initiallyDiscoverable);\n          },\n          () -> System.err.println(\"User not found: \" + namespace.getString(\"user\")));\n    } catch (final Exception e) {\n      System.err.println(\"Failed to update discoverability setting for \" + namespace.getString(\"user\"));\n      e.printStackTrace();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/StartPushNotificationExperimentCommand.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.util.function.Tuples;\nimport reactor.util.retry.Retry;\nimport java.io.UncheckedIOException;\nimport java.time.Duration;\nimport java.util.UUID;\n\npublic class StartPushNotificationExperimentCommand<T> extends AbstractSinglePassCrawlAccountsCommand {\n\n  private final PushNotificationExperimentFactory<T> experimentFactory;\n\n  private static final int DEFAULT_MAX_CONCURRENCY = 16;\n\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n\n  private static final Counter DEVICE_INSPECTED_COUNTER =\n      Metrics.counter(MetricsUtil.name(StartPushNotificationExperimentCommand.class, \"deviceInspected\"));\n\n  private static final String RECORD_INITIAL_SAMPLE_COUNTER_NAME =\n      MetricsUtil.name(StartPushNotificationExperimentCommand.class, \"recordInitialSample\");\n\n  private static final String APPLY_TREATMENT_COUNTER_NAME =\n      MetricsUtil.name(StartPushNotificationExperimentCommand.class, \"applyTreatment\");\n\n  private static final String DRY_RUN_TAG_NAME = \"dryRun\";\n\n  private static final Logger log = LoggerFactory.getLogger(StartPushNotificationExperimentCommand.class);\n\n  public StartPushNotificationExperimentCommand(final String name,\n      final String description,\n      final PushNotificationExperimentFactory<T> experimentFactory) {\n\n    super(name, description);\n    this.experimentFactory = experimentFactory;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(DEFAULT_MAX_CONCURRENCY)\n        .help(\"Max concurrency for DynamoDB operations\");\n\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don't actually record samples or apply treatments\");\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accounts) {\n    final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT);\n    final boolean dryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n\n    final PushNotificationExperiment<T> experiment =\n        experimentFactory.buildExperiment(getCommandDependencies(), getConfiguration());\n\n    final PushNotificationExperimentSamples pushNotificationExperimentSamples =\n        getCommandDependencies().pushNotificationExperimentSamples();\n\n    log.info(\"Starting \\\"{}\\\" with max concurrency: {}\", experiment.getExperimentName(), maxConcurrency);\n\n    accounts\n        .flatMap(account -> Flux.fromIterable(account.getDevices()).map(device -> Tuples.of(account, device)))\n        .doOnNext(ignored -> DEVICE_INSPECTED_COUNTER.increment())\n        .flatMap(accountAndDevice -> Mono.fromFuture(() ->\n                experiment.isDeviceEligible(accountAndDevice.getT1(), accountAndDevice.getT2()))\n            .mapNotNull(eligible -> eligible ? accountAndDevice : null), maxConcurrency)\n        .flatMap(accountAndDevice -> {\n          final UUID accountIdentifier = accountAndDevice.getT1().getIdentifier(IdentityType.ACI);\n          final byte deviceId = accountAndDevice.getT2().getId();\n\n          final Mono<Boolean> recordInitialSampleMono = dryRun\n              ? Mono.just(true)\n              : Mono.fromFuture(() -> {\n                    try {\n                      return pushNotificationExperimentSamples.recordInitialState(\n                          accountIdentifier,\n                          deviceId,\n                          experiment.getExperimentName(),\n                          isInExperimentGroup(accountIdentifier, deviceId, experiment.getExperimentName()),\n                          experiment.getState(accountAndDevice.getT1(), accountAndDevice.getT2()));\n                    } catch (final JsonProcessingException e) {\n                      throw new UncheckedIOException(e);\n                    }\n                  })\n                  .retryWhen(Retry.backoff(3, Duration.ofSeconds(1))\n                      .onRetryExhaustedThrow(((backoffSpec, retrySignal) -> retrySignal.failure())));\n\n          return recordInitialSampleMono.mapNotNull(stateStored -> {\n                Metrics.counter(RECORD_INITIAL_SAMPLE_COUNTER_NAME,\n                        DRY_RUN_TAG_NAME, String.valueOf(dryRun),\n                        \"initialSampleAlreadyExists\", String.valueOf(!stateStored))\n                    .increment();\n\n                return stateStored ? accountAndDevice : null;\n              })\n              .onErrorResume(throwable -> {\n                log.warn(\"Failed to record initial sample for {}:{} in experiment {}\",\n                    accountIdentifier, deviceId, experiment.getExperimentName(), throwable);\n\n                return Mono.empty();\n              });\n        }, maxConcurrency)\n        .flatMap(accountAndDevice -> {\n          final Account account = accountAndDevice.getT1();\n          final Device device = accountAndDevice.getT2();\n          final boolean inExperimentGroup =\n              isInExperimentGroup(account.getIdentifier(IdentityType.ACI), device.getId(), experiment.getExperimentName());\n\n          final Mono<Void> applyTreatmentMono = dryRun\n              ? Mono.empty()\n              : Mono.fromFuture(() -> inExperimentGroup\n                      ? experiment.applyExperimentTreatment(account, device)\n                      : experiment.applyControlTreatment(account, device))\n                  .onErrorResume(throwable -> {\n                    log.warn(\"Failed to apply {} treatment for {}:{} in experiment {}\",\n                        inExperimentGroup ? \"experimental\" : \" control\",\n                        account.getIdentifier(IdentityType.ACI),\n                        device.getId(),\n                        experiment.getExperimentName(),\n                        throwable);\n\n                    return Mono.empty();\n                  });\n\n          return applyTreatmentMono\n                  .doOnSuccess(ignored -> Metrics.counter(APPLY_TREATMENT_COUNTER_NAME,\n                      DRY_RUN_TAG_NAME, String.valueOf(dryRun),\n                      \"treatment\", inExperimentGroup ? \"experiment\" : \"control\").increment());\n        }, maxConcurrency)\n        .then()\n        .block();\n  }\n\n  private boolean isInExperimentGroup(final UUID accountIdentifier, final byte deviceId, final String experimentName) {\n    return ((accountIdentifier.hashCode() ^ Byte.hashCode(deviceId) ^ experimentName.hashCode()) & 0x01) != 0;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/UnlinkDeviceCommand.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.setup.Environment;\nimport java.util.List;\nimport java.util.UUID;\nimport net.sourceforge.argparse4j.impl.Arguments;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class UnlinkDeviceCommand extends AbstractCommandWithDependencies {\n\n  public UnlinkDeviceCommand() {\n    super(new Application<>() {\n      @Override\n      public void run(WhisperServerConfiguration configuration, Environment environment) {\n\n      }\n    }, \"unlink-device\", \"Unlink a device and clear messages\");\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    super.configure(subparser);\n\n    subparser.addArgument(\"-d\", \"--deviceId\")\n        .dest(\"deviceIds\")\n        .type(Byte.class)\n        .action(Arguments.append())\n        .required(true);\n\n    subparser.addArgument(\"-u\", \"--uuid\")\n        .help(\"the UUID of the account to modify\")\n        .dest(\"uuid\")\n        .type(String.class)\n        .required(true);\n  }\n\n  @Override\n  protected void run(final Environment environment, final Namespace namespace,\n      final WhisperServerConfiguration configuration,\n      final CommandDependencies deps) throws Exception {\n    final UUID aci = UUID.fromString(namespace.getString(\"uuid\").trim());\n    final List<Byte> deviceIds = namespace.getList(\"deviceIds\");\n\n    Account account = deps.accountsManager().getByAccountIdentifier(aci)\n        .orElseThrow(() -> new IllegalArgumentException(\"account id \" + aci + \" does not exist\"));\n\n    if (deviceIds.contains(Device.PRIMARY_ID)) {\n      throw new IllegalArgumentException(\"cannot delete primary device\");\n    }\n\n    for (byte deviceId : deviceIds) {\n      /** see {@link org.whispersystems.textsecuregcm.controllers.DeviceController#removeDevice} */\n      System.out.format(\"Removing device %s::%d\\n\", aci, deviceId);\n      deps.accountsManager().removeDevice(account, deviceId);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/UnlinkDevicesWithIdlePrimaryCommand.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Metrics;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.metrics.MetricsUtil;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.util.function.Tuples;\nimport reactor.util.retry.Retry;\n\npublic class UnlinkDevicesWithIdlePrimaryCommand extends AbstractSinglePassCrawlAccountsCommand {\n\n  private final Clock clock;\n\n  @VisibleForTesting\n  static final String DRY_RUN_ARGUMENT = \"dry-run\";\n\n  @VisibleForTesting\n  static final String MAX_CONCURRENCY_ARGUMENT = \"max-concurrency\";\n\n  @VisibleForTesting\n  static final String PRIMARY_IDLE_DAYS_ARGUMENT = \"primary-idle-days\";\n\n  @VisibleForTesting\n  static final int DEFAULT_PRIMARY_IDLE_DAYS = 90;\n\n  private static final String UNLINK_DEVICE_COUNTER_NAME =\n      MetricsUtil.name(UnlinkDevicesWithIdlePrimaryCommand.class, \"unlinkDevice\");\n\n  private static final Logger logger = LoggerFactory.getLogger(UnlinkDevicesWithIdlePrimaryCommand.class);\n\n  public UnlinkDevicesWithIdlePrimaryCommand(final Clock clock) {\n    super(\"unlink-devices-with-idle-primary\", \"Unlinks linked devices if the account's primary device is idle\");\n\n    this.clock = clock;\n  }\n\n  @Override\n  public void configure(final Subparser subparser) {\n    subparser.addArgument(\"--dry-run\")\n        .type(Boolean.class)\n        .dest(DRY_RUN_ARGUMENT)\n        .required(false)\n        .setDefault(true)\n        .help(\"If true, don't actually delete accounts\");\n\n    subparser.addArgument(\"--max-concurrency\")\n        .type(Integer.class)\n        .dest(MAX_CONCURRENCY_ARGUMENT)\n        .setDefault(16)\n        .help(\"Max concurrency for DynamoDB operations\");\n\n    subparser.addArgument(\"--primary-idle-days\")\n        .type(Integer.class)\n        .dest(PRIMARY_IDLE_DAYS_ARGUMENT)\n        .required(false)\n        .setDefault(DEFAULT_PRIMARY_IDLE_DAYS)\n        .help(\"The number of inactivity after which a primary device is considered idle\");\n\n    super.configure(subparser);\n  }\n\n  @Override\n  protected void crawlAccounts(final Flux<Account> accounts) {\n    final boolean isDryRun = getNamespace().getBoolean(DRY_RUN_ARGUMENT);\n    final int maxConcurrency = getNamespace().getInt(MAX_CONCURRENCY_ARGUMENT);\n    final Duration idleDurationThreshold = Duration.ofDays(getNamespace().getInt(PRIMARY_IDLE_DAYS_ARGUMENT));\n\n    final AccountsManager accountsManager = getCommandDependencies().accountsManager();\n\n    final Counter unlinkDeviceCounter =\n        Metrics.counter(UNLINK_DEVICE_COUNTER_NAME, \"dryRun\", String.valueOf(isDryRun));\n\n    final Instant currentTime = clock.instant();\n\n    accounts\n        .filter(account -> isPrimaryDeviceIdle(account, currentTime, idleDurationThreshold))\n        .flatMap(accountWithIdlePrimaryDevice -> Flux.fromIterable(accountWithIdlePrimaryDevice.getDevices())\n            .filter(device -> !device.isPrimary())\n            .map(linkedDevice -> Tuples.of(accountWithIdlePrimaryDevice, linkedDevice.getId())))\n        .flatMap(accountAndLinkedDeviceId -> {\n          final Mono<Account> unlinkDeviceMono = isDryRun\n              ? Mono.empty()\n              : Mono.fromSupplier(() -> accountsManager.removeDevice(accountAndLinkedDeviceId.getT1(), accountAndLinkedDeviceId.getT2()))\n                  .subscribeOn(Schedulers.boundedElastic());\n\n          return unlinkDeviceMono\n              .doOnSuccess(ignored -> unlinkDeviceCounter.increment())\n              .retryWhen(Retry.backoff(3, Duration.ofSeconds(1)).maxBackoff(Duration.ofSeconds(4)))\n              .onErrorResume(throwable -> {\n                logger.warn(\"Failed to unlink device to delete account {}:{}\", accountAndLinkedDeviceId.getT1().getIdentifier(\n                    IdentityType.ACI), accountAndLinkedDeviceId.getT2(), throwable);\n\n                return Mono.empty();\n              });\n        }, maxConcurrency)\n        .then()\n        .block();\n  }\n\n  private static boolean isPrimaryDeviceIdle(final Account account, final Instant currentTime, final Duration idleDurationThreshold) {\n    final Duration durationSincePrimaryLastSeen =\n        Duration.between(Instant.ofEpochMilli(account.getPrimaryDevice().getLastSeen()), currentTime);\n\n    return durationSincePrimaryLastSeen.compareTo(idleDurationThreshold) > 0;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java/org/whispersystems/textsecuregcm/workers/ZkParamsCommand.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport io.dropwizard.core.cli.Command;\nimport io.dropwizard.core.setup.Bootstrap;\nimport java.util.Base64;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport net.sourceforge.argparse4j.inf.Subparser;\nimport org.signal.libsignal.zkgroup.ServerPublicParams;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\n\npublic class ZkParamsCommand extends Command {\n\n  public ZkParamsCommand() {\n    super(\"zkparams\", \"Generates server zkparams\");\n  }\n\n  @Override\n  public void configure(Subparser subparser) {\n\n  }\n\n  @Override\n  public void run(Bootstrap<?> bootstrap, Namespace namespace) throws Exception {\n    ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n    ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();\n\n    System.out.println(\"Public: \" + Base64.getEncoder().withoutPadding().encodeToString(serverPublicParams.serialize()));\n    System.out.println(\"Private: \" + Base64.getEncoder().withoutPadding().encodeToString(serverSecretParams.serialize()));\n  }\n\n}\n"
  },
  {
    "path": "service/src/main/java-templates/org/whispersystems/textsecuregcm/WhisperServerVersion.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm;\n\npublic class WhisperServerVersion {\n\n  private static final String VERSION = \"${project.version}\";\n\n  public static String getServerVersion() {\n    return VERSION;\n  }\n}\n"
  },
  {
    "path": "service/src/main/java-templates/org/whispersystems/textsecuregcm/storage/FoundationDbVersion.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\npublic class FoundationDbVersion {\n\n  private static final String VERSION = \"${foundationdb.version}\";\n  private static final int API_VERSION = ${foundationdb.api-version};\n\n  public static String getFoundationDbVersion() {\n    return VERSION;\n  }\n\n  public static int getFoundationDbApiVersion() {\n    return API_VERSION;\n  }\n}\n"
  },
  {
    "path": "service/src/main/proto/CallQualitySurveyPubSub.proto",
    "content": "// Copyright 2025 Signal Messenger, LLC\n// SPDX-License-Identifier: AGPL-3.0-only\n\n// Note: proto2 is de-facto required here because BigQuery pub/sub\n// subscriptions demand strict matching of \"modes\" (i.e. nullability), and\n// the BigQuery subscription system doesn't recognize proto3 fields as\n// \"required\".\nsyntax = \"proto2\";\n\npackage org.signal.calling.survey;\n\noption java_multiple_files = true;\n\nmessage CallQualitySurveyResponsePubSubMessage {\n  // A unique identifier for this call quality survey response\n  required string response_id = 1;\n\n  // The time at which this call quality survey response was received in\n  // microseconds since the epoch (see\n  // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)\n  required int64 submission_timestamp = 2;\n\n  // The geographic region (an ISO 3166-1 alpha-2 region code) associated with\n  // the IP address of the client that submitted this call quality survey\n  // response\n  optional string asn_region = 3;\n\n  // The platform of the client that submitted this call quality survey response\n  optional string client_platform = 4;\n\n  // The semantic version of the client that submitted this call quality survey\n  // response\n  optional string client_version = 5;\n\n  // Any additional specifiers (e.g. \"Windows 10.0.19045 libsignal/0.81.1\") from\n  // the caller's user-agent string\n  optional string client_ua_additional_specifiers = 6;\n\n  // Indicates whether the user was generally satisfied with the quality of the\n  // call\n  required bool user_satisfied = 7;\n\n  // A list of call quality issues selected by the user\n  repeated string call_quality_issues = 8;\n\n  // A free-form description of any additional issues as written by the user\n  optional string additional_issues_description = 9;\n\n  // A URL for a set of debug logs associated with the call if the user chose to\n  // submit debug logs\n  optional string debug_log_url = 10;\n\n  // The time at which the call started in microseconds since the epoch (see\n  // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)\n  required int64 start_timestamp = 11;\n\n  // The time at which the call ended in microseconds since the epoch (see\n  // https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#timestamp_type)\n  required int64 end_timestamp = 12;\n\n  // The type of call; note that direct voice calls can become video calls and\n  // vice versa, and this field indicates which mode was selected at call\n  // initiation time. At the time of writing, expected call types are\n  // \"direct_voice\", \"direct_video\", \"group\", and \"call_link\".\n  required string call_type = 13;\n\n  // Indicates whether the call completed without error or if it terminated\n  // abnormally\n  required bool success = 14;\n\n  // A client-defined, but human-readable reason for call termination\n  required string call_end_reason = 15;\n\n  // The median round-trip time, measured in milliseconds, for STUN/ICE packets\n  // (i.e. connection maintenance and establishment)\n  optional float connection_rtt_median = 16;\n\n  // The median round-trip time, measured in milliseconds, for RTP/RTCP packets\n  // for audio streams\n  optional float audio_rtt_median = 17;\n\n  // The median round-trip time, measured in milliseconds, for RTP/RTCP packets\n  // for video streams\n  optional float video_rtt_median = 18;\n\n  // The median jitter for audio streams, measured in milliseconds, for the\n  // duration of the call as measured by the client submitting the survey\n  optional float audio_recv_jitter_median = 19;\n\n  // The median jitter for video streams, measured in milliseconds, for the\n  // duration of the call as measured by the client submitting the survey\n  optional float video_recv_jitter_median = 20;\n\n  // The median jitter for audio streams, measured in milliseconds, for the\n  // duration of the call as measured by the remote endpoint in the call (either\n  // the peer of the client submitting the survey in a direct call or the SFU in\n  // a group call)\n  optional float audio_send_jitter_median = 21;\n\n  // The median jitter for video streams, measured in milliseconds, for the\n  // duration of the call as measured by the remote endpoint in the call (either\n  // the peer of the client submitting the survey in a direct call or the SFU in\n  // a group call)\n  optional float video_send_jitter_median = 22;\n\n  // The fraction of audio packets lost over the duration of the call as\n  // measured by the client submitting the survey\n  optional float audio_recv_packet_loss_fraction = 23;\n\n  // The fraction of video packets lost over the duration of the call as\n  // measured by the client submitting the survey\n  optional float video_recv_packet_loss_fraction = 24;\n\n  // The fraction of audio packets lost over the duration of the call as\n  // measured by the remote endpoint in the call (either the peer of the client\n  // submitting the survey in a direct call or the SFU in a group call)\n  optional float audio_send_packet_loss_fraction = 25;\n\n  // The fraction of video packets lost over the duration of the call as\n  // measured by the remote endpoint in the call (either the peer of the client\n  // submitting the survey in a direct call or the SFU in a group call)\n  optional float video_send_packet_loss_fraction = 26;\n\n  // Technical, machine-generated data about the quality and mechanics of a\n  // call; this is a serialized protobuf entity generated (and, critically,\n  // explained to the user!) by the calling library\n  optional bytes call_telemetry = 27;\n}\n"
  },
  {
    "path": "service/src/main/proto/DisconnectionRequests.proto",
    "content": "/**\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\nsyntax = \"proto3\";\n\npackage org.signal.chat.auth;\n\noption java_package = \"org.whispersystems.textsecuregcm.auth\";\noption java_multiple_files = true;\n\nmessage DisconnectionRequest {\n  bytes account_identifier = 1;\n  repeated uint32 device_ids = 2;\n}\n"
  },
  {
    "path": "service/src/main/proto/DonationsPubsub.proto",
    "content": "syntax = \"proto2\";\n\noption java_package = \"org.whispersystems.textsecuregcm.subscriptions\";\n\n/**\n * A message that contains details about a new donation, whether a one-time \"boost\" or a recurring subscription.\n */\nmessage DonationPubSubMessage {\n\n  /**\n   * The instant at which this donation took place in microseconds since the epoch.\n   */\n  required int64 timestamp = 1;\n\n  /**\n   * A string identifying the source (either \"web\" or \"app\") from which this donation originated.\n   */\n  required string source = 2;\n\n  /**\n   * An identifier for the payment provider that handled this donation (e.g. \"stripe\" or \"braintree\" or \"donorbox\").\n   */\n  required string provider = 3;\n\n  /**\n   * If `true`, indicates that this donation is part of a subscription. If `false`, this is a one-time donation.\n   */\n  required bool recurring = 4;\n\n  /**\n   * The type of payment method used for this donation (e.g. \"credit_card\" or \"apple_pay\" or \"paypal\").\n   */\n  required string payment_method_type = 5;\n\n  /**\n   * The original amount of the donation before fees or conversion, in millionths of a full unit of the currency. For\n   * example, an amount of 9.75 USD would be represented as 9750000.\n   */\n  required int64 original_amount_micros = 6;\n\n  /**\n   * The ISO 4217 identifier for the original currency of this donation (e.g. \"USD\" or \"EUR\").\n   */\n  required string original_currency = 7;\n\n  /**\n   * The amount of the donation after conversion to USD in millionths of a dollar. If the original amount was in USD,\n   * this value must be the same as `original_amount_micros`.\n   */\n  required int64 original_amount_usd_micros = 8;\n\n  /**\n   * The ISO 3166 country code of the country from which this donation originated. May be omitted if not known.\n   */\n  optional string country = 9;\n\n  /**\n   * The platform of the client that made this donation (e.g. \"ios\" or \"android\" or \"desktop\") if known. May be omitted\n   * if not known.\n   */\n  optional string client_platform = 10;\n}\n"
  },
  {
    "path": "service/src/main/proto/KeyTransparencyService.proto",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\noption java_package = \"org.signal.keytransparency.client\";\n\npackage kt_query;\n\nimport \"org/signal/chat/require.proto\";\n\n/**\n * An external-facing, read-only key transparency service used by Signal's chat server\n * to look up and monitor identifiers.\n * There are three types of identifier mappings stored by the key transparency log:\n * - An ACI which maps to an ACI identity key\n * - An E164-formatted phone number which maps to an ACI\n * - A username hash which also maps to an ACI\n * Separately, the log also stores and periodically updates a fixed value known as the `distinguished` key.\n * Clients use the verified tree head from looking up this key for future calls to the Search and Monitor endpoints.\n *\n * Note that this service definition is used in two different contexts:\n *   1. Implementing the endpoints with rate-limiting and request validation\n *   2. Using the generated client stub to forward requests to the remote key transparency service\n */\nservice KeyTransparencyQueryService {\n  option (org.signal.chat.require.auth) = AUTH_ONLY_ANONYMOUS;\n  /**\n   * An endpoint used by clients to retrieve the most recent distinguished tree\n   * head, which should be used to derive consistency parameters for\n   * subsequent Search and Monitor requests. It should be the first key\n   * transparency RPC a client calls.\n   */\n  rpc Distinguished(DistinguishedRequest) returns (DistinguishedResponse) {}\n  /**\n   * An endpoint used by clients to search for one or more identifiers in the transparency log.\n   * The server returns proof that the identifier(s) exist in the log.\n   */\n  rpc Search(SearchRequest) returns (SearchResponse) {}\n  /**\n   * An endpoint that allows users to monitor a group of identifiers by returning proof that the log continues to be\n   * constructed correctly in later entries for those identifiers.\n   */\n  rpc Monitor(MonitorRequest) returns (MonitorResponse) {}\n}\n\nmessage SearchRequest {\n  /**\n   * The ACI to look up in the log.\n   */\n  bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];\n  /**\n   * The ACI identity key that the client thinks the ACI maps to in the log.\n   */\n  bytes aci_identity_key = 2 [(org.signal.chat.require.nonEmpty) = true];\n  /**\n   * The username hash to look up in the log.\n   */\n  optional bytes username_hash = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];\n  /**\n   * The E164 to look up in the log along with associated data.\n   */\n  optional E164SearchRequest e164_search_request = 4;\n  /**\n   * The tree head size(s) to prove consistency against.\n   */\n  ConsistencyParameters consistency = 5 [(org.signal.chat.require.present) = true];\n}\n\n/**\n * E164SearchRequest contains the data that the user must provide when looking up an E164.\n */\nmessage E164SearchRequest {\n  /**\n   * The E164 that the client wishes to look up in the transparency log.\n   */\n  optional string e164 = 1 [(org.signal.chat.require.e164) = true];\n  /**\n   * The unidentified access key of the account associated with the provided E164.\n   */\n  bytes unidentified_access_key = 2;\n}\n\n/**\n * SearchResponse contains search proofs for each of the requested identifiers.\n */\nmessage SearchResponse {\n  /**\n   * A signed representation of the log tree's current state along with some\n   * additional information necessary for validation such as a consistency proof and an auditor-signed tree head.\n   */\n  FullTreeHead tree_head = 1;\n  /**\n   * The ACI search response is always provided.\n   */\n  CondensedTreeSearchResponse aci = 2;\n  /**\n   * This response is only provided if all of the conditions are met:\n   * - the E164 exists in the log\n   * - its mapped ACI matches the one provided in the request\n   * - the account associated with the ACI is discoverable\n   * - the unidentified access key provided in E164SearchRequest matches the one on the account\n   */\n  optional CondensedTreeSearchResponse e164 = 3;\n  /**\n   * This response is only provided if the username hash exists in the log and\n   * its mapped ACI matches the one provided in the request.\n   */\n  optional CondensedTreeSearchResponse username_hash = 4;\n}\n\n/**\n * The tree head size(s) to prove consistency against. A client's very first\n * key transparency request should be looking up the \"distinguished\" key;\n * in this case, both fields will be omitted since the client has no previous\n * tree heads to prove consistency against.\n */\nmessage ConsistencyParameters {\n  /**\n   * The non-distinguished tree head size to prove consistency against.\n   * This field may be omitted if the client is looking up an identifier\n   * for the first time.\n   */\n  optional uint64 last = 1;\n  /**\n   * The distinguished tree head size to prove consistency against.\n   * This field may be omitted when the client is looking up the\n   * \"distinguished\" key for the very first time.\n   */\n  optional uint64 distinguished = 2;\n}\n\n/**\n * DistinguishedRequest looks up the most recent distinguished key in the\n * transparency log.\n */\nmessage DistinguishedRequest {\n  /**\n   * The tree size of the client's last verified distinguished request. With the\n   * exception of a client's very first request, this field should always be\n   * set.\n   */\n  optional uint64 last = 1;\n}\n\n/**\n * DistinguishedResponse contains the tree head and search proof for the most\n * recent `distinguished` key in the log.\n */\nmessage DistinguishedResponse {\n  /**\n   * A signed representation of the log tree's current state along with some\n   * additional information necessary for validation such as a consistency proof and an auditor-signed tree head.\n   */\n  FullTreeHead tree_head = 1;\n  /**\n   * This search response is always provided.\n   */\n  CondensedTreeSearchResponse distinguished = 2;\n}\n\nmessage CondensedTreeSearchResponse {\n  /**\n   * A proof that is combined with the original requested identifier and the VRF public key\n   * and outputs whether the proof is valid, and if so, the commitment index.\n   */\n  bytes vrf_proof = 1;\n  /**\n   * A proof that the binary search for the given identifier was done correctly.\n   */\n  SearchProof search = 2;\n  /**\n   * A 32-byte value computed based on the log position of the identifier\n   * and a random 32 byte key that is only known by the key transparency service.\n   * It is provided so that clients can recompute and verify the commitment.\n   */\n  bytes opening = 3;\n  /**\n   * The new or updated value that the identifier maps to.\n   */\n  UpdateValue value = 4;\n}\n\nmessage FullTreeHead {\n  /**\n   * A representation of the log tree's current state signed by the key transparency service.\n   */\n  TreeHead tree_head = 1;\n  /**\n   * A consistency proof between the current tree size and the requested tree size.\n   */\n  repeated bytes last = 2;\n  /**\n   * A consistency proof between the current tree size and the requested distinguished tree size.\n   */\n  repeated bytes distinguished = 3;\n  /**\n   * A list of tree heads signed by third-party auditors.\n   */\n  repeated FullAuditorTreeHead full_auditor_tree_heads = 4;\n}\n\n/**\n * TreeHead represents the key transparency service's view of the transparency log.\n */\nmessage TreeHead {\n  /**\n   * The number of entries in the log tree.\n   */\n  uint64 tree_size = 1;\n  /**\n   * The time in milliseconds since epoch when the tree head signature was generated.\n   */\n  int64 timestamp = 2;\n  /**\n   * A list of the key transparency service's signatures over the transparency log. Since the\n   * signed data structure assumes one auditor, the key transparency service generates\n   * one signature per auditor.\n   */\n  repeated Signature signatures = 3;\n}\n\n/**\n * The key transparency service provides one Signature per auditor.\n */\nmessage Signature {\n  /**\n   * The public component of the Ed25519 key pair that the auditor used to sign its view\n   * of the transparency log. This value allows clients to identify the corresponding signature.\n   */\n  bytes auditor_public_key = 1;\n  /**\n   * The key transparency service's signature over the transparency log using the\n   * the given public auditor key.\n   */\n  bytes signature = 2;\n}\n\n/**\n * AuditorTreeHead represents an auditor's view of the transparency log.\n */\nmessage AuditorTreeHead {\n  /**\n   * The number of entries in the auditor's view of the transparency log.\n   */\n  uint64 tree_size = 1;\n  /**\n   * The time in milliseconds since epoch when the auditor's signature was generated.\n   */\n  int64 timestamp = 2;\n  /**\n   * The auditor's signature computed over its view of the transparency log's current state\n   * and long-term log configuration.\n   */\n  bytes signature = 3;\n}\n\nmessage FullAuditorTreeHead {\n  /**\n   * A representation of the log tree state signed by a third-party auditor.\n   */\n  AuditorTreeHead tree_head = 1;\n  /**\n   * The root hash of the log tree when the auditor produced the tree head signature.\n   * Provided if the auditor tree head size is smaller than the size of the most recent\n   * tree head provided to the user.\n   */\n  optional bytes root_value = 2;\n  /**\n   * A consistency proof between the auditor tree head and the most recent tree head.\n   * Provided if the auditor tree head size is smaller than the size of the most recent\n   * tree head provided by the key transparency service to the user.\n   */\n  repeated bytes consistency = 3;\n  /**\n   * The public component of the Ed25519 key pair that the third-party auditor used to generate\n   * a signature. This value allows clients to identify the auditor tree head and signature.\n   */\n  bytes public_key = 4;\n}\n\n/**\n * A ProofStep represents one \"step\" or log entry in the binary search\n * and can be used to calculate a log tree leaf hash.\n */\nmessage ProofStep {\n  /**\n   * Provides the data needed to recompute the prefix tree root hash corresponding to the given log entry.\n   */\n  PrefixSearchResult prefix = 1;\n  /**\n   * A cryptographic hash of the update used to calculate the log tree leaf hash.\n   */\n  bytes commitment = 2;\n}\n\nmessage SearchProof {\n  /**\n   * The position in the log tree of the first occurrence of the requested identifier.\n   */\n  uint64 pos = 1;\n  /**\n   * The steps of a binary search through the entries of the log tree for the given identifier version.\n   * Each ProofStep corresponds to a log entry and provides the information necessary to recompute a log tree\n   * leaf hash.\n   */\n  repeated ProofStep steps = 2;\n  /**\n   * A batch inclusion proof for all log tree leaves involved in the binary search for the given identifier.\n   */\n  repeated bytes inclusion = 3;\n}\n\n\nmessage UpdateValue {\n  /**\n   * The new mapped value for an identifier or the \"distinguished\" key.\n   */\n  bytes value = 1;\n}\n\nmessage PrefixSearchResult {\n  /**\n   * A proof from a prefix tree that indicates a search was done correctly for a given identifier.\n   * The elements of this array are the copath of the prefix tree leaf node in bottom-to-top order.\n   */\n  repeated bytes proof = 1;\n  /**\n   * The version of the requested identifier in the prefix tree.\n   */\n  uint32 counter = 2;\n}\n\nmessage MonitorRequest {\n  AciMonitorRequest aci = 1 [(org.signal.chat.require.present) = true];\n  optional UsernameHashMonitorRequest username_hash = 2;\n  optional E164MonitorRequest e164 = 3;\n  ConsistencyParameters consistency = 4 [(org.signal.chat.require.present) = true];\n}\n\nmessage AciMonitorRequest {\n  bytes aci = 1 [(org.signal.chat.require.exactlySize) = 16];\n  uint64 entry_position = 2;\n  bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 32];\n}\n\nmessage UsernameHashMonitorRequest {\n  bytes username_hash = 1 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];\n  uint64 entry_position = 2;\n  bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];\n}\n\nmessage E164MonitorRequest {\n  optional string e164 = 1 [(org.signal.chat.require.e164) = true];\n  uint64 entry_position = 2;\n  bytes commitment_index = 3 [(org.signal.chat.require.exactlySize) = 0, (org.signal.chat.require.exactlySize) = 32];\n}\n\nmessage MonitorProof {\n  /**\n   * Generated based on the monitored entry provided in MonitorRequest.entries. Each ProofStep\n   * corresponds to a log tree entry that exists in the search path to each monitored entry\n   * and that came *after* that monitored entry. It proves that the log tree has been constructed\n   * correctly at that later entry. This list also includes any remaining entries\n   * along the \"frontier\" of the log tree which proves that the very last entry in the log\n   * has been constructed correctly.\n   */\n  repeated ProofStep steps = 1;\n}\n\nmessage MonitorResponse {\n  /**\n   * A signed representation of the log tree's current state along with some\n   * additional information necessary for validation such as a consistency proof and an auditor-signed tree head.\n   */\n  FullTreeHead tree_head = 1;\n  /**\n   * A proof that the MonitorRequest's ACI continues to be constructed correctly in later entries of the log tree.\n   */\n  MonitorProof aci = 2;\n  /**\n   * A proof that the username hash continues to be constructed correctly in later entries of the log tree.\n   * Will be absent if the request did not include a UsernameHashMonitorRequest.\n   */\n  optional MonitorProof username_hash = 3;\n  /**\n   * A proof that the e164 continues to be constructed correctly in later entries of the log tree.\n   * Will be absent if the request did not include a E164MonitorRequest.\n   */\n  optional MonitorProof e164 = 4;\n  /**\n   * A batch inclusion proof that the log entries involved in the binary search for each of the entries\n   * being monitored in the request are included in the current log tree.\n   */\n  repeated bytes inclusion = 5;\n}\n"
  },
  {
    "path": "service/src/main/proto/PubSubMessage.proto",
    "content": "/**\n * Copyright 2014 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\nsyntax = \"proto2\";\n\npackage textsecure;\n\noption java_package = \"org.whispersystems.textsecuregcm.storage\";\noption java_outer_classname = \"PubSubProtos\";\n\nmessage PubSubMessage {\n  enum Type {\n    UNKNOWN   = 0;\n    QUERY_DB  = 1;\n    DELIVER   = 2;\n    KEEPALIVE = 3;\n    CLOSE     = 4;\n    CONNECTED = 5;\n  }\n\n  optional Type  type    = 1;\n  optional bytes content = 2;\n}\n"
  },
  {
    "path": "service/src/main/proto/RegistrationService.proto",
    "content": "syntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.registration.rpc;\n\nservice RegistrationService {\n  /**\n   * Create a new registration session for a given destination phone number.\n   */\n  rpc CreateSession (CreateRegistrationSessionRequest) returns (CreateRegistrationSessionResponse) {}\n\n  /**\n   * Retrieves session metadata for a given session.\n   */\n  rpc GetSessionMetadata (GetRegistrationSessionMetadataRequest) returns (GetRegistrationSessionMetadataResponse) {}\n\n  /**\n   * Sends a verification code to a destination phone number within the context\n   * of a previously-created registration session.\n   */\n  rpc SendVerificationCode (SendVerificationCodeRequest) returns (SendVerificationCodeResponse) {}\n\n  /**\n   * Checks a client-provided verification code for a given registration\n   * session.\n   */\n  rpc CheckVerificationCode (CheckVerificationCodeRequest) returns (CheckVerificationCodeResponse) {}\n}\n\nmessage CreateRegistrationSessionRequest {\n  /**\n   * The phone number for which to create a new registration session.\n   */\n  uint64 e164 = 1;\n\n  /**\n   * Indicates whether an account already exists with the given e164 (i.e. this\n   * session represents a \"re-registration\" attempt).\n   */\n  bool account_exists_with_e164 = 2;\n\n  /**\n   * The session creation rate limit for the number will be\n   * collated by this key.\n   */\n  string rate_limit_collation_key = 3;\n\n  /**\n   * The MCC for the given `e164` as reported by a number lookup service.\n   */\n  string mcc = 4;\n\n  /**\n   * The MNC for the given `e164` as reported by a number lookup service.\n   */\n  string mnc = 5;\n}\n\nmessage CreateRegistrationSessionResponse {\n  oneof response {\n    /**\n     * Metadata for the newly-created session.\n     */\n    RegistrationSessionMetadata session_metadata = 1;\n\n    /**\n     * A response explaining why a session could not be created as requested.\n     */\n    CreateRegistrationSessionError error = 2;\n  }\n}\n\nmessage RegistrationSessionMetadata {\n  /**\n   * An opaque sequence of bytes that uniquely identifies the registration\n   * session associated with this registration attempt.\n   */\n  bytes session_id = 1;\n\n  /**\n   * Indicates whether a valid verification code has been submitted in the scope\n   * of this session.\n   */\n  bool verified = 2;\n\n  /**\n   * The phone number associated with this registration session.\n   */\n  uint64 e164 = 3;\n\n  /**\n   * Indicates whether the caller may request delivery of a verification code\n   * via SMS now or at some time in the future. If true, the time a caller must\n   * wait before requesting a verification code via SMS is given in the\n   * `next_sms_seconds` field.\n   */\n  bool may_request_sms = 4;\n\n  /**\n   * The duration, in seconds, after which a caller will next be allowed to\n   * request delivery of a verification code via SMS if `may_request_sms` is\n   * true. If zero, a caller may request a verification code via SMS\n   * immediately. If `may_request_sms` is false, this field has no meaning.\n   */\n  uint64 next_sms_seconds = 5;\n\n  /**\n   * Indicates whether the caller may request delivery of a verification code\n   * via a phone call now or at some time in the future. If true, the time a\n   * caller must wait before requesting a verification code via SMS is given in\n   * the `next_voice_call_seconds` field. If false, simply waiting will not\n   * allow the caller to request a phone call and the caller may need to\n   * perform some other action (like attempting verification code delivery via\n   * SMS) before requesting a voice call.\n   */\n  bool may_request_voice_call = 6;\n\n  /**\n   * The duration, in seconds, after which a caller will next be allowed to\n   * request delivery of a verification code via a phone call if\n   * `may_request_voice_call` is true. If zero, a caller may request a\n   * verification code via a phone call immediately. If `may_request_voice_call`\n   * is false, this field has no meaning.\n   */\n  uint64 next_voice_call_seconds = 7;\n\n  /**\n   * Indicates whether the caller may submit new verification codes now or at\n   * some time in the future. If true, the time a caller must wait before\n   * submitting a verification code is given in the `next_code_check_seconds`\n   * field. If false, simply waiting will not allow the caller to submit a\n   * verification code and the caller may need to perform some other action\n   * (like requesting delivery of a verification code) before checking a\n   * verification code.\n   */\n  bool may_check_code = 8;\n\n  /**\n   * The duration, in seconds, after which a caller will next be allowed to\n   * submit a verification code if `may_check_code` is true. If zero, a caller\n   * may submit a verification code immediately. If `may_check_code` is false,\n   * this field has no meaning.\n   */\n  uint64 next_code_check_seconds = 9;\n\n  /**\n   * The duration, in seconds, after which this session will expire.\n   */\n  uint64 expiration_seconds = 10;\n}\n\nmessage CreateRegistrationSessionError {\n  /**\n   * The type of error that prevented a session from being created.\n   */\n  CreateRegistrationSessionErrorType error_type = 1;\n\n  /**\n   * Indicates that this error may succeed if retried without modification after\n   * a delay indicated by `retry_after_seconds`. If false, callers should not\n   * retry the request without modification.\n   */\n  bool may_retry = 2;\n\n  /**\n   * If this error may be retried,, indicates the duration in seconds from the\n   * present after which the request may be retried without modification. This\n   * value has no meaning otherwise.\n   */\n  uint64 retry_after_seconds = 3;\n}\n\nenum CreateRegistrationSessionErrorType {\n  CREATE_REGISTRATION_SESSION_ERROR_TYPE_UNSPECIFIED = 0;\n\n  /**\n   * Indicates that a session could not be created because too many requests to\n   * create a session for the given phone number have been received in some\n   * window of time. Callers should wait and try again later.\n   */\n  CREATE_REGISTRATION_SESSION_ERROR_TYPE_RATE_LIMITED = 1;\n\n  /**\n   * Indicates that the provided phone number could not be parsed.\n   */\n  CREATE_REGISTRATION_SESSION_ERROR_TYPE_ILLEGAL_PHONE_NUMBER = 2;\n}\n\nmessage GetRegistrationSessionMetadataRequest {\n  /**\n   * The ID of the session for which to retrieve metadata.\n   */\n  bytes session_id = 1;\n}\n\nmessage GetRegistrationSessionMetadataResponse {\n  oneof response {\n    RegistrationSessionMetadata session_metadata = 1;\n    GetRegistrationSessionMetadataError error = 2;\n  }\n}\n\nmessage GetRegistrationSessionMetadataError {\n  GetRegistrationSessionMetadataErrorType error_type = 1;\n}\n\nenum GetRegistrationSessionMetadataErrorType {\n  GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_UNSPECIFIED = 0;\n\n  /**\n   * No session was found with the given identifier.\n   */\n  GET_REGISTRATION_SESSION_METADATA_ERROR_TYPE_NOT_FOUND = 1;\n}\n\nmessage SendVerificationCodeRequest {\n\n  reserved 1;\n\n  /**\n   * The message transport to use to send a verification code to the destination\n   * phone number.\n   */\n  MessageTransport transport = 2;\n\n  /**\n   * A prioritized list of languages accepted by the destination; should be\n   * provided in the same format as the value of an HTTP Accept-Language header.\n   */\n  string accept_language = 3;\n\n  /**\n   * The type of client requesting a verification code.\n   */\n  ClientType client_type = 4;\n\n  /**\n   * The ID of a session within which to send (or re-send) a verification code.\n   */\n  bytes session_id = 5;\n\n  /**\n   * If provided, always attempt to use the specified sender to send\n   * this message.\n   */\n  string sender_name = 6;\n}\n\nenum MessageTransport {\n  MESSAGE_TRANSPORT_UNSPECIFIED = 0;\n  MESSAGE_TRANSPORT_SMS = 1;\n  MESSAGE_TRANSPORT_VOICE = 2;\n}\n\nenum ClientType {\n  CLIENT_TYPE_UNSPECIFIED = 0;\n  CLIENT_TYPE_IOS = 1;\n  CLIENT_TYPE_ANDROID_WITH_FCM = 2;\n  CLIENT_TYPE_ANDROID_WITHOUT_FCM = 3;\n}\n\nmessage SendVerificationCodeResponse {\n  reserved 1;\n\n  /**\n   * Metadata for the named session. May be absent if the session could not be\n   * found or has expired.\n   */\n  RegistrationSessionMetadata session_metadata = 2;\n\n  /**\n   * If a code could not be sent, explains the underlying error. Will be absent\n   * if a code was sent successfully. Note that both an error and session\n   * metadata may be present in the same response because the session metadata\n   * may include information helpful for resolving the underlying error (i.e.\n   * \"next attempt\" times).\n   */\n  SendVerificationCodeError error = 3;\n}\n\nmessage SendVerificationCodeError {\n  /**\n   * The type of error that prevented a verification code from being sent.\n   */\n  SendVerificationCodeErrorType error_type = 1;\n\n  /**\n   * Indicates that this error may succeed if retried without modification after\n   * a delay indicated by `retry_after_seconds`. If false, callers should not\n   * retry the request without modification.\n   */\n  bool may_retry = 2;\n\n  /**\n   * If this error may be retried,, indicates the duration in seconds from the\n   * present after which the request may be retried without modification. This\n   * value has no meaning otherwise.\n   */\n  uint64 retry_after_seconds = 3;\n}\n\nenum SendVerificationCodeErrorType {\n  SEND_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0;\n\n  /**\n   * The sender received and understood the request to send a verification code,\n   * but declined to do so (i.e. due to rate limits or suspected fraud).\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_REJECTED = 1;\n\n  /**\n   * The sender could not process or would not accept some part of a request\n   * (e.g. a valid phone number that cannot receive SMS messages).\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_SENDER_ILLEGAL_ARGUMENT = 2;\n\n  /**\n   * A verification could could not be sent via the requested channel due to\n   * timing/rate restrictions. The response object containing this error should\n   * include session metadata that indicates when the next attempt is allowed.\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 3;\n\n  /**\n   * No session was found with the given ID.\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 4;\n\n  /**\n   * A new verification could could not be sent because the session has already\n   * been verified.\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_SESSION_ALREADY_VERIFIED = 5;\n\n  /**\n   * A verification code could not be sent via the requested transport because\n   * the destination phone number (or the sender) does not support the requested\n   * transport.\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_TRANSPORT_NOT_ALLOWED = 6;\n\n  /**\n   * The sender declined to send the verification code due to suspected fraud\n   */\n  SEND_VERIFICATION_CODE_ERROR_TYPE_SUSPECTED_FRAUD = 7;\n\n}\n\nmessage CheckVerificationCodeRequest {\n  /**\n   * The session ID returned when sending a verification code.\n   */\n  bytes session_id = 1;\n\n  /**\n   * The client-provided verification code.\n   */\n  string verification_code = 2;\n}\n\nmessage CheckVerificationCodeResponse {\n  reserved 1;\n\n  /**\n   * Metadata for the named session. May be absent if the session could not be\n   * found or has expired.\n   */\n  RegistrationSessionMetadata session_metadata = 2;\n\n  /**\n   * If a code could not be checked, explains the underlying error. Will be\n   * absent if no error occurred. Note that both an error and session\n   * metadata may be present in the same response because the session metadata\n   * may include information helpful for resolving the underlying error (i.e.\n   * \"next attempt\" times).\n   */\n  CheckVerificationCodeError error = 3;\n}\n\nmessage CheckVerificationCodeError {\n  /**\n   * The type of error that prevented a verification code from being checked.\n   */\n  CheckVerificationCodeErrorType error_type = 1;\n\n  /**\n   * Indicates that this error may succeed if retried without modification after\n   * a delay indicated by `retry_after_seconds`. If false, callers should not\n   * retry the request without modification.\n   */\n  bool may_retry = 2;\n\n  /**\n   * If this error may be retried,, indicates the duration in seconds from the\n   * present after which the request may be retried without modification. This\n   * value has no meaning otherwise.\n   */\n  uint64 retry_after_seconds = 3;\n}\n\nenum CheckVerificationCodeErrorType {\n  CHECK_VERIFICATION_CODE_ERROR_TYPE_UNSPECIFIED = 0;\n\n  /**\n   * The caller has attempted to submit a verification code even though no\n   * verification codes have been sent within the scope of this session. The\n   * caller must issue a \"send code\" request before trying again.\n   */\n  CHECK_VERIFICATION_CODE_ERROR_TYPE_NO_CODE_SENT = 1;\n\n  /**\n   * The caller has made too many guesses within some period of time. Callers\n   * should wait for the duration prescribed in the session metadata object\n   * elsewhere in the response before trying again.\n   */\n  CHECK_VERIFICATION_CODE_ERROR_TYPE_RATE_LIMITED = 2;\n\n  /**\n   * The session identified in this request could not be found (possibly due to\n   * session expiration).\n   */\n  CHECK_VERIFICATION_CODE_ERROR_TYPE_SESSION_NOT_FOUND = 3;\n\n  /**\n   * The session identified in this request is still active, but the most\n   * recently-sent code has expired. Callers should request a new code, then\n   * try again.\n   */\n  CHECK_VERIFICATION_CODE_ERROR_TYPE_ATTEMPT_EXPIRED = 4;\n}\n"
  },
  {
    "path": "service/src/main/proto/TextSecure.proto",
    "content": "/**\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\nsyntax = \"proto2\";\n\npackage textsecure;\n\noption java_package = \"org.whispersystems.textsecuregcm.entities\";\noption java_outer_classname = \"MessageProtos\";\n\nmessage Envelope {\n  enum Type {\n    reserved 2, 7;\n\n    UNKNOWN = 0;\n    CIPHERTEXT = 1;\n    PREKEY_BUNDLE = 3;\n    SERVER_DELIVERY_RECEIPT = 5;\n    UNIDENTIFIED_SENDER = 6;\n    PLAINTEXT_CONTENT = 8;  // for decryption error receipts\n  }\n\n  optional Type type = 1;\n  optional string source_service_id = 11;\n  optional uint32 source_device = 7;\n  optional uint64 client_timestamp = 5;\n  optional bytes content = 8; // Contains an encrypted Content\n  optional string server_guid = 9;\n  optional uint64 server_timestamp = 10;\n  optional bool ephemeral = 12; // indicates that the message should not be persisted if the recipient is offline\n  optional string destination_service_id = 13;\n  optional bool urgent = 14 [default=true];\n  optional string updated_pni = 15;\n  optional bool story = 16; // indicates that the content is a story.\n  optional bytes report_spam_token = 17; // token sent when reporting spam\n  optional bytes shared_mrm_key = 18; // indicates content should be fetched from multi-recipient message datastore\n  optional bytes source_service_id_binary = 19; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)\n  optional bytes destination_service_id_binary = 20; // service ID binary (i.e. 16 byte UUID for ACI, 1 byte prefix + 16 byte UUID for PNI)\n  optional bytes server_guid_binary = 21; // 16-byte UUID\n  optional bytes updated_pni_binary = 22; // 16-byte UUID\n  // next: 22\n}\n\nmessage ProvisioningAddress {\n  optional string address = 1;\n}\n\nmessage ServerCertificate {\n  message Certificate {\n    optional uint32 id = 1;\n    optional bytes key = 2;\n  }\n\n  optional bytes certificate = 1;\n  optional bytes signature = 2;\n}\n\nmessage SenderCertificate {\n  message Certificate {\n    reserved 6;\n\n    optional string sender_e164 = 1;\n    optional bytes sender_uuid_= 7;\n    optional uint32 sender_device = 2;\n    optional fixed64 expires = 3;\n    optional bytes identity_key = 4;\n    oneof signer {\n      ServerCertificate signer_certificate = 5;\n      uint32 signer_id = 8;\n    }\n    // next: 9\n  }\n\n  optional bytes certificate = 1;\n  optional bytes signature = 2;\n}\n"
  },
  {
    "path": "service/src/main/proto/WebSocketConnectionEvent.proto",
    "content": "/**\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\nsyntax = \"proto3\";\n\npackage org.signal.chat.presence;\n\noption java_package = \"org.whispersystems.textsecuregcm.push\";\noption java_multiple_files = true;\n\nmessage ClientEvent {\n  reserved 3;\n  oneof event {\n    NewMessageAvailableEvent new_message_available = 1;\n    ClientConnectedEvent client_connected = 2;\n    MessagesPersistedEvent messages_persisted = 4;\n  }\n}\n\n/**\n * Indicates that a new message is available for the client to retrieve.\n */\nmessage NewMessageAvailableEvent {\n}\n\n/**\n * Indicates that a client has connected to the presence system.\n */\nmessage ClientConnectedEvent {\n  bytes server_id = 1;\n}\n\n/**\n * Indicates that messages for the client have been persisted from short-term\n * storage to long-term storage.\n */\nmessage MessagesPersistedEvent {\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/README.md",
    "content": "#  Chat gRPC API \nEventually, all chat protocol endpoints will be available via gRPC.\n\nClients may provide headers for gRPC requests via [gRPC metadata](https://grpc.io/docs/guides/metadata/) which translates directly to HTTP/2 headers.\n\n- Clients should provide a `User-Agent` header on all gRPC requests.  \n- Clients may provide an `Accept-Language` on any gRPC requests.\n\n## Errors\n\nIn the gRPC protocol all errors are at the request level. That is, errors are returned in response to individual requests and do not impact other H2 streams on the same connection nor terminate the connection.\n\nFor errors that may be returned by any RPCs, the chat server will return the well-defined gRPC status codes returned as part of every RPC call. For errors that are specific to a particular RPC, the error must be encoded in the service proto definition and will be returned with a `Status` of `OK`.\n\nStatus errors include additional metadata as described in [AIP-193 (google's richer error model)](https://google.aip.dev/193#error_model). Every `Status != OK` response returned by the chat server's application layer will include a `Grpc-Status-Details-Bin` response trailer with a `google.rpc.Status` proto.\n\nEach `google.rpc.Status` must have a status matching the top-level status on the gRPC response. Additionally, a single `ErrorInfo` must always be present in the details field of the `Status`. The `ErrorInfo` must contain a `domain` field and a `reason` field.\n\nThe `domain` for a status error generated by the chat server will always be `grpc.chat.signal.org`\n\nThe server may set the `reason` to match the enum string for the `Status.Code` if there is no need to further distinguish a code. Clients should inspect the `reason`, not the `status`, for automated error handling.\n\nThe chat server may return the following errors from any RPC\n\n| Status Code | Reason | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |\n| :---- | :---- |:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n| `INVALID_ARGUMENT` | `UPGRADE_REQUIRED` | The client version provided in the `User-Agent` is no longer supported. The client must upgrade to use the service.                                                                                                                                                                                                                                                                                                                                                                                             |\n| `INVALID_ARGUMENT` | `CONSTRAINT_VIOLATED` | The RPC argument violated a constraint that was annotated or documented in the service definition. It is always possible to check this constraint without communicating with the chat server. This always represents a client bug or out of date client. <br><br> The `details` may include a [`BadRequest` message](https://github.com/googleapis/googleapis/blob/8c06c1e04ae562f49f411357577c700e9142f33c/google/rpc/error_details.proto#L236) that indicates additional information about the invalid field. |\n| `INVALID_ARGUMENT` | `BAD_AUTHENTICATION` | The request has incorrectly set authentication credentials for the RPC. This represents a client bug where the authorization mode is not correct for the RPC. For example, <br><br> The RPC was for an anonymous service, but included an Authentication header in the RPC metadata. <br><br>The RPC should only be made by the primary device, but the request had linked device credentials.                                                                                                                  |\n| `UNAUTHENTICATED` | `INVALID_CREDENTIALS` | The account credentials provided in the authorization header are not valid.                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| `RESOURCE_EXHAUSTED` | `RESOURCE_EXHAUSTED` | A server-side resource was exhausted. The `details` field may include a [`RetryInfo` message](https://github.com/googleapis/googleapis/blob/8c06c1e04ae562f49f411357577c700e9142f33c/google/rpc/error_details.proto#L92) that includes the amount of time in seconds the client should wait before retrying the request. <br><br> If a `RetryInfo` is present, the client must wait the indicated time before retrying the request. If absent, the client should retry with an exponential backoff.             |\n| `UNAVAILABLE` | `UNAVAILABLE` | There was an internal error processing the RPC. The client should retry the request with exponential backoff.                                                                                                                                                                                                                                                                                                                                                                                                   |\n\n### Logging Errors\n\nWhen logging error responses, clients may always log the status code, domain, and reason.\n\nFor errors with domain `grpc.chat.signal.org` and reason `CONSTRAINT_VIOLATED`, clients should check the `details` field for a `BadRequest` proto. If present, they should log the `field` of each violation in `field_violations`.\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/account.proto",
    "content": "syntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.account;\n\nimport \"org/signal/chat/common.proto\";\nimport \"org/signal/chat/errors.proto\";\nimport \"org/signal/chat/require.proto\";\n\n// Provides methods for working with Signal accounts.\nservice Accounts {\n  // Returns basic identifiers for the authenticated account.\n  rpc GetAccountIdentity(GetAccountIdentityRequest) returns (GetAccountIdentityResponse) {}\n\n  // Deletes the authenticated account, purging all associated data in the\n  // process.\n  rpc DeleteAccount(DeleteAccountRequest) returns (DeleteAccountResponse) {}\n\n  // Sets the registration lock secret for the authenticated account. To remove\n  // a registration lock, please use `ClearRegistrationLock`.\n  rpc SetRegistrationLock(SetRegistrationLockRequest) returns (SetRegistrationLockResponse) {}\n\n  // Removes any registration lock credentials from the authenticated account.\n  rpc ClearRegistrationLock(ClearRegistrationLockRequest) returns (ClearRegistrationLockResponse) {}\n\n  // Attempts to reserve one of multiple given username hashes. Reserved\n  // usernames may be claimed later via `ConfirmUsernameHash`.\n  rpc ReserveUsernameHash(ReserveUsernameHashRequest) returns (ReserveUsernameHashResponse) {}\n\n  // Sets the username hash/encrypted username to a previously-reserved value\n  // (see `ReserveUsernameHash`).\n  rpc ConfirmUsernameHash(ConfirmUsernameHashRequest) returns (ConfirmUsernameHashResponse) {}\n\n  // Clears the current username hash, ciphertext, and link for the\n  // authenticated user.\n  rpc DeleteUsernameHash(DeleteUsernameHashRequest) returns (DeleteUsernameHashResponse) {}\n\n  // Associates the given username ciphertext with the account, replacing any\n  // previously stored ciphertext. A new link handle will optionally be created,\n  // and the link handle to use will be returned in any event.\n  rpc SetUsernameLink(SetUsernameLinkRequest) returns (SetUsernameLinkResponse) {}\n\n  // Clears any username link associated with the authenticated account.\n  rpc DeleteUsernameLink(DeleteUsernameLinkRequest) returns (DeleteUsernameLinkResponse) {}\n\n  // Configures \"unidentified access\" keys and preferences for the authenticated\n  // account. Other users permitted to interact with this account anonymously\n  // may take actions like fetching pre-keys and profiles for this account or\n  // sending sealed-sender messages without providing identifying credentials.\n  rpc ConfigureUnidentifiedAccess(ConfigureUnidentifiedAccessRequest) returns (ConfigureUnidentifiedAccessResponse) {}\n\n  // Sets whether the authenticated account may be discovered by phone number\n  // via the Contact Discovery Service (CDS).\n  rpc SetDiscoverableByPhoneNumber(SetDiscoverableByPhoneNumberRequest) returns (SetDiscoverableByPhoneNumberResponse) {}\n\n  // Sets the registration recovery password for the authenticated account.\n  rpc SetRegistrationRecoveryPassword(SetRegistrationRecoveryPasswordRequest) returns (SetRegistrationRecoveryPasswordResponse) {}\n}\n\n// Provides methods for looking up Signal accounts. Callers must not provide\n// identifying credentials when calling methods in this service.\nservice AccountsAnonymous {\n  // Checks whether an account with the given service identifier exists.\n  rpc CheckAccountExistence(CheckAccountExistenceRequest) returns (CheckAccountExistenceResponse) {}\n\n  // Finds the service identifier of the account associated with the given\n  // username hash.\n  rpc LookupUsernameHash(LookupUsernameHashRequest) returns (LookupUsernameHashResponse) {}\n\n  // Finds the encrypted username identified by a given username link handle.\n  rpc LookupUsernameLink(LookupUsernameLinkRequest) returns (LookupUsernameLinkResponse) {}\n}\n\nmessage GetAccountIdentityRequest {\n}\n\nmessage GetAccountIdentityResponse {\n  // A set of account identifiers for the authenticated account.\n  common.AccountIdentifiers account_identifiers = 1;\n}\n\nmessage DeleteAccountRequest {\n}\n\nmessage DeleteAccountResponse {\n}\n\nmessage SetRegistrationLockRequest {\n  // The new registration lock secret for the authenticated account.\n  bytes registration_lock = 1 [(require.exactlySize) = 32];\n}\n\nmessage SetRegistrationLockResponse {\n}\n\nmessage ClearRegistrationLockRequest {\n}\n\nmessage ClearRegistrationLockResponse {\n}\n\nmessage ReserveUsernameHashRequest {\n  // A prioritized list of username hashes to attempt to reserve. Each hash must\n  // be exactly 32 bytes.\n  repeated bytes username_hashes = 1 [(require.size) = {min: 1, max: 20}];\n}\n\nmessage UsernameNotAvailable {}\n\nmessage ReserveUsernameHashResponse {\n  oneof response {\n    // The first username hash that was available (and actually reserved).\n    bytes username_hash = 1;\n\n    // Indicates that, of all of the candidate hashes provided, none were\n    // available. Callers may generate a new set of hashes and and retry.\n    UsernameNotAvailable username_not_available = 2;\n  }\n}\n\nmessage ConfirmUsernameHashRequest {\n  // The username hash to claim for the authenticated account.\n  bytes username_hash = 1 [(require.exactlySize) = 32];\n\n  // A zero-knowledge proof that the given username hash was generated by the\n  // Signal username algorithm.\n  bytes zk_proof = 2 [(require.nonEmpty) = true];\n\n  // The ciphertext of the chosen username for use in public-facing contexts\n  // (e.g. links and QR codes).\n  bytes username_ciphertext = 3 [(require.size) = {min: 1, max: 128}];\n}\n\nmessage ConfirmUsernameHashResponse {\n  message ConfirmedUsernameHash {\n    // The newly-confirmed username hash.\n    bytes username_hash = 1;\n\n    // The server-generated username link handle for the newly-confirmed username.\n    bytes username_link_handle = 2;\n  }\n\n  oneof response {\n    // The details of the successfully confirmed username.\n    ConfirmedUsernameHash confirmed_username_hash = 1;\n\n    // The provided hash was not reserved for the account.\n    errors.FailedPrecondition reservation_not_found = 2;\n\n    // The reservation has lapsed and the requested username has been claimed by\n    // another caller.\n    UsernameNotAvailable username_not_available =  3;\n  }\n}\n\nmessage DeleteUsernameHashRequest {\n}\n\nmessage DeleteUsernameHashResponse {\n}\n\nmessage SetUsernameLinkRequest {\n  // The username ciphertext for which to generate a new link handle.\n  bytes username_ciphertext = 1 [(require.size) = {min: 1, max: 128}];\n\n  // If true and the account already had an encrypted username stored, the\n  // existing link handle will be reused. Otherwise a new link handle will be\n  // created.\n  bool keep_link_handle = 2;\n}\n\n\nmessage SetUsernameLinkResponse {\n  oneof response {\n    // A new link handle for the given username ciphertext.\n    bytes username_link_handle = 1;\n\n    // The authenticated account did not have a username set.\n    errors.FailedPrecondition no_username_set = 2;\n\n  }\n}\n\nmessage DeleteUsernameLinkRequest {\n}\n\nmessage DeleteUsernameLinkResponse {\n}\n\nmessage ConfigureUnidentifiedAccessRequest {\n  // The key that other users must provide to interact with this account\n  // anonymously (i.e. to retrieve keys or profiles or to send messages) unless\n  // unrestricted unidentified access is permitted. Must be present if\n  // unrestricted unidentified access is not allowed.\n  bytes unidentified_access_key = 1;\n\n  // If `true`, any user may interact with this account anonymously without\n  // providing an unidentified access key. Otherwise, users must provide the\n  // given unidentified access key to interact with this account anonymously.\n  bool allow_unrestricted_unidentified_access = 2;\n}\n\nmessage ConfigureUnidentifiedAccessResponse {\n}\n\nmessage SetDiscoverableByPhoneNumberRequest {\n  // If true, the authenticated account may be discovered by phone number via\n  // the Contact Discovery Service (CDS). Otherwise, other users must discover\n  // this account by other means (i.e. by username).\n  bool discoverable_by_phone_number = 1;\n}\n\nmessage SetDiscoverableByPhoneNumberResponse {\n}\n\nmessage SetRegistrationRecoveryPasswordRequest {\n  // The new registration recovery password for the authenticated account.\n  bytes registration_recovery_password = 1 [(require.exactlySize) = 32];\n}\n\nmessage SetRegistrationRecoveryPasswordResponse {\n}\n\nmessage CheckAccountExistenceRequest {\n  // The service identifier of an account that may or may not exist.\n  common.ServiceIdentifier service_identifier = 1;\n}\n\nmessage CheckAccountExistenceResponse {\n  // True if an account exists with the given service identifier or false if no\n  // account was found.\n  bool account_exists = 1;\n}\n\nmessage LookupUsernameHashRequest {\n  // A 32-byte username hash for which to find an account.\n  bytes username_hash = 1 [(require.exactlySize) = 32];\n}\n\nmessage LookupUsernameHashResponse {\n  oneof response {\n    // The service identifier associated with the provided username hash.\n    common.ServiceIdentifier service_identifier = 1;\n\n    // No account was found for the provided username hash.\n    errors.NotFound not_found = 2;\n  }\n}\n\nmessage LookupUsernameLinkRequest {\n  // The link handle for which to find an encrypted username. Link handles are\n  // 16-byte representations of UUIDs.\n  bytes username_link_handle = 1 [(require.exactlySize) = 16];\n}\n\nmessage LookupUsernameLinkResponse {\n  oneof response {\n    // The ciphertext of the username identified by the provided link handle.\n    bytes username_ciphertext = 1;\n\n\n    // No username was found for the provided link handle.\n    errors.NotFound not_found = 2;\n  }\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/attachments.proto",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.attachments;\n\nimport \"org/signal/chat/common.proto\";\nimport \"org/signal/chat/require.proto\";\n\nservice Attachments {\n  option (require.auth) = AUTH_ONLY_AUTHENTICATED;\n\n  // Retrieve an upload form that can be used to perform a resumable upload\n  rpc GetUploadForm(GetUploadFormRequest) returns (GetUploadFormResponse) {}\n}\n\nmessage GetUploadFormRequest {}\nmessage GetUploadFormResponse {\n  common.UploadForm upload_form = 1;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/backups.proto",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.backup;\n\nimport \"google/protobuf/empty.proto\";\nimport \"org/signal/chat/common.proto\";\nimport \"org/signal/chat/errors.proto\";\nimport \"org/signal/chat/require.proto\";\nimport \"org/signal/chat/tag.proto\";\n\n// Service for backup operations that require account authentication.\n//\n// Most actual backup operations operate on the backup-id and cannot be linked\n// to the caller's account, but setting up anonymous credentials and changing\n// backup tier requires account authentication.\nservice Backups {\n  option (require.auth) = AUTH_ONLY_AUTHENTICATED;\n\n  // Set (blinded) backup-id(s) for the account.\n  //\n  // Each account may have a single active backup-id for each credential type\n  // that can be used to store and retrieve backups. Once the backup-id is set,\n  // BackupAuthCredentials can be generated using GetBackupAuthCredentials.\n  //\n  // The blinded backup-id and the key-pair used to blind it must be derived\n  // from a recoverable secret.\n  //\n  // At least one of the credential types must be set on the request.\n  // Only the primary device can set a blinded backup-id.\n  rpc SetBackupId(SetBackupIdRequest) returns (SetBackupIdResponse) {}\n\n  // Redeem a receipt acquired from /v1/subscription/{subscriberId}/receipt_credentials\n  // to mark the account as eligible for the paid backup tier.\n  //\n  // After successful redemption, subsequent requests to\n  // GetBackupAuthCredentials will return credentials with the level on the\n  // provided receipt until the expiration time on the receipt.\n  rpc RedeemReceipt(RedeemReceiptRequest) returns (RedeemReceiptResponse) {}\n\n  // After setting a blinded backup-id with PUT /v1/archives/, this fetches\n  // credentials that can be used to perform operations against that backup-id.\n  // Clients may (and should) request up to 7 days of credentials at a time.\n  //\n  // The redemption_start and redemption_end seconds must be UTC day aligned, and\n  // must not span more than 7 days.\n  //\n  // Each credential contains a receipt level which indicates the backup level\n  // the credential is good for. If the account has paid backup access that\n  // expires at some point in the provided redemption window, credentials with\n  // redemption times after the expiration may be on a lower backup level.\n  //\n  // Clients must validate the receipt level on the credential matches a known\n  // receipt level before using it.\n  rpc GetBackupAuthCredentials(GetBackupAuthCredentialsRequest) returns (GetBackupAuthCredentialsResponse) {}\n}\n\nmessage SetBackupIdRequest {\n  // A BackupAuthCredentialRequest containing a blinded encrypted backup-id,\n  // encoded in standard padded base64. This backup-id should be used for\n  // message backups only, and must have the message backup type set on the\n  // credential. If absent, the message credential request will not be updated.\n  bytes messages_backup_auth_credential_request = 1;\n\n  // A BackupAuthCredentialRequest containing a blinded encrypted backup-id,\n  // encoded in standard padded base64. This backup-id should be used for\n  // media only, and must have the media type set on the credential. If absent,\n  // the media credential request will not be updated.\n  bytes media_backup_auth_credential_request = 2;\n}\n\nmessage SetBackupIdResponse {}\n\n\nmessage RedeemReceiptRequest {\n  // Presentation for a previously acquired receipt, serialized with libsignal\n  bytes presentation = 1;\n}\n\nmessage RedeemReceiptResponse {\n  oneof outcome {\n    // The receipt was successfully redeemed\n    google.protobuf.Empty success = 1;\n\n    // The target account does not have a backup-id commitment\n    errors.FailedPrecondition account_missing_commitment = 2 [(tag.reason) = \"account_missing_commitment\"];\n\n    // The provided receipt presentation was malformed or expired\n    errors.FailedPrecondition invalid_receipt = 3 [(tag.reason) = \"invalid_receipt\"];\n  }\n}\n\nmessage GetBackupAuthCredentialsRequest {\n  // The redemption time for the first credential. This must be a day-aligned\n  // seconds since epoch in UTC.\n  int64 redemption_start = 1 [(require.range).min = 1];\n\n  // The redemption time for the last credential. This must be a day-aligned\n  // seconds since epoch in UTC. The span between redemptionStart and\n  // redemptionEnd must not exceed 7 days.\n  int64 redemption_stop = 2 [(require.range).min = 1];\n}\n\nmessage GetBackupAuthCredentialsResponse {\n  message Credentials {\n    // The requested message backup ZkCredentials indexed by the start of their\n    // validity period. The smallest key should be for the requested\n    // redemption_start, the largest for the requested redemption_end.\n    map<int64, common.ZkCredential> message_credentials = 1;\n\n    // The requested media backup ZkCredentials indexed by the start of their\n    // validity period. The smallest key should be for the requested\n    // redemption_start, the largest for the requested redemption_end.\n    map<int64, common.ZkCredential> media_credentials = 2;\n  }\n\n  // The requested credentials. If absent, there was no existing blinded\n  // backup id associated with the provided account.\n  Credentials credentials = 1;\n}\n\n// Service for backup operations with anonymous credentials\n//\n// This service never requires account authentication. It instead requires a\n// backup-id authenticated with an anonymous credential that cannot be linked\n// to the account.\n//\n// To register an anonymous credential:\n//   1. Set a backup-id on the authenticated channel via Backups::SetBackupId\n//   2. Retrieve BackupAuthCredentials via Backups::GetBackupAuthCredentials\n//   3. Generate a key pair and set the public key via\n//      BackupsAnonymous::SetPublicKey\n//\n// Unless otherwise noted, requests for this service require a\n// SignedPresentation, which includes:\n//   - a presentation generated from a BackupAuthCredential issued by\n//     GetBackupAuthCredentials\n//   - a signature of that presentation using the private key of a key pair\n//     previously set with SetPublicKey.\nservice BackupsAnonymous {\n  option (require.auth) = AUTH_ONLY_ANONYMOUS;\n\n  // Retrieve credentials used to read objects stored on the backup cdn\n  rpc GetCdnCredentials(GetCdnCredentialsRequest) returns (GetCdnCredentialsResponse) {}\n\n  // Retrieve credentials used to interact with the SecureValueRecoveryB service\n  rpc GetSvrBCredentials(GetSvrBCredentialsRequest) returns (GetSvrBCredentialsResponse) {}\n\n  // Retrieve information about the currently stored message backup\n  rpc GetMessageBackupInfo(GetBackupInfoRequest) returns (GetMessageBackupInfoResponse) {}\n\n  // Retrieve information about the currently stored media backup\n  rpc GetMediaBackupInfo(GetBackupInfoRequest) returns (GetMediaBackupInfoResponse) {}\n\n  // Permanently set the public key of an ED25519 key-pair for the backup-id.\n  // All requests (including this one!) must sign their BackupAuthCredential\n  // presentations with the private key corresponding to the provided public key.\n  rpc SetPublicKey(SetPublicKeyRequest) returns (SetPublicKeyResponse) {}\n\n  // Refresh the backup, indicating that the backup is still active. Clients\n  // must periodically upload new backups or perform a refresh. If a backup has\n  // not been active for 30 days, it may be deleted.\n  rpc Refresh(RefreshRequest) returns (RefreshResponse) {}\n\n  // Retrieve an upload form that can be used to perform a resumable upload\n  rpc GetUploadForm(GetUploadFormRequest) returns (GetUploadFormResponse) {}\n\n  // Copy and re-encrypt media from the attachments cdn into the backup cdn.\n  // The original, already encrypted, attachments will be encrypted with the\n  // provided key material before being copied.\n  //\n  // The copy operation is not atomic and responses will be returned as copy\n  // operations complete with detailed information about the outcome. If an\n  // error is encountered, not all requests may be reflected in the responses.\n  //\n  // On retries, a particular destination media id must not be reused with a\n  // different source media id or different encryption parameters.\n  rpc CopyMedia(CopyMediaRequest) returns (stream CopyMediaResponse) {}\n\n  // Retrieve a page of media objects stored for this backup-id. A client may\n  // have previously stored media objects that are no longer referenced in their\n  // current backup. To reclaim storage space used by these orphaned objects,\n  // perform a list operation and remove any unreferenced media objects\n  // via DeleteMedia.\n  rpc ListMedia(ListMediaRequest) returns (ListMediaResponse) {}\n\n  // Delete media objects stored with this backup-id. Streams the locations of\n  // media items back when the item has successfully been removed.\n  rpc DeleteMedia(DeleteMediaRequest) returns (stream DeleteMediaResponse) {}\n\n  // Delete all backup metadata, objects, and stored public key. To use\n  // backups again, a public key must be resupplied.\n  rpc DeleteAll(DeleteAllRequest) returns (DeleteAllResponse) {}\n}\n\nmessage SignedPresentation {\n  // Presentation of a BackupAuthCredential previously retrieved from\n  // GetBackupAuthCredentials on the authenticated channel\n  bytes presentation = 1 [(require.nonEmpty) = true];\n\n  // The presentation signed with the private key corresponding to the public\n  // key set with SetPublicKey\n  bytes presentation_signature = 2 [(require.nonEmpty) = true];\n}\n\nmessage SetPublicKeyRequest {\n  SignedPresentation signed_presentation = 1;\n\n  // The public key, serialized in libsignal's elliptic-curve public key format.\n  bytes public_key = 2 [(require.nonEmpty) = true];\n}\n\nmessage SetPublicKeyResponse {\n  oneof outcome {\n    // The public key was successfully set\n    google.protobuf.Empty success = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    //\n    // This may also be returned if there was an existing public key and the\n    // provided public key did not match.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage GetCdnCredentialsRequest {\n  SignedPresentation signed_presentation = 1;\n  uint32 cdn = 2;\n}\nmessage GetCdnCredentialsResponse {\n  message CdnCredentials {\n    map<string, string> headers = 1;\n  }\n  oneof outcome {\n    // Headers to include with requests to the read from the backup CDN. Includes\n    // time limited read-only credentials.\n    CdnCredentials cdn_credentials = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage GetSvrBCredentialsRequest {\n  SignedPresentation signed_presentation = 1;\n}\n\nmessage GetSvrBCredentialsResponse {\n  message SvrBCredentials {\n    // A username that can be presented to authenticate with SVRB\n    string username = 1;\n\n    // A password that can be presented to authenticate with SVRB\n    string password = 2;\n  }\n\n  oneof outcome {\n    SvrBCredentials svrb_credentials = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage GetBackupInfoRequest {\n  SignedPresentation signed_presentation = 1;\n}\nmessage GetMessageBackupInfoResponse {\n  message MessageBackupInfo {\n    // The base directory of your backup data on the cdn. The message backup can\n    // be found in the returned cdn at /backup_dir/backup_name\n    string backup_dir = 1;\n\n    // The CDN type where the message backup is stored. Media may be stored\n    // elsewhere.\n    uint32 cdn = 2;\n\n    // The location of the message backup on the cdn. If a backup was previously\n    // uploaded and unexpired, it can be found at /backup_dir/backup_name.\n    string backup_name = 3;\n  }\n\n  oneof outcome {\n    MessageBackupInfo backup_info = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\nmessage GetMediaBackupInfoResponse {\n  message MediaBackupInfo {\n    // The base directory of your backup data on the cdn.\n    string backup_dir = 1;\n\n    // The prefix path component for media objects on a cdn. Stored media for a\n    // media_id can be found at /backup_dir/media_dir/media_id, where the media_id\n    // is encoded in unpadded url-safe base64.\n    string media_dir = 2;\n\n    // The amount of space used to store media\n    uint64 used_space = 3;\n  }\n\n  oneof outcome {\n    MediaBackupInfo backup_info = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage RefreshRequest {\n  SignedPresentation signed_presentation = 1;\n}\nmessage RefreshResponse {\n  oneof outcome {\n    // The backup was successfully refreshed\n    google.protobuf.Empty success = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage GetUploadFormRequest {\n  SignedPresentation signed_presentation = 1;\n\n  message MessagesUploadType {\n    uint64 upload_length = 1;\n  }\n  message MediaUploadType {}\n  oneof upload_type {\n    // Retrieve an upload form that can be used to perform a resumable upload of\n    // a message backup. The finished upload will be available on the backup cdn.\n    MessagesUploadType messages = 2;\n\n    // Retrieve an upload form for a temporary location that can be used to\n    // perform a resumable upload of an attachment. After uploading, the\n    // attachment can be copied into the backup via CopyMedia.\n    //\n    // Behaves identically to the account authenticated version at /attachments.\n    MediaUploadType media = 3;\n  }\n}\nmessage GetUploadFormResponse {\n  oneof outcome {\n    common.UploadForm upload_form = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n\n    // The request size was larger than the maximum supported upload size. The\n    // maximum upload size is subject to change.\n    errors.FailedPrecondition exceeds_max_upload_length = 3 [(tag.reason) = \"oversize_upload\"];\n  }\n}\n\nmessage CopyMediaItem {\n  // The attachment cdn of the object to copy into the backup\n  uint32 source_attachment_cdn = 1 [(require.range).min = 1, (require.range).max = 3];\n\n  // The attachment key of the object to copy into the backup\n  string source_key = 2 [(require.nonEmpty) = true];\n\n  // The length of the source attachment before the encryption applied by the\n  // copy operation\n  uint32 object_length = 3;\n\n  // media_id to copy on to the backup CDN\n  bytes media_id = 4 [(require.exactlySize) = 15];\n\n  // A 32-byte key for the MAC\n  bytes hmac_key = 5 [(require.exactlySize) = 32];\n\n  // A 32-byte encryption key for AES\n  bytes encryption_key = 6 [(require.exactlySize) = 32];\n}\n\nmessage CopyMediaRequest {\n  SignedPresentation signed_presentation = 1;\n\n  // Items to copy\n  repeated CopyMediaItem items = 2 [(require.size) = {min: 1, max: 1000}];\n}\n\nmessage CopyMediaResponse {\n  message SourceNotFound {}\n  message WrongSourceLength {}\n  message OutOfSpace {}\n  message CopySuccess {\n    // The backup cdn where this media object is stored\n    uint32 cdn = 1;\n  }\n\n  // The 15-byte media_id from the corresponding CopyMediaItem in the request\n  bytes media_id = 1;\n\n  oneof outcome {\n    // The media item was successfully copied into the backup\n    CopySuccess success = 2;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 3 [(tag.reason) = \"failed_authentication\"];\n\n    // The source object was not found\n    SourceNotFound source_not_found = 4;\n\n    // The provided object length was incorrect\n    WrongSourceLength wrong_source_length = 5;\n\n    // All media capacity has been consumed. Free some space to continue.\n    OutOfSpace out_of_space = 6;\n  }\n}\n\nmessage ListMediaRequest {\n  SignedPresentation signed_presentation = 1;\n\n  // A cursor returned by a previous call to ListMedia, absent on the first call\n  optional string cursor = 2;\n\n  // If provided, the maximum number of entries to return in a page\n  uint32 limit = 3 [(require.range) = {min: 1, max: 10000}];\n}\nmessage ListMediaResponse {\n  message ListEntry {\n    // The backup cdn where this media object is stored\n    uint32 cdn = 1;\n    // The media_id of the object\n    bytes media_id = 2;\n    // The length of the object in bytes\n    uint64 length = 3;\n  }\n\n  message ListResult {\n\n    // A page of media objects stored for this backup ID\n    repeated ListEntry page = 1;\n\n    // The base directory of the backup data on the cdn. The stored media can be\n    // found at /backup_dir/media_dir/media_id, where the media_id is encoded with\n    // unpadded url-safe base64.\n    string backup_dir = 2;\n\n    // The prefix path component for the media objects. The stored media for\n    // media_id can be found at /backup_dir/media_dir/media_id, where the media_id\n    // is encoded with unpadded url-safe base64.\n    string media_dir = 3;\n\n    // If set, the cursor value to pass to the next list request to continue\n    // listing. If absent, all objects have been listed\n    optional string cursor = 4;\n  }\n\n  oneof outcome {\n    ListResult list_result = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage DeleteAllRequest {\n  SignedPresentation signed_presentation = 1;\n}\nmessage DeleteAllResponse {\n  oneof outcome {\n    // The backup was successfully scheduled for deletion\n    google.protobuf.Empty success = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n\nmessage DeleteMediaItem {\n  // The backup cdn where this media object is stored\n  uint32 cdn = 1;\n\n  // The media_id of the object to delete\n  bytes media_id = 2 [(require.exactlySize) = 15];\n}\n\nmessage DeleteMediaRequest {\n  SignedPresentation signed_presentation = 1;\n\n  repeated DeleteMediaItem items = 2 [(require.size) = {min: 1, max: 1000}];\n}\n\nmessage DeleteMediaResponse {\n  oneof outcome {\n    DeleteMediaItem deleted_item = 1;\n\n    // The provided backup auth credential presentation could not be\n    // authenticated. Either, the presentation could not be verified, or\n    // the public key signature was invalid, or there is no backup associated\n    // with the backup-id in the presentation.\n    errors.FailedZkAuthentication failed_authentication = 2 [(tag.reason) = \"failed_authentication\"];\n  }\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/call_quality.proto",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.calling.quality;\n\n// Provides methods for submitting call quality surveys\nservice CallQuality {\n\n  // Submits a call quality survey response.\n  rpc SubmitCallQualitySurvey(SubmitCallQualitySurveyRequest) returns (SubmitCallQualitySurveyResponse) {}\n}\n\nmessage SubmitCallQualitySurveyRequest {\n  // Indicates whether the caller was generally satisfied with the quality of\n  // the call\n  bool user_satisfied = 1;\n\n  // A list of call quality issues selected by the caller\n  repeated string call_quality_issues = 2;\n\n  // A free-form description of any additional issues as written by the caller\n  optional string additional_issues_description = 3;\n\n  // A URL for a set of debug logs associated with the call if the caller chose\n  // to submit debug logs\n  optional string debug_log_url = 4;\n\n  // The time at which the call started in milliseconds since the epoch\n  int64 start_timestamp = 5;\n\n  // The time at which the call ended in milliseconds since the epoch\n  int64 end_timestamp = 6;\n\n  // The type of call; note that direct voice calls can become video calls and\n  // vice versa, and this field indicates which mode was selected at call\n  // initiation time. At the time of writing, expected call types are\n  // \"direct_voice\", \"direct_video\", \"group\", and \"call_link\".\n  string call_type = 7;\n\n  // Indicates whether the call completed without error or if it terminated\n  // abnormally\n  bool success = 8;\n\n  // A client-defined, but human-readable reason for call termination\n  string call_end_reason = 9;\n\n  // The median round-trip time, measured in milliseconds, for STUN/ICE packets\n  // (i.e. connection maintenance and establishment)\n  optional float connection_rtt_median = 10;\n\n  // The median round-trip time, measured in milliseconds, for RTP/RTCP packets\n  // for audio streams\n  optional float audio_rtt_median = 11;\n\n  // The median round-trip time, measured in milliseconds, for RTP/RTCP packets\n  // for video streams\n  optional float video_rtt_median = 12;\n\n  // The median jitter for audio streams, measured in milliseconds, for the\n  // duration of the call as measured by the client submitting the survey\n  optional float audio_recv_jitter_median = 13;\n\n  // The median jitter for video streams, measured in milliseconds, for the\n  // duration of the call as measured by the client submitting the survey\n  optional float video_recv_jitter_median = 14;\n\n  // The median jitter for audio streams, measured in milliseconds, for the\n  // duration of the call as measured by the remote endpoint in the call (either\n  // the peer of the client submitting the survey in a direct call or the SFU in\n  // a group call)\n  optional float audio_send_jitter_median = 15;\n\n  // The median jitter for video streams, measured in milliseconds, for the\n  // duration of the call as measured by the remote endpoint in the call (either\n  // the peer of the client submitting the survey in a direct call or the SFU in\n  // a group call)\n  optional float video_send_jitter_median = 16;\n\n  // The fraction of audio packets lost over the duration of the call as\n  // measured by the client submitting the survey\n  optional float audio_recv_packet_loss_fraction = 17;\n\n  // The fraction of video packets lost over the duration of the call as\n  // measured by the client submitting the survey\n  optional float video_recv_packet_loss_fraction = 18;\n\n  // The fraction of audio packets lost over the duration of the call as\n  // measured by the remote endpoint in the call (either the peer of the client\n  // submitting the survey in a direct call or the SFU in a group call)\n  optional float audio_send_packet_loss_fraction = 19;\n\n  // The fraction of video packets lost over the duration of the call as\n  // measured by the remote endpoint in the call (either the peer of the client\n  // submitting the survey in a direct call or the SFU in a group call)\n  optional float video_send_packet_loss_fraction = 20;\n\n  // Machine-generated telemetry from the call; this is a serialized protobuf\n  // entity generated (and, critically, explained to the user!) by the calling\n  // library\n  optional bytes call_telemetry = 21;\n}\n\nmessage SubmitCallQualitySurveyResponse {\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/calling.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.calling;\n\n// Provides methods for getting credentials for one-on-one and group calls.\nservice Calling {\n\n  // Generates and returns TURN credentials for the caller.\n  rpc GetTurnCredentials(GetTurnCredentialsRequest) returns (GetTurnCredentialsResponse) {}\n}\n\nmessage GetTurnCredentialsRequest {}\n\nmessage GetTurnCredentialsResponse {\n  // A username that can be presented to authenticate with a TURN server.\n  string username = 1;\n\n  // A password that can be presented to authenticate with a TURN server.\n  string password = 2;\n\n  // A list of TURN (or TURNS or STUN) servers where the provided credentials\n  // may be used.\n  repeated string urls = 3;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/common.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.common;\n\nimport \"org/signal/chat/require.proto\";\n\nenum IdentityType {\n  IDENTITY_TYPE_UNSPECIFIED = 0;\n  IDENTITY_TYPE_ACI = 1;\n  IDENTITY_TYPE_PNI = 2;\n}\n\nmessage ServiceIdentifier {\n  // The type of identity represented by this service identifier.\n  IdentityType identity_type = 1;\n\n  // The UUID of the identity represented by this service identifier.\n  bytes uuid = 2 [(require.exactlySize) = 16];\n}\n\nmessage AccountIdentifiers {\n  // A list of service identifiers for the identified account.\n  repeated ServiceIdentifier service_identifiers = 1;\n\n  // The phone number associated with the identified account.\n  string e164 = 2;\n\n  // The username hash (if any) associated with the identified account. May be\n  // empty if no username is associated with the identified account.\n  bytes username_hash = 3;\n}\n\nmessage EcPreKey {\n  // A locally-unique identifier for this key, which will be provided by\n  // peers using this key to encrypt messages so the private key can be looked\n  // up.\n  uint32 key_id = 1;\n\n  // The public key, serialized in libsignal's elliptic-curve public key format.\n  bytes public_key = 2  [(require.nonEmpty) = true];\n}\n\nmessage EcSignedPreKey {\n  // A locally-unique identifier for this key, which will be provided by\n  // peers using this key to encrypt messages so the private key can be looked\n  // up.\n  uint32 key_id = 1;\n\n  // The public key, serialized in libsignal's elliptic-curve public key format.\n  bytes public_key = 2  [(require.nonEmpty) = true];\n\n  // A signature of the public key, verifiable with the identity key for the\n  // account/identity associated with this pre-key.\n  bytes signature = 3  [(require.nonEmpty) = true];\n}\n\nmessage KemSignedPreKey {\n  // An locally-unique identifier for this key, which will be provided by peers\n  // using this key to encrypt messages so the private key can be looked up.\n  uint32 key_id = 1;\n\n  // The public key, serialized in libsignal's Kyber1024 public key format.\n  bytes public_key = 2 [(require.nonEmpty) = true];\n\n  // A signature of the public key, verifiable with the identity key for the\n  // account/identity associated with this pre-key.\n  bytes signature = 3 [(require.nonEmpty) = true];\n}\n\nenum DeviceCapability {\n  DEVICE_CAPABILITY_UNSPECIFIED = 0;\n  DEVICE_CAPABILITY_STORAGE = 1;\n  DEVICE_CAPABILITY_TRANSFER = 2;\n  reserved 3;\n  reserved 4;\n  reserved 5;\n  DEVICE_CAPABILITY_ATTACHMENT_BACKFILL = 6;\n  DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET = 7;\n}\n\nmessage ZkCredential {\n  /*\n   * Day on which this credential can be redeemed, in UTC seconds since epoch\n   */\n  int64 redemption_time = 1;\n\n  /*\n   * The ZK credential, using libsignal's serialization\n   */\n  bytes credential = 2  [(require.nonEmpty) = true];\n}\n\n// An upload location and credentials which may be used to upload an object\n// to an external CDN\nmessage UploadForm {\n  // Indicates the CDN type. 3 indicates resumable uploads using TUS\n  uint32 cdn = 1;\n\n  // The location within the specified cdn where the finished upload can be found\n  string key = 2;\n\n  // A map of headers to include with all upload requests. Potentially contains\n  // time-limited upload credentials\n  map<string, string> headers = 3;\n\n  // The URL to upload to with the appropriate protocol\n  string signed_upload_location = 4;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/credentials.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\nimport \"org/signal/chat/require.proto\";\n\npackage org.signal.chat.credentials;\n\n// Provides methods for obtaining and verifying credentials for \"external\" services\n// (i.e. services that are not a part of the chat server deployment).\n// All methods of this service require authentication.\nservice ExternalServiceCredentials {\n\n  // Generates and returns an external service credentials for the caller.\n  rpc GetExternalServiceCredentials(GetExternalServiceCredentialsRequest)\n      returns (GetExternalServiceCredentialsResponse) {}\n}\n\nservice ExternalServiceCredentialsAnonymous {\n  // Given a list of secure value recovery (SVR) service credentials and a phone number,\n  // checks, which of the provided credentials were generated by the user with the given phone number\n  // and have not yet expired.\n  rpc CheckSvrCredentials(CheckSvrCredentialsRequest)\n      returns (CheckSvrCredentialsResponse) {}\n}\n\nenum ExternalServiceType {\n  EXTERNAL_SERVICE_TYPE_UNSPECIFIED = 0;\n  EXTERNAL_SERVICE_TYPE_DIRECTORY = 1;\n  EXTERNAL_SERVICE_TYPE_PAYMENTS = 2;\n  EXTERNAL_SERVICE_TYPE_STORAGE = 3;\n  EXTERNAL_SERVICE_TYPE_SVR = 4;\n}\n\nmessage GetExternalServiceCredentialsRequest {\n  // A service to request credentials for.\n  ExternalServiceType externalService = 1;\n}\n\nmessage GetExternalServiceCredentialsResponse {\n  // A username that can be presented to authenticate with the external service.\n  string username = 1;\n\n  // A password that can be presented to authenticate with the external service.\n  string password = 2;\n}\n\nenum AuthCheckResult {\n  AUTH_CHECK_RESULT_UNSPECIFIED = 0;\n  // The credentials could be used to make a call to SVR service by the user\n  // associated with the `CheckSvrCredentialsRequest.number` phone number.\n  AUTH_CHECK_RESULT_MATCH = 1;\n  // The credentials were generated by a different user.\n  AUTH_CHECK_RESULT_NO_MATCH = 2;\n  // This status indicates that the corresponding credentials token should no longer be used.\n  // This may be because it has expired or invalid, but it can also mean that there is a more\n  // recent token in the request which should be used instead.\n  AUTH_CHECK_RESULT_INVALID = 3;\n}\n\nmessage CheckSvrCredentialsRequest {\n  // A phone number in the E164 format to check the passwords against.\n  // Only passwords generated for the user associated with the given number will be marked as `AUTH_CHECK_RESULT_MATCH`.\n  string number = 1;\n\n  // A list of credentials from previously made calls to `ExternalServiceCredentials.GetExternalServiceCredentials()`\n  // for `EXTERNAL_SERVICE_TYPE_SVR`. This list may contain credentials generated by different users. Up to 10 credentials\n  // can be checked.\n  repeated string passwords = 2 [(require.nonEmpty) = true, (require.size) = {max: 10}];\n}\n\n// For each of the credentials tokens in the `CheckSvrCredentialsRequest` contains the result of the check.\nmessage CheckSvrCredentialsResponse {\n\n  map<string, AuthCheckResult> matches = 1;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/device.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.device;\n\nimport \"google/protobuf/empty.proto\";\n\nimport \"org/signal/chat/common.proto\";\nimport \"org/signal/chat/errors.proto\";\nimport \"org/signal/chat/require.proto\";\nimport \"org/signal/chat/tag.proto\";\n\n// Provides methods for working with devices attached to a Signal account.\nservice Devices {\n  // Returns a list of devices associated with the caller's account.\n  rpc GetDevices(GetDevicesRequest) returns (GetDevicesResponse) {}\n\n  // Removes a linked device from the caller's account.\n  //\n  // Linked devices may only remove themselves. Primary devices may remove\n  // any device other than themselves.\n  rpc RemoveDevice(RemoveDeviceRequest) returns (RemoveDeviceResponse) {}\n\n  // Sets the encrypted human-readable name for a specific devices. Primary\n  // devices may change the name of any device associated with their account,\n  // but linked devices may only change their own name. The response will\n  // indicate if the target device was not found.\n  rpc SetDeviceName(SetDeviceNameRequest) returns (SetDeviceNameResponse) {}\n\n  // Sets the token(s) the server should use to send new message notifications\n  // to the authenticated device.\n  rpc SetPushToken(SetPushTokenRequest) returns (SetPushTokenResponse) {}\n\n  // Removes any push tokens associated with the authenticated device. After\n  // calling this method, the server will assume that the authenticated device\n  // will periodically poll for new messages.\n  rpc ClearPushToken(ClearPushTokenRequest) returns (ClearPushTokenResponse) {}\n\n  // Declares that the authenticated device supports certain features.\n  rpc SetCapabilities(SetCapabilitiesRequest) returns (SetCapabilitiesResponse) {}\n}\n\nmessage GetDevicesRequest {}\n\nmessage GetDevicesResponse {\n  message LinkedDevice {\n    // The identifier for the device within an account.\n    uint32 id = 1;\n\n    // A sequence of bytes that encodes an encrypted human-readable name for\n    // this device.\n    bytes name = 2;\n\n    // The approximate time, in milliseconds since the epoch, at which this\n    // device last connected to the server.\n    uint64 last_seen = 3;\n\n    // The registration ID of the given device.\n    uint32 registration_id = 4 [(require.range).max = 0x3fff];\n\n    // A sequence of bytes that encodes the time,\n    // in milliseconds since the epoch, at which this device was\n    // attached to its parent account.\n    bytes created_at_ciphertext = 5;\n  }\n\n  // A list of devices linked to the authenticated account.\n  repeated LinkedDevice devices = 1;\n}\n\nmessage RemoveDeviceRequest {\n  // The identifier for the device to remove from the authenticated account. The\n  // identifier must not be for the primary device.\n  uint32 id = 1;\n}\n\nmessage SetDeviceNameRequest {\n  // A sequence of bytes that encodes an encrypted human-readable name for this\n  // device.\n  bytes name = 1 [(require.size) = {min: 1, max: 225}];\n\n  // The identifier for the device for which to set a name.\n  uint32 id = 2;\n}\n\nmessage SetDeviceNameResponse {\n  oneof response {\n    // The device name was successfully set\n    google.protobuf.Empty success = 1;\n\n    // No device with the provided identifier was found on the account\n    errors.NotFound target_device_not_found = 2 [(tag.reason) = \"not_found\"];\n  }\n}\n\nmessage RemoveDeviceResponse {}\n\nmessage SetPushTokenRequest {\n  message ApnsTokenRequest {\n    // A \"standard\" APNs device token.\n    string apns_token = 1 [(require.nonEmpty) = true];\n  }\n\n  message FcmTokenRequest {\n    // An FCM push token.\n    string fcm_token = 1 [(require.nonEmpty) = true];\n  }\n\n  oneof token_request {\n    // If present, specifies the APNs device token(s) the server will use to\n    // send new message notifications to the authenticated device.\n    ApnsTokenRequest apns_token_request = 1;\n\n    // If present, specifies the FCM push token the server will use to send new\n    // message notifications to the authenticated device.\n    FcmTokenRequest fcm_token_request = 2;\n  }\n}\n\nmessage SetPushTokenResponse {}\n\nmessage ClearPushTokenRequest {}\n\nmessage ClearPushTokenResponse {}\n\nmessage SetCapabilitiesRequest {\n  repeated common.DeviceCapability capabilities = 1;\n}\n\nmessage SetCapabilitiesResponse {}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/errors.proto",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.errors;\n\n// Response message that indicates a particular resource was not found.\nmessage NotFound {}\n\n// Response message that indicates that some precondition of the request was not\n// met. For example, if there was a request to update foo, but foo had not been\n// set, this would be an appropriate error.\nmessage FailedPrecondition {\n  // An optional description indicating what precondition failed.\n  string description = 1;\n}\n\n// Response message that authentication via an anonymous credential failed.\nmessage FailedZkAuthentication {\n  // An optional description with additional information about the failure.\n  string description = 1;\n}\n\n// Response message that indicates authorization to perform an unidentified\n// operation via an endorsement or access key failed\nmessage FailedUnidentifiedAuthorization {\n  // An optional description with additional information about the failure.\n  string description = 1;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/keys.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.keys;\n\nimport \"google/protobuf/empty.proto\";\n\nimport \"org/signal/chat/common.proto\";\nimport \"org/signal/chat/errors.proto\";\nimport \"org/signal/chat/require.proto\";\n\n// Provides methods for working with pre-keys.\nservice Keys {\n\n  // Retrieves an approximate count of the number of the various kinds of\n  // pre-keys stored for the authenticated device.\n  rpc GetPreKeyCount (GetPreKeyCountRequest) returns (GetPreKeyCountResponse) {}\n\n  // Retrieves a set of pre-keys for establishing a session with the targeted\n  // device or devices. Note that callers with an unidentified access key for\n  // the targeted account should use the version of this method in\n  // `KeysAnonymous` instead.\n  rpc GetPreKeys(GetPreKeysRequest) returns (GetPreKeysResponse) {}\n\n  // Uploads a new set of one-time EC pre-keys for the authenticated device,\n  // clearing any previously-stored pre-keys. Note that all keys submitted via\n  // a single call to this method _must_ have the same identity type (i.e. if\n  // the first key has an ACI identity type, then all other keys in the same\n  // stream must also have an ACI identity type). The provided list of pre-keys\n  // must be non-empty.\n  rpc SetOneTimeEcPreKeys (SetOneTimeEcPreKeysRequest) returns (SetPreKeyResponse) {}\n\n  // Uploads a new set of one-time KEM pre-keys for the authenticated device,\n  // clearing any previously-stored pre-keys. Note that all keys submitted via\n  // a single call to this method _must_ have the same identity type (i.e. if\n  // the first key has an ACI identity type, then all other keys in the same\n  // stream must also have an ACI identity type). The provided list of pre-keys\n  // must be non-empty.\n  rpc SetOneTimeKemSignedPreKeys (SetOneTimeKemSignedPreKeysRequest) returns (SetPreKeyResponse) {}\n\n  // Sets the signed EC pre-key for one identity (i.e. ACI or PNI) associated\n  // with the authenticated device.\n  rpc SetEcSignedPreKey (SetEcSignedPreKeyRequest) returns (SetPreKeyResponse) {}\n\n  // Sets the last-resort KEM pre-key for one identity (i.e. ACI or PNI)\n  // associated with the authenticated device.\n  rpc SetKemLastResortPreKey (SetKemLastResortPreKeyRequest) returns (SetPreKeyResponse) {}\n}\n\n// Provides methods for working with pre-keys using \"unidentified access\"\n// credentials.\nservice KeysAnonymous {\n\n  // Retrieves a set of pre-keys for establishing a session with the targeted\n  // device or devices. Callers must not submit any self-identifying credentials\n  // when calling this method and must instead present the targeted account's\n  // unidentified access key as an anonymous authentication mechanism. Callers\n  // without an unidentified access key should use the equivalent, authenticated\n  // method in `Keys` instead.\n  rpc GetPreKeys(GetPreKeysAnonymousRequest) returns (GetPreKeysAnonymousResponse) {}\n\n  // Checks identity key fingerprints of the target accounts.\n  //\n  // Returns a stream of elements, each one representing an account that had a mismatched\n  // identity key fingerprint with the server and the corresponding identity key stored by the server.\n  rpc CheckIdentityKeys(stream CheckIdentityKeyRequest) returns (stream CheckIdentityKeyResponse) {}\n}\n\nmessage GetPreKeyCountRequest {\n}\n\nmessage GetPreKeyCountResponse {\n  // The approximate number of one-time EC pre-keys stored for the\n  // authenticated device and associated with the caller's ACI.\n  uint32 aci_ec_pre_key_count = 1;\n\n  // The approximate number of one-time Kyber pre-keys stored for the\n  // authenticated device and associated with the caller's ACI.\n  uint32 aci_kem_pre_key_count = 2;\n\n  // The approximate number of one-time EC pre-keys stored for the\n  // authenticated device and associated with the caller's PNI.\n  uint32 pni_ec_pre_key_count = 3;\n\n  // The approximate number of one-time KEM pre-keys stored for the\n  // authenticated device and associated with the caller's PNI.\n  uint32 pni_kem_pre_key_count = 4;\n}\n\nmessage GetPreKeysRequest {\n  // The service identifier of the account for which to retrieve pre-keys.\n  common.ServiceIdentifier target_identifier = 1;\n\n  // The ID of the device associated with the targeted account for which to\n  // retrieve pre-keys. If not set, pre-keys are returned for all devices\n  // associated with the targeted account.\n  optional uint32 device_id = 2;\n}\n\nmessage GetPreKeysAnonymousRequest {\n  // The request to retrieve pre-keys for a specific account/device(s).\n  GetPreKeysRequest request = 1;\n\n  // A means to authorize the request.\n  oneof authorization {\n    // The unidentified access key (UAK) for the targeted account.\n    bytes unidentified_access_key = 2;\n\n    // A group send endorsement token for the targeted account.\n    bytes group_send_token = 3;\n\n    // The destination account allows unrestricted unidentified access\n    google.protobuf.Empty unrestricted_access = 4;\n  }\n}\n\nmessage DevicePreKeyBundle {\n  // The EC signed pre-key associated with the targeted\n  // account/device/identity.\n  common.EcSignedPreKey ec_signed_pre_key = 1;\n\n  // A one-time EC pre-key for the targeted account/device/identity. May not\n  // be set if no one-time EC pre-keys are available.\n  common.EcPreKey ec_one_time_pre_key = 2;\n\n  // A one-time KEM pre-key (or a last-resort KEM pre-key) for the targeted\n  // account/device/identity.\n  common.KemSignedPreKey kem_one_time_pre_key = 3;\n\n  // The registration ID for the targeted account/device/identity.\n  uint32 registration_id = 4;\n}\n\nmessage AccountPreKeyBundles {\n  // The identity key associated with the targeted account/identity.\n  bytes identity_key = 1;\n\n  // A map of device IDs to pre-key \"bundles\" for the targeted account.\n  map<uint32, DevicePreKeyBundle> device_pre_keys = 2;\n}\n\nmessage GetPreKeysResponse {\n  oneof response {\n    // The requested pre-key bundles\n    AccountPreKeyBundles pre_keys = 1;\n\n    // Either the target account was not found, no active device with the given\n    // ID (if specified) was found on the target account.\n    errors.NotFound target_not_found = 2;\n  }\n}\n\nmessage GetPreKeysAnonymousResponse {\n  oneof response {\n    // The requested pre-key bundles\n    AccountPreKeyBundles pre_keys = 1;\n\n    // Either the target account was not found, no active device with the given\n    // ID (if specified) was found on the target account.\n    errors.NotFound target_not_found = 2;\n\n    // The provided unidentified authorization credential was invalid\n    errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3;\n  }\n}\n\nmessage SetOneTimeEcPreKeysRequest {\n  // The identity type (i.e. ACI/PNI) with which the keys in this request are\n  // associated.\n  common.IdentityType identity_type = 1;\n\n  // The unsigned EC pre-keys to be stored.\n  repeated common.EcPreKey pre_keys = 2 [(require.size) = {min: 1, max: 100}];\n}\n\nmessage SetOneTimeKemSignedPreKeysRequest {\n  // The identity type (i.e. ACI/PNI) with which the keys in this request are\n  // associated.\n  common.IdentityType identity_type = 1;\n\n  // The KEM pre-keys to be stored.\n  repeated common.KemSignedPreKey pre_keys = 2 [(require.size) = {min: 1, max: 100}];\n}\n\nmessage SetEcSignedPreKeyRequest {\n  // The identity type (i.e. ACI/PNI) with which this key is associated.\n  common.IdentityType identity_type = 1;\n\n  // The signed EC pre-key itself.\n  common.EcSignedPreKey signed_pre_key = 2 [(require.present) = true];\n}\n\nmessage SetKemLastResortPreKeyRequest {\n  // The identity type (i.e. ACI/PNI) with which this key is associated.\n  common.IdentityType identity_type = 1;\n\n  // The signed KEM pre-key itself.\n  common.KemSignedPreKey signed_pre_key = 2 [(require.present) = true];\n}\n\nmessage SetPreKeyResponse {\n}\n\nmessage CheckIdentityKeyRequest {\n  // The service identifier of the account for which we want to check the associated identity key fingerprint.\n  common.ServiceIdentifier target_identifier = 1;\n  // The most significant 4 bytes of the SHA-256 hash of the identity key associated with the target account/identity type.\n  bytes fingerprint = 2 [(require.exactlySize) = 4];\n}\n\nmessage CheckIdentityKeyResponse {\n  // The service identifier of the account for which there is a mismatch between the client and server identity key fingerprints.\n  common.ServiceIdentifier target_identifier = 1;\n  // The identity key that is stored by the server for the target account/identity type.\n  bytes identity_key = 2;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/messages.proto",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.messages;\n\nimport \"google/protobuf/empty.proto\";\n\nimport \"org/signal/chat/common.proto\";\nimport \"org/signal/chat/require.proto\";\nimport \"org/signal/chat/errors.proto\";\n\n// Provides methods for sending \"unsealed sender\" messages.\nservice Messages {\n\n  option (require.auth) = AUTH_ONLY_AUTHENTICATED;\n\n  // Sends an \"unsealed sender\" message to all devices linked to a single\n  // destination account.\n  //\n  // The destination account must not be the same as the authenticated caller.\n  // Callers should use `SendSyncMessage` to send messages to themselves.\n  rpc SendMessage(SendAuthenticatedSenderMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}\n\n  // Sends a \"sync\" message to all other devices linked to the authenticated\n  // sender's account.\n  rpc SendSyncMessage(SendSyncMessageRequest) returns (SendMessageAuthenticatedSenderResponse) {}\n}\n\n// Provides methods for sending \"sealed sender\" messages.\nservice MessagesAnonymous {\n\n  option (require.auth) = AUTH_ONLY_ANONYMOUS;\n\n  // Sends a \"sealed sender\" message to all devices linked to a single\n  // destination account.\n  //\n  // If this RPC is authorized with an unidentified access key, it will fail\n  // with an authorization failure if the credential is invalid OR if the\n  // destination account was not found. If it is authorized using a group send\n  // token, it will fail with an authorization failure if the credential is\n  // invalid and with an destination not found error if the account does not\n  // exist\n  rpc SendSingleRecipientMessage(SendSealedSenderMessageRequest) returns (SendMessageResponse) {}\n\n  // Sends a \"sealed sender\" message with a common payload to all devices linked\n  // to multiple destination accounts.\n  rpc SendMultiRecipientMessage(SendMultiRecipientMessageRequest) returns (SendMultiRecipientMessageResponse) {}\n\n  // Sends a story message to devices linked to a single destination account.\n  rpc SendStory(SendStoryMessageRequest) returns (SendMessageResponse) {}\n\n  // Sends a story message with a common payload to devices linked to devices\n  // linked to multiple destination accounts.\n  rpc SendMultiRecipientStory(SendMultiRecipientStoryRequest) returns (SendMultiRecipientMessageResponse) {}\n}\n\nmessage IndividualRecipientMessageBundle {\n\n  // A message for an individual device linked to a destination account.\n  message Message {\n\n    // The registration ID for the destination device.\n    uint32 registration_id = 1 [(require.range).max = 0x3fff];\n\n    // The content of the message to deliver to the destination device.\n    bytes payload = 2 [(require.size) = {min: 1, max: 262144}]; // 256 KiB\n\n    // The message type of the message. If this message is part of an\n    // unidentified send, this must be UNIDENTIFIED_SENDER\n    SendMessageType type = 3;\n  }\n\n  // The time, in milliseconds since the epoch, at which this message was\n  // originally sent from the perspective of the sender. Note that the maximum\n  // allowable timestamp for JavaScript clients is less than Long.MAX_VALUE; see\n  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date\n  // for additional details and discussion.\n  uint64 timestamp = 1 [(require.range).min = 1, (require.range).max = 8640000000000000];\n\n  // A map of device IDs to individual messages. Generally, callers must include\n  // one message for each device linked to the destination account. In cases of\n  // \"sync messages\" where a sender is distributing information to other devices\n  // linked to the sender's account, senders may omit a message for the sending\n  // device.\n  map<uint32, Message> messages = 2 [(require.nonEmpty) = true];\n}\n\nenum SendMessageType {\n  UNSPECIFIED = 0;\n\n  // A double-ratchet message represents a \"normal,\" \"unsealed-sender\" message\n  // encrypted using the Double Ratchet within an established Signal session.\n  DOUBLE_RATCHET = 1;\n\n  // A prekey message begins a new Signal session. The `content` of a prekey\n  // message is a superset of a double-ratchet message's `content` and\n  // contains the sender's identity public key and information identifying the\n  // pre-keys used in the message's ciphertext.\n  PREKEY_MESSAGE = 2;\n\n  // A plaintext message is used solely to convey encryption error receipts\n  // and never contains encrypted message content. Encryption error receipts\n  // must be delivered in plaintext because encryption/decryption of a prior\n  // message failed and there is no reason to believe that\n  // encryption/decryption of subsequent messages with the same key material\n  // would succeed.\n  //\n  // Critically, plaintext messages never have \"real\" message content\n  // generated by users. Plaintext messages include sender information.\n  PLAINTEXT_CONTENT = 3;\n\n  // An unidentified sender message is an encrypted message. No other\n  // information about the type of the encrypted message is known to the server.\n  //\n  // Unidenitfied sender messages require an unidentified access token or a\n  // group send endorsement token to prove the unidentified sender is authorized\n  // to send messages to the destination.\n  UNIDENTIFIED_SENDER = 4;\n}\n\nmessage SendAuthenticatedSenderMessageRequest {\n\n  // The service identifier of the account to which to deliver the message.\n  common.ServiceIdentifier destination = 1;\n\n  // If true, this message will only be delivered to destination devices that\n  // have an active message delivery channel with a Signal server.\n  bool ephemeral = 2;\n\n  // Indicates whether this message is urgent and should trigger a high-priority\n  // notification if the destination device does not have an active message\n  // delivery channel with a Signal server\n  bool urgent = 3;\n\n  // The messages to send to the destination account.\n  IndividualRecipientMessageBundle messages = 4;\n}\n\nmessage SendMessageAuthenticatedSenderResponse {\n\n  // The outcome of the message delivery\n  oneof response {\n\n    // The message was successfully delivered to all destination devices\n    google.protobuf.Empty success = 1;\n\n    // A list of discrepancies between the destination devices identified in a\n    // request to send a message and the devices that are actually linked to an\n    // account.\n    MismatchedDevices mismatched_devices = 2;\n\n    // A description of a challenge callers must complete before sending\n    // additional messages.\n    ChallengeRequired challenge_required = 3;\n\n    // The destination account did not exist\n    errors.NotFound destination_not_found = 4;\n\n  }\n}\n\n\nmessage SendSyncMessageRequest {\n\n  // Indicates whether this message is urgent and should trigger a high-priority\n  // notification if the destination device does not have an active message\n  // delivery channel with a Signal server\n  bool urgent = 1;\n\n  // The messages to send to the destination account.\n  IndividualRecipientMessageBundle messages = 2;\n}\n\nmessage SendSealedSenderMessageRequest {\n\n  // The service identifier of the account to which to deliver the message.\n  common.ServiceIdentifier destination = 1;\n\n  // If true, this message will only be delivered to destination devices that\n  // have an active message delivery channel with a Signal server.\n  bool ephemeral = 2;\n\n  // Indicates whether this message is urgent and should trigger a high-priority\n  // notification if the destination device does not have an active message\n  // delivery channel with a Signal server\n  bool urgent = 3;\n\n  // The messages to send to the destination account.\n  IndividualRecipientMessageBundle messages = 4;\n\n  // A means to authorize the request.\n  oneof authorization {\n\n    // The unidentified access key (UAK) for the destination account.\n    bytes unidentified_access_key = 5 [(require.exactlySize) = 16];\n\n    // A group send endorsement token for the destination account.\n    bytes group_send_token = 6;\n\n    // The destination account allows unrestricted unidentified access\n    google.protobuf.Empty unrestricted_access = 7;\n  }\n}\n\nmessage SendStoryMessageRequest {\n\n  // The service identifier of the account to which to deliver the message.\n  common.ServiceIdentifier destination = 1;\n\n  // Indicates whether this message is urgent and should trigger a high-priority\n  // notification if the destination device does not have an active message\n  // delivery channel with a Signal server\n  bool urgent = 2;\n\n  // The messages to send to the destination account.\n  IndividualRecipientMessageBundle messages = 3;\n}\n\nmessage SendMessageResponse {\n\n  // The outcome of the message delivery\n  oneof response {\n\n    // The message was successfully delivered to all destination devices\n    google.protobuf.Empty success = 1;\n\n    // A list of discrepancies between the destination devices identified in a\n    // request to send a message and the devices that are actually linked to an\n    // account.\n    MismatchedDevices mismatched_devices = 2;\n\n    // The provided unidentified authorization credential was invalid\n    errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3;\n\n    // The destination account did not exist\n    errors.NotFound destination_not_found = 4;\n\n  }\n}\n\nmessage MultiRecipientMessage {\n\n  // The time, in milliseconds since the epoch, at which this message was\n  // originally sent from the perspective of the sender. Note that the maximum\n  // allowable timestamp for JavaScript clients is less than Long.MAX_VALUE; see\n  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#the_epoch_timestamps_and_invalid_date\n  // for additional details and discussion.\n  uint64 timestamp = 1 [(require.range).min = 1, (require.range).max = 8640000000000000];\n\n  // The serialized multi-recipient message payload.\n  bytes payload = 2 [(require.size).max = 762144]; // 256 KiB payload + (5000 * 100) of overhead\n}\n\nmessage SendMultiRecipientMessageRequest {\n\n  // If true, this message will only be delivered to destination devices that\n  // have an active message delivery channel with a Signal server.\n  bool ephemeral = 1;\n\n  // Indicates whether this message is urgent and should trigger a high-priority\n  // notification if the destination device does not have an active message\n  // delivery channel with a Signal server\n  bool urgent = 2;\n\n  // The multi-recipient message to send to all destination accounts and\n  // devices.\n  MultiRecipientMessage message = 3;\n\n  // A group send endorsement token for the destination account.\n  bytes group_send_token = 4 [(require.nonEmpty) = true];\n}\n\nmessage SendMultiRecipientStoryRequest {\n\n  // Indicates whether this message is urgent and should trigger a high-priority\n  // notification if the destination device does not have an active message\n  // delivery channel with a Signal server\n  bool urgent = 1;\n\n  // The multi-recipient story message to send to all destination accounts and\n  // devices.\n  MultiRecipientMessage message = 2;\n}\n\nmessage MultiRecipientSuccess {\n  // A list of destination service identifiers that could not be resolved to\n  // registered Signal accounts. The  message in the original request was sent\n  // to all service identifiers/devices in the original request except for the\n  // destination devices associated with the service identifiers in this list.\n  repeated common.ServiceIdentifier unresolved_recipients = 1;\n}\n\nmessage SendMultiRecipientMessageResponse {\n\n  // The outcome of the message delivery\n  oneof response {\n    // The message was sent to at least some of the destination accounts/devices\n    // identified in the original request.\n    MultiRecipientSuccess success = 1;\n\n    // A list of sets of discrepancies between the destination devices\n    // identified in a request to send a message and the devices that are\n    // actually linked to a destination account.\n    MultiRecipientMismatchedDevices mismatched_devices = 2;\n\n    // The provided unidentified authorization credential was invalid\n    errors.FailedUnidentifiedAuthorization failed_unidentified_authorization = 3;\n  }\n}\n\nmessage MismatchedDevices {\n\n  // The service identifier to which the devices named in this object are\n  // linked.\n  common.ServiceIdentifier service_identifier = 1;\n\n  // A list of device IDs that are linked to the destination account, but were\n  // not included in the collection of messages bound for the destination\n  // account.\n  repeated uint32 missing_devices = 2 [(require.range).max = 0x7f];\n\n  // A list of device IDs that were included in the collection of messages bound\n  // for the destination account, but are not currently linked to the\n  // destination account.\n  repeated uint32 extra_devices = 3 [(require.range).max = 0x7f];\n\n  // A list of device IDs that present in the collection of messages bound for\n  // the destination account and are linked to the destination account, but have\n  // a different registration ID than the registration ID presented by the\n  // sender (indicating that the destination device has likely been replaced by\n  // another device).\n  repeated uint32 stale_devices = 4 [(require.range).max = 0x7f];\n}\n\nmessage MultiRecipientMismatchedDevices {\n\n  // A list of sets of discrepancies between the destination devices identified\n  // in a request to send a message and the devices that are actually linked to\n  // a destination account.\n  repeated MismatchedDevices mismatched_devices = 1;\n}\n\nmessage ChallengeRequired {\n\n  enum ChallengeType {\n    UNSPECIFIED = 0;\n\n    // A challenge that callers can fulfill by completing a captcha.\n    CAPTCHA = 1;\n\n    // A challenge that callers can fulfill by supplying a token delivered via\n    // push notification.\n    PUSH_CHALLENGE = 2;\n  };\n\n  // An opaque token identifying this challenge request. Clients must generally\n  // submit this token when submitting a challenge response.\n  string token = 1;\n\n  // A list of challenge types callers may choose to complete to resolve the\n  // challenge requirement. May be empty, in which case callers cannot resolve\n  // the challenge by any means other than waiting.\n  repeated ChallengeType challenge_options = 2;\n\n  // A duration (in seconds) after which the challenge requirement may be\n  // resolved by simply waiting. May not be set if the challenge cannot be\n  // resolved by waiting.\n  optional uint64 retry_after_seconds = 3;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/payments.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.payments;\n\n// Provides methods for working with payments.\nservice Payments {\n  \n  rpc GetCurrencyConversions(GetCurrencyConversionsRequest) returns (GetCurrencyConversionsResponse) {}\n}\n\nmessage GetCurrencyConversionsRequest {\n}\n\nmessage GetCurrencyConversionsResponse {\n\n  message CurrencyConversionEntity {\n\n    string base = 1;\n\n    map<string, string> conversions = 2;\n  }\n\n  uint64 timestamp = 1;\n\n  repeated CurrencyConversionEntity currencies = 2;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/profile.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.profile;\n\nimport \"org/signal/chat/common.proto\";\n\n// Provides methods for working with profiles and profile-related data.\nservice Profile {\n  // Sets profile data and if needed, returns S3 credentials used by clients to upload an avatar.\n  //\n  // This RPC may fail with `PERMISSION_DENIED` if it attempts to set the MobileCoin wallet ID\n  // on an account whose profile does not currently have a MobileCoin wallet ID and\n  // whose phone number contains a disallowed country prefix.\n  rpc SetProfile(SetProfileRequest) returns (SetProfileResponse) {}\n\n  // Retrieves versioned profile data. Callers with an unidentified access key for the account\n  // should use the version of this method in `ProfileAnonymous` instead.\n  //\n  // This RPC may fail with a `NOT_FOUND` status if the target account was not\n  // found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been\n  // exceeded, in which case a `retry-after` header containing an ISO 8601\n  // duration string will be present in the response trailers.\n  rpc GetVersionedProfile(GetVersionedProfileRequest) returns (GetVersionedProfileResponse) {}\n\n  // Retrieves unversioned profile data. Callers with an unidentified access key for the account\n  // should use the version of this method in `ProfileAnonymous` instead.\n  //\n  // This RPC may fail with a `NOT_FOUND` status if the target account was not\n  // found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been\n  // exceeded, in which case a `retry-after` header containing an ISO 8601\n  // duration string will be present in the response trailers.\n  rpc GetUnversionedProfile(GetUnversionedProfileRequest) returns (GetUnversionedProfileResponse) {}\n\n  // Retrieves a profile key credential.\n  // Callers with an unidentified access key for the account\n  // should use the version of this method in `ProfileAnonymous` instead.\n  //\n  // This RPC may fail with a `NOT_FOUND` status if the target account was not\n  // found. It may fail with a `RESOURCE_EXHAUSTED` if a rate limit for fetching profiles has been\n  // exceeded, in which case a `retry-after` header containing an ISO 8601\n  // duration string will be present in the response trailers. It may also fail with an\n  // `INVALID_ARGUMENT` status if the given credential type is invalid.\n  rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialRequest) returns (GetExpiringProfileKeyCredentialResponse) {}\n}\n\n// Provides methods for working with profiles and profile-related data using \"unidentified access\"\n// credentials. Callers must not submit any self-identifying credentials\n// when calling methods in this service and must instead present the targeted account's\n// unidentified access key as an anonymous authentication mechanism. Callers\n// without an unidentified access key should use the equivalent, authenticated\n// methods in `Profile` instead.\nservice ProfileAnonymous {\n  // Retrieves versioned profile data.\n  //\n  // This RPC may fail with a `NOT_FOUND` status if the target account was not\n  // found. It may also fail with an `UNAUTHENTICATED` status if the given\n  // unidentified access key did not match the target account's unidentified\n  // access key.\n  rpc GetVersionedProfile(GetVersionedProfileAnonymousRequest) returns (GetVersionedProfileResponse) {}\n  // Retrieves unversioned profile data.\n  //\n  // This RPC may fail with a `NOT_FOUND` status if the target account was not\n  // found. It may also fail with an `UNAUTHENTICATED` status if the given\n  // unidentified access key did not match the target account's unidentified\n  // access key.\n  rpc GetUnversionedProfile(GetUnversionedProfileAnonymousRequest) returns (GetUnversionedProfileResponse) {}\n  // Retrieves a profile key credential.\n  //\n  // This RPC may fail with a `NOT_FOUND` status if the target account was not\n  // found. It may also fail with an `UNAUTHENTICATED` status if the given\n  // unidentified access key did not match the target account's unidentified\n  // access key, or an `INVALID_ARGUMENT` status if the given credential type is invalid.\n  rpc GetExpiringProfileKeyCredential(GetExpiringProfileKeyCredentialAnonymousRequest) returns (GetExpiringProfileKeyCredentialResponse) {}\n}\n\nmessage SetProfileRequest {\n  enum AvatarChange {\n    AVATAR_CHANGE_UNCHANGED = 0;\n    AVATAR_CHANGE_CLEAR = 1;\n    AVATAR_CHANGE_UPDATE = 2;\n  }\n  // The profile version. Must be set.\n  string version = 1;\n  // The ciphertext of a name that users must set on the profile.\n  bytes name = 2;\n  // An enum to indicate what change, if any, is made to the avatar with this request.\n  AvatarChange avatarChange = 3;\n  // The ciphertext of an emoji that users can set on their profile.\n  bytes about_emoji = 4;\n  // The ciphertext of a description that users can set on their profile.\n  bytes about = 5;\n  // The ciphertext of the MobileCoin wallet ID on the profile.\n  bytes payment_address = 6;\n  // A list of badge IDs associated with the profile.\n  repeated string badge_ids = 7;\n  // The ciphertext of the phone-number sharing setting on the profile. 29-byte encrypted boolean.\n  bytes phone_number_sharing = 8;\n  // The profile key commitment. Used to issue a profile key credential response.\n  // Must be set on the request.\n  bytes commitment = 9;\n}\n\nmessage SetProfileResponse {\n  // The policy and credential used by clients to upload an avatar to S3.\n  ProfileAvatarUploadAttributes attributes = 1;\n}\n\nmessage GetVersionedProfileRequest {\n  // The ACI of the account for which to get profile data.\n  common.ServiceIdentifier accountIdentifier = 1;\n  // The profile version to retrieve.\n  string version = 2;\n}\n\nmessage GetVersionedProfileAnonymousRequest {\n  // Contains the data necessary to request a versioned profile.\n  GetVersionedProfileRequest request = 1;\n  // The unidentified access key for the targeted account.\n  bytes unidentified_access_key = 2;\n}\n\nmessage GetVersionedProfileResponse {\n  // The ciphertext of the name on the profile.\n  bytes name = 1;\n  // The ciphertext of the description on the profile.\n  bytes about = 2;\n  // The ciphertext of the emoji on the profile.\n  bytes about_emoji = 3;\n  // The S3 path of the avatar on the profile.\n  string avatar = 4;\n  // The ciphertext of the MobileCoin wallet ID on the profile.\n  bytes payment_address = 5;\n  // The ciphertext of the phone-number sharing setting on the profile.\n  bytes phone_number_sharing = 6;\n}\n\nmessage GetUnversionedProfileRequest {\n  // The service identifier of the account for which to get profile data.\n  common.ServiceIdentifier serviceIdentifier = 1;\n}\n\nmessage GetUnversionedProfileAnonymousRequest {\n  // Contains the data necessary to request an unversioned profile.\n  GetUnversionedProfileRequest request = 1;\n\n  oneof authentication {\n    // The unidentified access key for the targeted account.\n    bytes unidentified_access_key = 2;\n\n    // A group send endorsement token for the targeted account.\n    bytes group_send_token = 3;\n  }\n}\n\nmessage GetUnversionedProfileResponse {\n  // The identity key of the targeted account/identity type.\n  bytes identity_key = 1;\n  // A checksum of the unidentified access key for the targeted account.\n  bytes unidentified_access = 2;\n  // Whether the account has enabled sealed sender from anyone.\n  bool unrestricted_unidentified_access = 3;\n  // A list of capabilities enabled on the account.\n  repeated common.DeviceCapability capabilities = 4;\n  // A list of badges associated with the account.\n  repeated Badge badges = 5;\n}\n\nmessage GetExpiringProfileKeyCredentialRequest {\n  // The ACI of the account for which to get a profile key credential.\n  common.ServiceIdentifier accountIdentifier = 1;\n  // A zkgroup request for a profile key credential.\n  bytes credential_request = 2;\n  // The type of credential being requested.\n  CredentialType credential_type = 3;\n  // The profile version for which to generate a profile key credential.\n  string version = 4;\n}\n\nmessage GetExpiringProfileKeyCredentialAnonymousRequest {\n  // Contains the data necessary to request an expiring profile key credential.\n  GetExpiringProfileKeyCredentialRequest request = 1;\n  // The unidentified access key for the targeted account.\n  bytes unidentified_access_key = 2;\n}\n\nmessage GetExpiringProfileKeyCredentialResponse {\n  // A zkgroup credential used by a client to prove that it has the profile key\n  // of a targeted account.\n  bytes profileKeyCredential = 1;\n}\n\nmessage ProfileAvatarUploadAttributes {\n  // The S3 upload path for the profile's avatar.\n  string path = 1;\n  // A scoped credential. Includes the AWS access key, date, region targeted, and AWS service.\n  string credential = 2;\n  // The type of access control for the avatar object.\n  string acl = 3;\n  // The algorithm used to calculate a signature on the S3 policy.\n  string algorithm = 4;\n  // The timestamp at which the S3 policy and signature were generated.\n  string date = 5;\n  // The S3 policy used to upload the avatar object.\n  string policy = 6;\n  // A digital signature on the S3 policy.\n  bytes signature = 7;\n}\n\nmessage Badge {\n  // An ID that uniquely identifies the badge.\n  string id = 1;\n  // The category the badge falls in (\"donor\" or \"other\").\n  string category = 2;\n  // The badge name.\n  string name = 3;\n  // The badge description.\n  string description = 4;\n  // Different size badge SVG files.\n  repeated string sprites6 = 5;\n  // File name of the scalable vector graphic representing this badge.\n  string svg = 6;\n  // Pairs of light/dark SVG files designed for display at different sizes.\n  repeated BadgeSvg svgs = 7;\n}\n\nmessage BadgeSvg {\n  // File name of the scalable vector graphic for light mode.\n  string light = 1;\n  // File name of the scalable vector graphic for dark mode.\n  string dark = 2;\n}\n\nenum CredentialType {\n  CREDENTIAL_TYPE_UNSPECIFIED = 0;\n  CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY = 1;\n}\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/require.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.require;\n\nimport \"google/protobuf/descriptor.proto\";\n\nextend google.protobuf.FieldOptions {\n  /*\n   * Requires a field to have content of non-zero size/length.\n   * Applies to both `optional` and regular fields, i.e. if the field is not set\n   * or has a default value, it's considered to be empty. This does not apply\n   * to fields that are contained in a `oneof`.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   *\n   * message Data {\n   *   string nonEmptyString = 1 [(require.nonEmpty) = true];\n   *   bytes nonEmptyBytes = 2 [(require.nonEmpty) = true];\n   *   optional string nonEmptyStringOptional = 3 [(require.nonEmpty) = true];\n   *   optional bytes nonEmptyBytesOptional = 4 [(require.nonEmpty) = true];\n   *   repeated string nonEmptyList = 5 [(require.nonEmpty) = true];\n   * }\n   * ```\n   *\n   * Applicable to fields of type `string`, `byte`, and `repeated` fields.\n   */\n  optional bool nonEmpty  = 70001;\n\n  /*\n   * Requires a enum field to have value with an index greater than zero.\n   * Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value,\n   * its index will be <= 0.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   *\n   * message Data {\n   *   Color color = 1 [(require.specified) = true];\n   * }\n   *\n   * enum Color {\n   *   COLOR_UNSPECIFIED = 0;\n   *   COLOR_RED = 1;\n   *   COLOR_GREEN = 2;\n   *   COLOR_BLUE = 3;\n   * }\n   * ```\n   */\n  optional bool specified  = 70002;\n\n  /*\n   * Requires a size/length of a field to be within certain boundaries.\n   * Applies to both `optional` and regular fields, i.e. if the field is not set\n   * or has a default value, its size considered to be zero. However, if the\n   * field is contained in a `oneof` and is not set, this annotation does not\n   * apply.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   *\n   * message Data {\n   *\n   *   string name = 1 [(require.size) = {min: 3, max: 8}];\n   *\n   *   optional string address = 2 [(require.size) = {min: 3, max: 8}];\n   * }\n   * ```\n   *\n   * Applicable to fields of type `string`, `byte`, and `repeated` fields.\n   */\n  optional SizeConstraint size = 70003;\n\n  /*\n   * Requires a size/length of a field to be within certain boundaries.\n   * Applies to both `optional` and regular fields, i.e. if the field is not set\n   * or has a default value, its size considered to be zero. However, if the\n   * field is contained in a `oneof` and is not set, this annotation does not\n   * apply.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   *\n   * message Data {\n   *\n   *   string zip = 1 [(require.exactlySize) = 5];\n   *\n   *   optional string exactlySizeVariants = 2 [(require.exactlySize) = 2, (require.exactlySize) = 4];\n   * }\n   * ```\n   *\n   * Applicable to fields of type `string`, `byte`, and `repeated` fields.\n   */\n  repeated uint32 exactlySize = 70004;\n\n  /*\n   * Requires a value of a string field to be a valid E164-normalized phone number.\n   * If the field is `optional`, this check allows a value to be not set.\n   *\n   *  ```\n   *  import \"org/signal/chat/require.proto\";\n   *\n   *  message Data {\n   *    string number = 1 [(require.e164)];\n   *  }\n   *  ```\n   */\n  optional bool e164  = 70005;\n\n  /*\n   * Requires an integer value to be within a certain range. The range boundaries are specified\n   * with the values of type `int32`, which should be enough for all practical purposes.\n   *\n   * If the field is `optional`, this check allows a value to be not set.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   *\n   * message Data {\n   *   int32 byte = 1 [(require.range) = {min: -128, max: 127}];\n   *   uint32 unsignedByte = 2 [(require.range).max = 255];\n   * }\n   * ```\n   */\n  optional ValueRangeConstraint range = 70006;\n\n  /*\n   * Require a value of a message field to be present.\n   *\n   * Applies to both `optional` and regular fields (both of which have explicit\n   * presence for the message type anyways). This does not apply to fields that\n   * are contained in a `oneof`.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   * message Data {\n   *   message MyMessage {}\n   *   MyMessage myMessage = 1 [(require.present) = true];\n   * }\n   *````\n   */\n  optional bool present = 70007;\n}\n\nmessage SizeConstraint {\n  optional uint32 min = 1;\n  optional uint32 max = 2;\n}\n\nmessage ValueRangeConstraint {\n  optional int64 min = 1;\n  optional int64 max = 2;\n}\n\nextend google.protobuf.ServiceOptions {\n  /*\n   * Indicates that all methods in a given service require a certain kind of authentication.\n   *\n   * ```\n   * import \"org/signal/chat/require.proto\";\n   *\n   * service AuthService {\n   *   option (require.auth) = AUTH_ONLY_AUTHENTICATED;\n   *\n   *   rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}\n   * }\n   * ```\n   */\n  optional Auth auth = 71001;\n}\n\nenum Auth {\n  AUTH_UNSPECIFIED = 0;\n  AUTH_ONLY_AUTHENTICATED = 1;\n  AUTH_ONLY_ANONYMOUS = 2;\n}\n\n"
  },
  {
    "path": "service/src/main/proto/org/signal/chat/tag.proto",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.tag;\n\nimport \"google/protobuf/descriptor.proto\";\n\nextend google.protobuf.FieldOptions {\n  // Indicate that a message which includes this field (directly or indirectly)\n  // was generated for a particular reason.\n  //\n  // ```\n  // import \"org/signal/chat/tag.proto\"\n  //\n  // message LookupThingResponse {\n  //   oneof response {\n  //     string thing = 1;\n  //     Error not_found = 2 [(tag.reason) = \"not_found\"];\n  //     Error forbidden = 3 [(tag.reason) = \"forbidden\"];\n  //   }\n  // }\n  // ```\n  //\n  // Metrics middleware may then inspect `LookupThingResponse` and tag responses\n  // with the provided reason. This is useful when multiple outcomes are\n  // potentially represented with a status = \"OK\" RPC response.\n  //\n  // Valid messages should only have a single reason set. If a message has\n  // multiple fields present that have a reason option set, no guarantees are\n  // made about the reason that is selected.\n  optional string reason = 71000;\n}\n"
  },
  {
    "path": "service/src/main/resources/META-INF/services/io.dropwizard.jackson.Discoverable",
    "content": "org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory\norg.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory\norg.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory\norg.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClientFactory\norg.whispersystems.textsecuregcm.configuration.PaymentsServiceClientsFactory\norg.whispersystems.textsecuregcm.configuration.PubSubPublisherFactory\norg.whispersystems.textsecuregcm.configuration.RegistrationServiceClientFactory\norg.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory\n"
  },
  {
    "path": "service/src/main/resources/META-INF/services/io.dropwizard.logging.common.AppenderFactory",
    "content": "org.whispersystems.textsecuregcm.metrics.LogstashTcpSocketAppenderFactory\norg.whispersystems.textsecuregcm.metrics.OpenTelemetryAppenderFactory\n"
  },
  {
    "path": "service/src/main/resources/META-INF/services/io.dropwizard.logging.common.filter.FilterFactory",
    "content": "org.whispersystems.textsecuregcm.util.logging.RequestLogEnabledFilterFactory\norg.whispersystems.textsecuregcm.util.logging.UnknownKeepaliveOptionFilterFactory\n"
  },
  {
    "path": "service/src/main/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.AwsCredentialsProviderFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.StaticAwsCredentialsFactory\n"
  },
  {
    "path": "service/src/main/resources/META-INF/validation/constraints-custom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<constraint-mappings\n    xmlns=\"http://xmlns.jcp.org/xml/ns/validation/mapping\"\n    xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n    xsi:schemaLocation=\"http://xmlns.jcp.org/xml/ns/validation/mapping\n            http://xmlns.jcp.org/xml/ns/validation/mapping/validation-mapping-2.0.xsd\"\n    version=\"2.0\">\n  <constraint-definition annotation=\"jakarta.validation.constraints.NotEmpty\">\n    <validated-by include-existing-validators=\"true\">\n      <value>org.whispersystems.textsecuregcm.configuration.secrets.SecretStringList$ValidatorNotEmpty</value>\n      <value>org.whispersystems.textsecuregcm.configuration.secrets.SecretBytesList$ValidatorNotEmpty</value>\n    </validated-by>\n  </constraint-definition>\n</constraint-mappings>\n"
  },
  {
    "path": "service/src/main/resources/META-INF/validation.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<validation-config\n    xmlns=\"http://xmlns.jcp.org/xml/ns/validation/configuration\"\n    xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n    xsi:schemaLocation=\"http://xmlns.jcp.org/xml/ns/validation/configuration validation-configuration-2.0.xsd\"\n    version=\"2.0\">\n  <constraint-mapping>META-INF/validation/constraints-custom.xml</constraint-mapping>\n</validation-config>\n"
  },
  {
    "path": "service/src/main/resources/banner.txt",
    "content": " _____  _                       _  _____                               \n/  ___|(_)                     | |/  ___|                              \n\\ `--.  _   __ _  _ __    __ _ | |\\ `--.   ___  _ __ __   __ ___  _ __ \n `--. \\| | / _` || '_ \\  / _` || | `--. \\ / _ \\| '__|\\ \\ / // _ \\| '__|\n/\\__/ /| || (_| || | | || (_| || |/\\__/ /|  __/| |    \\ V /|  __/| |   \n\\____/ |_| \\__, ||_| |_| \\__,_||_|\\____/  \\___||_|     \\_/  \\___||_|   \n            __/ |                                                      \n           |___/                                                       \n\n"
  },
  {
    "path": "service/src/main/resources/lua/apn/schedule_background_notification.lua",
    "content": "local lastBackgroundNotificationTimestampKey = KEYS[1]\nlocal queueKey = KEYS[2]\n\nlocal accountDevicePair = ARGV[1]\nlocal currentTimeMillis = tonumber(ARGV[2])\nlocal backgroundNotificationPeriod = tonumber(ARGV[3])\n\nlocal lastBackgroundNotificationTimestamp = redis.call(\"GET\", lastBackgroundNotificationTimestampKey)\nlocal nextNotificationTimestamp\n\nif (lastBackgroundNotificationTimestamp) then\n    nextNotificationTimestamp = tonumber(lastBackgroundNotificationTimestamp) + backgroundNotificationPeriod\nelse\n    nextNotificationTimestamp = currentTimeMillis\nend\n\nredis.call(\"ZADD\", queueKey, \"NX\", nextNotificationTimestamp, accountDevicePair)\n"
  },
  {
    "path": "service/src/main/resources/lua/get_delivery_attempt_count.lua",
    "content": "local firstMessageGuidKey = KEYS[1]\nlocal firstMessageAttemptsKey = KEYS[2]\n\nlocal firstMessageGuid = ARGV[1]\nlocal ttlSeconds = ARGV[2]\n\nif firstMessageGuid ~= redis.call(\"GET\", firstMessageGuidKey) then\n    -- This is the first time we've attempted to deliver this message as the first message in a \"page\"\n    redis.call(\"SET\", firstMessageGuidKey, firstMessageGuid, \"EX\", ttlSeconds)\n    redis.call(\"SET\", firstMessageAttemptsKey, 0, \"EX\", ttlSeconds)\nend\n\nreturn redis.call(\"INCR\", firstMessageAttemptsKey)\n"
  },
  {
    "path": "service/src/main/resources/lua/get_items.lua",
    "content": "-- gets messages from a device's queue, up to a given limit\n-- returns a list of all envelopes and their queue-local IDs\n\nlocal queueKey       = KEYS[1] -- sorted set of all Envelopes for a device, scored by queue-local ID\nlocal queueLockKey   = KEYS[2] -- a key whose presence indicates that the queue is being persisted and must not be read\nlocal limit          = ARGV[1] -- [number] the maximum number of messages to return\nlocal afterMessageId = ARGV[2] -- [number] a queue-local ID to exclusively start after, to support pagination. Use -1 to start at the beginning\nlocal bypassLock     = ARGV[3] -- [string] whether to bypass the persistence lock (i.e. when fetching messages for persistence)\n\nif bypassLock ~= \"true\" then\n    local locked = redis.call(\"GET\", queueLockKey)\n\n    if locked then\n        return {}\n    end\nend\n\nif afterMessageId == \"null\" or afterMessageId == nil then\n    return redis.error_reply(\"ERR afterMessageId is required\")\nend\n\nreturn redis.call(\"ZRANGE\", queueKey, \"(\"..afterMessageId, \"+inf\", \"BYSCORE\", \"LIMIT\", 0, limit, \"WITHSCORES\")\n"
  },
  {
    "path": "service/src/main/resources/lua/insert_item.lua",
    "content": "-- inserts a message into a device's queue, and updates relevant associated data\n-- returns a number, the queue-local message ID (useful for testing)\n\nlocal queueKey           = KEYS[1] -- sorted set of Envelopes for a device, by queue-local ID\nlocal queueMetadataKey   = KEYS[2] -- hash of message GUID to queue-local IDs\nlocal eventChannelKey    = KEYS[3] -- pub/sub channel for message availability events\nlocal message            = ARGV[1] -- [bytes] the Envelope to insert\nlocal guid               = ARGV[2] -- [string] the message GUID\nlocal eventPayload       = ARGV[3] -- [bytes] a protobuf payload for a \"message available\" pub/sub event\n\nif redis.call(\"HEXISTS\", queueMetadataKey, guid) == 1 then\n    return tonumber(redis.call(\"HGET\", queueMetadataKey, guid))\nend\n\nlocal messageId = redis.call(\"HINCRBY\", queueMetadataKey, \"counter\", 1)\n\nredis.call(\"ZADD\", queueKey, \"NX\", messageId, message)\n\nredis.call(\"HSET\", queueMetadataKey, guid, messageId)\nredis.call(\"EXPIRE\", queueKey, 3974400) -- 46 days\nredis.call(\"EXPIRE\", queueMetadataKey, 3974400) -- 46 days\n\nreturn redis.call(\"SPUBLISH\", eventChannelKey, eventPayload) > 0\n"
  },
  {
    "path": "service/src/main/resources/lua/insert_shared_multirecipient_message_data.lua",
    "content": "-- inserts shared multi-recipient message data\n\nlocal sharedMrmKey = KEYS[1] -- [string] the key containing the shared MRM data\nlocal mrmData      = ARGV[1] -- [bytes] the serialized multi-recipient message data\n-- the remainder of ARGV is list of recipient keys and view data\n\nif 1 == redis.call(\"EXISTS\", sharedMrmKey) then\n    return redis.error_reply(\"ERR key exists\")\nend\n\nredis.call(\"HSET\", sharedMrmKey, \"data\", mrmData);\nredis.call(\"EXPIRE\", sharedMrmKey, 604800) -- 7 days\n\n-- unpack() fails with \"too many results\" at very large table sizes, so we loop\nfor i = 2, #ARGV, 2 do\n    redis.call(\"HSET\", sharedMrmKey, ARGV[i], ARGV[i + 1])\nend\n"
  },
  {
    "path": "service/src/main/resources/lua/release_node_claim.lua",
    "content": "-- Releases a message persister's claim to a Redis node when a persist-to-DynamoDB run has finished\n\nlocal nodeClaimKey = KEYS[1] -- simple string key whose presence indicates a claim on a node\nlocal persisterId  = ARGV[1] -- a string identifying the persister that claimed the node\n\nlocal claimedByInstanceId = redis.call(\"GET\", nodeClaimKey)\n\nif persisterId == claimedByInstanceId then\n    redis.call(\"DEL\", nodeClaimKey)\nend\n"
  },
  {
    "path": "service/src/main/resources/lua/remove_item_by_guid.lua",
    "content": "-- removes a list of messages by ID from the cluster, returning the deleted messages\n-- returns a list of removed envelopes\n--   Note: content may be absent for MRM messages, and for these messages, the caller must update the sharedMrmKey\n--         to remove the recipient's reference\n\nlocal queueKey           = KEYS[1] -- sorted set of Envelopes for a device, by queue-local ID\nlocal queueMetadataKey   = KEYS[2] -- hash of message GUID to queue-local IDs\nlocal messageGuids       = ARGV    -- [list[string]] message GUIDs\n\nlocal removedMessages = {}\n\nfor _, guid in ipairs(messageGuids) do\n    local messageId = redis.call(\"HGET\", queueMetadataKey, guid)\n\n    if messageId then\n        local envelope = redis.call(\"ZRANGE\", queueKey, messageId, messageId, \"BYSCORE\", \"LIMIT\", 0, 1)\n\n        redis.call(\"ZREMRANGEBYSCORE\", queueKey, messageId, messageId)\n        redis.call(\"HDEL\", queueMetadataKey, guid)\n\n        if envelope and next(envelope) then\n            table.insert(removedMessages, envelope[1])\n        end\n    end\nend\n\nif (redis.call(\"ZCARD\", queueKey) == 0) then\n    redis.call(\"DEL\", queueKey)\n    redis.call(\"DEL\", queueMetadataKey)\nend\n\nreturn removedMessages\n"
  },
  {
    "path": "service/src/main/resources/lua/remove_queue.lua",
    "content": "-- incrementally removes a given device's queue and associated data\n-- returns: a page of messages and scores.\n--    The messages must be checked for mrmKeys to update. After updating MRM keys, this script must be called again\n--    with processedMessageGuids. If the returned table is empty, then\n--    the queue has been fully deleted.\n\nlocal queueKey              = KEYS[1] -- sorted set of Envelopes for a device, by queue-local ID\nlocal queueMetadataKey      = KEYS[2] -- hash of message GUID to queue-local IDs\nlocal limit                 = ARGV[1] -- the maximum number of messages to return\nlocal processedMessageGuids = { unpack(ARGV, 2) }\n\nfor _, guid in ipairs(processedMessageGuids) do\n    local messageId = redis.call(\"HGET\", queueMetadataKey, guid)\n    if messageId then\n        redis.call(\"ZREMRANGEBYSCORE\", queueKey, messageId, messageId)\n        redis.call(\"HDEL\", queueMetadataKey, guid)\n    end\nend\n\nlocal messages = redis.call(\"ZRANGE\", queueKey, 0, limit-1)\n\nif #messages == 0 then\n    redis.call(\"DEL\", queueKey)\n    redis.call(\"DEL\", queueMetadataKey)\nend\n\nreturn messages\n"
  },
  {
    "path": "service/src/main/resources/lua/remove_recipient_view_from_mrm_data.lua",
    "content": "-- Removes the given recipient view from the shared MRM data. If the only field remaining after the removal is the\n-- `data` field, then the key will be deleted\n\nlocal sharedMrmKeys         = KEYS    -- KEYS: list of all keys in a single slot to update\nlocal recipientViewToRemove = ARGV[1] -- the recipient view to remove from the hash\n\nlocal keysDeleted = 0\n\nfor _, sharedMrmKey in ipairs(sharedMrmKeys) do\n    redis.call(\"HDEL\", sharedMrmKey, recipientViewToRemove)\n    if redis.call(\"HLEN\", sharedMrmKey) == 1 then\n        redis.call(\"DEL\", sharedMrmKey)\n        keysDeleted = keysDeleted + 1\n    end\nend\n\nreturn keysDeleted\n"
  },
  {
    "path": "service/src/main/resources/lua/unlock_queue.lua",
    "content": "-- Unlocks a message queue when a persist-to-DynamoDB run has finished and publishes an event notifying listeners that\n-- messages have been persisted\n\nlocal persistInProgressKey = KEYS[1] -- simple string key whose presence indicates a lock\nlocal eventChannelKey      = KEYS[2] -- the channel on which to publish the \"messages persisted\" event\nlocal eventPayload         = ARGV[1] -- [bytes] a protobuf payload for a \"message persisted\" pub/sub event\n\nredis.call(\"DEL\", persistInProgressKey)\nredis.call(\"SPUBLISH\", eventChannelKey, eventPayload)\n"
  },
  {
    "path": "service/src/main/resources/lua/validate_rate_limit.lua",
    "content": "-- The script encapsulates the logic of a token bucket rate limiter.\n-- Two types of operations are supported: 'check-only' and 'use-if-available' (controlled by the 'useTokens' arg).\n-- Both operations take in rate limiter configuration parameters and the requested amount of tokens.\n-- Both operations return 0, if the rate limiter has enough tokens to cover the requested amount,\n-- and the deficit amount otherwise.\n-- However, 'check-only' operation doesn't modify the bucket, while 'use-if-available' (if successful)\n-- reduces the amount of available tokens by the requested amount.\n\nlocal bucketId = KEYS[1]\n\nlocal bucketSize = tonumber(ARGV[1])\nlocal refillRatePerMillis = tonumber(ARGV[2])\nlocal currentTimeMillis = tonumber(ARGV[3])\nlocal requestedAmount = tonumber(ARGV[4])\nlocal useTokens = ARGV[5] and string.lower(ARGV[5]) == \"true\"\n\nlocal SIZE_FIELD = \"s\"\nlocal TIME_FIELD = \"t\"\n\nlocal changesMade = false\nlocal tokensRemaining\nlocal lastUpdateTimeMillis\n\nlocal tokensRemainingStr, lastUpdateTimeMillisStr = unpack(redis.call(\"HMGET\", bucketId, SIZE_FIELD, TIME_FIELD))\nif tokensRemainingStr and lastUpdateTimeMillisStr then\n    tokensRemaining = tonumber(tokensRemainingStr)\n    lastUpdateTimeMillis = tonumber(lastUpdateTimeMillisStr)\nelse\n    tokensRemaining = bucketSize\n    lastUpdateTimeMillis = currentTimeMillis\nend\n\nlocal elapsedTime = currentTimeMillis - lastUpdateTimeMillis\nlocal availableAmount = math.min(\n    bucketSize,\n    math.floor(tokensRemaining + (elapsedTime * refillRatePerMillis))\n)\n\nif availableAmount >= requestedAmount then\n    if useTokens then\n        tokensRemaining = availableAmount - requestedAmount\n        lastUpdateTimeMillis = currentTimeMillis\n        changesMade = true\n    end\n    if changesMade then\n        local tokensUsed = bucketSize - tokensRemaining\n        -- Storing a 'full' bucket (i.e. tokensUsed == 0) is equivalent of not storing any state at all\n        -- (in which case a bucket will be just initialized from the input configs as a 'full' one).\n        -- For this reason, we either set an expiration time on the record (calculated to let the bucket fully replenish)\n        -- or we just delete the key if the bucket is full.\n        if tokensUsed > 0 then\n            local ttlMillis = math.ceil(tokensUsed / refillRatePerMillis)\n            redis.call(\"HSET\", bucketId, SIZE_FIELD, tokensRemaining, TIME_FIELD, lastUpdateTimeMillis)\n            redis.call(\"PEXPIRE\", bucketId, ttlMillis)\n        else\n            redis.call(\"DEL\", bucketId)\n        end\n    end\n    return 0\nelse\n    return requestedAmount - availableAmount\nend\n"
  },
  {
    "path": "service/src/main/resources/org/signal/badges/Badges.properties",
    "content": "#\n# Copyright 2021 Signal Messenger, LLC\n# SPDX-License-Identifier: AGPL-3.0-only\n#\n\nTEST_name = Test Badge\nTEST_description = {short_name} has this badge for testing purposes.\n\nTEST1_name = Test Badge Alpha\nTEST1_description = {short_name} is testing the alpha test badge.\n\nTEST2_name = Test Badge Beta\nTEST2_description = {short_name} is testing the beta test badge.\n\nTEST3_name = Test Badge Gamma\nTEST3_description = {short_name} is testing the gamma test badge.\n\nR_LOW_name = Signal Star\nR_LOW_description = {short_name} supports Signal with a monthly donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.\n\nR_MED_name = Signal Planet\nR_MED_description = {short_name} supports Signal with a monthly donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.\n\nR_HIGH_name = Signal Sun\nR_HIGH_description = {short_name} supports Signal with a monthly donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.\n\nBOOST_name = Signal Boost\nBOOST_description = {short_name} supported Signal with a donation. Signal is a nonprofit with no advertisers or investors, supported only by people like you.\n\nGIFT_name = Signal UFO\nGIFT_description = A friend made a donation to Signal on behalf of {short_name}. Signal is a nonprofit with no advertisers or investors, supported only by people like you.\n"
  },
  {
    "path": "service/src/main/resources/org/signal/badges/Badges_en.properties",
    "content": ""
  },
  {
    "path": "service/src/main/resources/org/signal/bankmandate/BankMandate.properties",
    "content": "#\n# Copyright 2023 Signal Messenger, LLC\n# SPDX-License-Identifier: AGPL-3.0-only\n#\n\nSEPA_MANDATE = By providing your payment information and confirming this payment, you authorise (A) Signal Technology Foundation and Stripe, our payment service provider, to send instructions to your bank to debit your account and (B) your bank to debit your account in accordance with those instructions. As part of your rights, you are entitled to a refund from your bank under the terms and conditions of your agreement with your bank. A refund must be claimed within 8 weeks starting from the date on which your account was debited. Your rights are explained in a statement that you can obtain from your bank. You agree to receive notifications for future debits up to 2 days before they occur.\n\n"
  },
  {
    "path": "service/src/main/resources/org/signal/donations/PayPal.properties",
    "content": "#\n# Copyright 2026 Signal Messenger, LLC\n# SPDX-License-Identifier: AGPL-3.0-only\n#\n\n# checkout line item description on the donation confirmation web page. Max length: 127 characters\noneTime.donationLineItemName = Donation to Signal Technology Foundation\n"
  },
  {
    "path": "service/src/main/resources/org/whispersystems/textsecuregcm/push/apns-certificates.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB\niDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl\ncnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV\nBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw\nMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV\nBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU\naGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy\ndGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK\nAoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B\n3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY\ntJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/\nFp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2\nVN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT\n79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6\nc0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT\nYo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l\nc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee\nUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE\nHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd\nBgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G\nA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF\nUp/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO\nVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3\nATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs\n8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR\niQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze\nSf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ\nXHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/\nqS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB\nVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB\nL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG\njjxDah2nGN59PRbxYvnKkKj9\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "service/src/main/resources/org/whispersystems/textsecuregcm/storage/devicecheck/apple_device_check.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw\nJAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK\nQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa\nFw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv\nbiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y\nbmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh\nNbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au\nYen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/\nMB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw\nCgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn\n53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV\noyFraWVIyd/dganmrduC1bmTBGwD\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/BufferingInterceptorIntegrationTest.java",
    "content": "package org.whispersystems.textsecuregcm;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.util.BufferingInterceptor;\nimport org.whispersystems.textsecuregcm.util.VirtualExecutorServiceProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class BufferingInterceptorIntegrationTest {\n  private static final DropwizardAppExtension<Configuration> DROPWIZARD_APP_EXTENSION =\n      new DropwizardAppExtension<>(TestApplication.class);\n\n  public static class TestApplication extends Application<Configuration> {\n\n    @Override\n    public void run(final Configuration configuration, final Environment environment) throws Exception {\n      final TestController testController = new TestController();\n      environment.jersey().register(testController);\n      environment.jersey().register(new BufferingInterceptor());\n      environment.jersey().register(new VirtualExecutorServiceProvider(\"virtual-thread-\", 10));\n      JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), null);\n    }\n  }\n\n  @Test\n  public void testVirtual() {\n    final Response response = DROPWIZARD_APP_EXTENSION.client()\n        .target(\"http://127.0.0.1:%d/test/virtual/8\".formatted(DROPWIZARD_APP_EXTENSION.getLocalPort()))\n        .request().get();\n    assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH)).isEqualTo(\"8\");\n  }\n\n  @Test\n  public void testPlatform() {\n    final Response response = DROPWIZARD_APP_EXTENSION.client()\n        .target(\"http://127.0.0.1:%d/test/platform/8\".formatted(DROPWIZARD_APP_EXTENSION.getLocalPort()))\n        .request().get();\n    assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH)).isEqualTo(\"8\");\n\n  }\n\n  @Path(\"/test\")\n  public static class TestController {\n\n    @GET\n    @Produces(MediaType.APPLICATION_JSON)\n    @Path(\"/virtual/{size}\")\n    @ManagedAsync\n    public String getVirtual(@PathParam(\"size\") int size) {\n      return RandomStringUtils.secure().nextAscii(size);\n    }\n\n    @GET\n    @Produces(MediaType.APPLICATION_JSON)\n    @Path(\"/platform/{size}\")\n    public String getPlatform(@PathParam(\"size\") int size) {\n      return RandomStringUtils.secure().nextAscii(size);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/CheckServiceConfigurations.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm;\n\nimport java.io.File;\nimport java.util.Arrays;\n\n/**\n * Checks whether all YAML configuration files in a given directory are valid.\n * <p>\n * Note: the current implementation fails fast, rather than reporting multiple invalid files\n */\npublic class CheckServiceConfigurations {\n\n  private static final String SECRETS_BUNDLE_FILENAME = \"sample-secrets-bundle.yml\";\n\n  private void checkConfiguration(final File configDirectory) {\n\n    final File[] configFiles = configDirectory.listFiles(f ->\n        !f.isDirectory()\n            && f.getPath().endsWith(\".yml\")\n            && !f.getPath().endsWith(SECRETS_BUNDLE_FILENAME));\n\n    if (configFiles == null || configFiles.length == 0) {\n      throw new IllegalArgumentException(\"No .yml configuration files found at \" + configDirectory.getPath());\n    }\n\n    final File[] secretsBundle = configDirectory.listFiles(f -> !f.isDirectory() && f.getName().equals(SECRETS_BUNDLE_FILENAME));\n    if (secretsBundle == null || secretsBundle.length != 1) {\n      throw new IllegalArgumentException(\"No [%s] file found at %s\".formatted(SECRETS_BUNDLE_FILENAME, configDirectory.getPath()));\n    }\n    System.setProperty(WhisperServerService.SECRETS_BUNDLE_FILE_NAME_PROPERTY, secretsBundle[0].getAbsolutePath());\n\n    for (final File configFile : configFiles) {\n      final String[] args = new String[]{\"check\", configFile.getAbsolutePath()};\n      try {\n        new WhisperServerService().run(args);\n      } catch (final Exception e) {\n        // Invalid configuration will cause the \"check\" command to call `System.exit()`, rather than throwing,\n        // so this is unexpected\n        throw new RuntimeException(e);\n      }\n    }\n  }\n\n  public static void main(final String[] args) {\n    if (args.length != 1) {\n      throw new IllegalArgumentException(\"Expected single argument with config directory: \" + Arrays.toString(args));\n    }\n\n    final File configDirectory = new File(args[0]);\n\n    if (!(configDirectory.exists() && configDirectory.isDirectory())) {\n      throw new IllegalArgumentException(\"No directory found at \" + configDirectory.getPath());\n    }\n\n    new CheckServiceConfigurations().checkConfiguration(configDirectory);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/LocalWhisperServerService.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm;\n\nimport io.dropwizard.util.Resources;\nimport java.util.Optional;\n\n/**\n * This class may be run directly from a correctly configured IDE, or using the command line:\n * <p>\n * <code>./mvnw clean integration-test -DskipTests=true -Ptest-server</code>\n * <p>\n * <strong>NOTE: many features are non-functional, especially those that depend on external services</strong>\n * <p>\n * By default, it will use {@code config/test.yml}, but this may be overridden by setting an environment variable,\n * {@value SIGNAL_SERVER_CONFIG_ENV_VAR}, with a custom path.\n */\npublic class LocalWhisperServerService {\n\n  private static final String SIGNAL_SERVER_CONFIG_ENV_VAR = \"SIGNAL_SERVER_CONFIG\";\n\n  public static void main(String[] args) throws Exception {\n\n    System.setProperty(\"secrets.bundle.filename\",\n        Resources.getResource(\"config/test-secrets-bundle.yml\").getPath());\n\n    final String config = Optional.ofNullable(System.getenv(SIGNAL_SERVER_CONFIG_ENV_VAR))\n        .orElse(Resources.getResource(\"config/test.yml\").getPath());\n\n    new WhisperServerService().run(\"server\", config);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/ProvisioningTimeoutIntegrationTest.java",
    "content": "package org.whispersystems.textsecuregcm;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.filters.RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME;\n\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport jakarta.servlet.DispatcherType;\nimport jakarta.servlet.ServletRegistration;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.EnumSet;\nimport java.util.Objects;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.client.ClientUpgradeRequest;\nimport org.eclipse.jetty.websocket.client.WebSocketClient;\nimport org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.push.ProvisioningManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.tests.util.TestWebsocketListener;\nimport org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener;\nimport org.whispersystems.websocket.WebSocketResourceProviderFactory;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.messages.InvalidMessageException;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class ProvisioningTimeoutIntegrationTest {\n\n  private static final DropwizardAppExtension<Configuration> DROPWIZARD_APP_EXTENSION =\n      new DropwizardAppExtension<>(TestApplication.class);\n\n\n  private WebSocketClient client;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    client = new WebSocketClient();\n    client.start();\n    final TestApplication testApplication = DROPWIZARD_APP_EXTENSION.getApplication();\n    reset(testApplication.scheduler);\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    client.stop();\n  }\n\n  public static class TestProvisioningListener extends TestWebsocketListener {\n    CompletableFuture<String> provisioningAddressFuture = new CompletableFuture<>();\n\n    @Override\n    public void onWebSocketBinary(final byte[] payload, final int offset, final int length) {\n      try {\n        WebSocketMessage webSocketMessage = messageFactory.parseMessage(payload, offset, length);\n        if (Objects.requireNonNull(webSocketMessage.getType()) == WebSocketMessage.Type.REQUEST_MESSAGE\n            && webSocketMessage.getRequestMessage().getPath().equals(\"/v1/address\")) {\n          MessageProtos.ProvisioningAddress provisioningAddress =\n              MessageProtos.ProvisioningAddress.parseFrom(webSocketMessage.getRequestMessage().getBody().orElseThrow());\n          provisioningAddressFuture.complete(provisioningAddress.getAddress());\n          return;\n        }\n      } catch (InvalidMessageException e) {\n        throw new RuntimeException(e);\n      } catch (InvalidProtocolBufferException e) {\n        throw new RuntimeException(e);\n      }\n      super.onWebSocketBinary(payload, offset, length);\n    }\n  }\n\n  public static class TestApplication extends Application<Configuration> {\n\n    ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);\n\n    @Override\n    public void run(final Configuration configuration, final Environment environment) throws Exception {\n      final WebSocketConfiguration webSocketConfiguration = new WebSocketConfiguration();\n      final WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment =\n          new WebSocketEnvironment<>(environment, webSocketConfiguration);\n\n      environment.servlets()\n          .addFilter(\"RemoteAddressFilter\", new RemoteAddressFilter())\n          .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, \"/*\");\n      webSocketEnvironment.setConnectListener(\n          new ProvisioningConnectListener(mock(ProvisioningManager.class), () -> mock(AsnInfoProvider.class), mock(ClientReleaseManager.class), scheduler, Duration.ofSeconds(5)));\n\n      final WebSocketResourceProviderFactory<AuthenticatedDevice> webSocketServlet =\n          new WebSocketResourceProviderFactory<>(webSocketEnvironment, AuthenticatedDevice.class,\n              webSocketConfiguration, REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n      JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), null);\n      final ServletRegistration.Dynamic websocketServlet = environment.servlets()\n          .addServlet(\"WebSocket\", webSocketServlet);\n      websocketServlet.addMapping(\"/websocket\");\n      websocketServlet.setAsyncSupported(true);\n    }\n  }\n\n  @Test\n  public void websocketTimeoutWithHeader() throws IOException {\n    final TestProvisioningListener testWebsocketListener = new TestProvisioningListener();\n\n    final TestApplication testApplication = DROPWIZARD_APP_EXTENSION.getApplication();\n    when(testApplication.scheduler.schedule(any(Runnable.class), anyLong(), any()))\n        .thenReturn(mock(ScheduledFuture.class));\n\n    final ClientUpgradeRequest upgradeRequest = new ClientUpgradeRequest();\n    try (Session ignored = client.connect(testWebsocketListener,\n        URI.create(String.format(\"ws://127.0.0.1:%d/websocket\", DROPWIZARD_APP_EXTENSION.getLocalPort())),\n        upgradeRequest).join()) {\n\n      assertThat(testWebsocketListener.provisioningAddressFuture.join()).isNotNull();\n      assertThat(testWebsocketListener.closeFuture()).isNotDone();\n\n      final ArgumentCaptor<Runnable> closeFunctionCaptor = ArgumentCaptor.forClass(Runnable.class);\n      verify(testApplication.scheduler).schedule(closeFunctionCaptor.capture(), anyLong(), any());\n      closeFunctionCaptor.getValue().run();\n\n      assertThat(testWebsocketListener.closeFuture())\n          .succeedsWithin(Duration.ofSeconds(1))\n          .isEqualTo(1000);\n    }\n  }\n\n  @Test\n  public void websocketTimeoutCancelled() throws IOException {\n    final TestProvisioningListener testWebsocketListener = new TestProvisioningListener();\n\n    final TestApplication testApplication = DROPWIZARD_APP_EXTENSION.getApplication();\n    @SuppressWarnings(\"unchecked\") final ScheduledFuture<Void> scheduled = mock(ScheduledFuture.class);\n    doReturn(scheduled).when(testApplication.scheduler).schedule(any(Runnable.class), anyLong(), any());\n\n    final ClientUpgradeRequest upgradeRequest = new ClientUpgradeRequest();\n    final Session session = client.connect(testWebsocketListener,\n        URI.create(String.format(\"ws://127.0.0.1:%d/websocket\", DROPWIZARD_APP_EXTENSION.getLocalPort())),\n        upgradeRequest).join();\n\n    // Close the websocket, make sure the timeout is cancelled.\n    session.close();\n    assertThat(testWebsocketListener.closeFuture()).succeedsWithin(Duration.ofSeconds(1));\n    verify(scheduled, times(1)).cancel(anyBoolean());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/WebsocketResourceProviderIntegrationTest.java",
    "content": "package org.whispersystems.textsecuregcm;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.whispersystems.textsecuregcm.filters.RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport jakarta.servlet.DispatcherType;\nimport jakarta.servlet.ServletRegistration;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.util.EnumSet;\nimport java.util.Optional;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.eclipse.jetty.websocket.client.WebSocketClient;\nimport org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.tests.util.TestWebsocketListener;\nimport org.whispersystems.websocket.WebSocketResourceProviderFactory;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class WebsocketResourceProviderIntegrationTest {\n  private static final DropwizardAppExtension<Configuration> DROPWIZARD_APP_EXTENSION =\n      new DropwizardAppExtension<>(TestApplication.class);\n\n\n  private WebSocketClient client;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    client = new WebSocketClient();\n    client.start();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    client.stop();\n  }\n\n\n  public static class TestApplication extends Application<Configuration> {\n\n    @Override\n    public void run(final Configuration configuration, final Environment environment) throws Exception {\n      final TestController testController = new TestController();\n\n      final WebSocketConfiguration webSocketConfiguration = new WebSocketConfiguration();\n\n      final WebSocketEnvironment<AuthenticatedDevice> webSocketEnvironment =\n          new WebSocketEnvironment<>(environment, webSocketConfiguration);\n\n      environment.jersey().register(testController);\n      environment.servlets()\n          .addFilter(\"RemoteAddressFilter\", new RemoteAddressFilter())\n          .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, \"/*\");\n      webSocketEnvironment.jersey().register(testController);\n      webSocketEnvironment.jersey().register(new RemoteAddressFilter());\n      webSocketEnvironment.setAuthenticator(upgradeRequest -> Optional.of(mock(AuthenticatedDevice.class)));\n\n      webSocketEnvironment.jersey().property(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE);\n      webSocketEnvironment.setConnectListener(webSocketSessionContext -> {\n      });\n\n      final WebSocketResourceProviderFactory<AuthenticatedDevice> webSocketServlet =\n          new WebSocketResourceProviderFactory<>(webSocketEnvironment, AuthenticatedDevice.class,\n              webSocketConfiguration, REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n      JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), null);\n\n      final ServletRegistration.Dynamic websocketServlet =\n          environment.servlets().addServlet(\"WebSocket\", webSocketServlet);\n\n      websocketServlet.addMapping(\"/websocket\");\n      websocketServlet.setAsyncSupported(true);\n    }\n  }\n\n\n  @ParameterizedTest\n  // Jersey's content-length buffering by default does not buffer responses with a content-length of > 8192. We disable\n  // that buffering and do our own though, so the 9000 byte case should work.\n  @ValueSource(ints = {0, 1, 100, 1025, 9000})\n  public void contentLength(int length) throws IOException {\n    final TestWebsocketListener testWebsocketListener = new TestWebsocketListener();\n    client.connect(testWebsocketListener,\n        URI.create(String.format(\"ws://127.0.0.1:%d/websocket\", DROPWIZARD_APP_EXTENSION.getLocalPort())));\n\n    final WebSocketResponseMessage readResponse = testWebsocketListener.doGet(\"/test/%d\".formatted(length)).join();\n    assertThat(readResponse.getHeaders().get(HttpHeaders.CONTENT_LENGTH.toLowerCase()))\n        .isEqualTo(Integer.toString(length));\n  }\n\n\n  @Path(\"/test\")\n  public static class TestController {\n\n    @GET\n    @Produces(MediaType.APPLICATION_JSON)\n    @Path(\"/{size}\")\n    @ManagedAsync\n    public String get(@PathParam(\"size\") int size) {\n      return RandomStringUtils.secure().nextAscii(size);\n    }\n\n    @PUT\n    @ManagedAsync\n    public String put() {\n      return \"put\";\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/WhisperServerServiceTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport io.dropwizard.testing.ConfigOverride;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.util.Resources;\nimport jakarta.ws.rs.client.Client;\nimport jakarta.ws.rs.core.Response;\nimport java.net.URI;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ArrayBlockingQueue;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Semaphore;\nimport java.util.concurrent.ThreadFactory;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.eclipse.jetty.util.component.LifeCycle;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.StatusCode;\nimport org.eclipse.jetty.websocket.client.WebSocketClient;\nimport org.jetbrains.annotations.NotNull;\nimport org.junit.jupiter.api.AfterAll;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.configuration.OpenTelemetryConfiguration;\nimport org.whispersystems.textsecuregcm.metrics.NoopAwsSdkMetricPublisher;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.tests.util.TestWebsocketListener;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.Util;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass WhisperServerServiceTest {\n\n  static {\n    System.setProperty(\"secrets.bundle.filename\",\n        Resources.getResource(\"config/test-secrets-bundle.yml\").getPath());\n  }\n\n  private static final WebSocketClient webSocketClient = new WebSocketClient();\n\n  private static final DropwizardAppExtension<WhisperServerConfiguration> EXTENSION = new DropwizardAppExtension<>(\n      WhisperServerService.class, Resources.getResource(\"config/test.yml\").getPath(),\n      // Tables will be created by the local DynamoDbExtension\n      ConfigOverride.config(\"dynamoDbClient.initTables\", \"false\"));\n\n  @RegisterExtension\n  public static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.values());\n\n  @AfterAll\n  static void teardown() {\n    System.clearProperty(\"secrets.bundle.filename\");\n  }\n\n  @BeforeAll\n  static void setUp() throws Exception {\n    webSocketClient.start();\n  }\n\n  @Test\n  void start() throws Exception {\n    // make sure the service nominally starts and responds to health checks\n\n    Client client = EXTENSION.client();\n\n    final Response ping = client.target(\n            String.format(\"http://localhost:%d%s\", EXTENSION.getAdminPort(), \"/ping\"))\n        .request(\"application/json\")\n        .get();\n\n    assertEquals(200, ping.getStatus());\n\n    final Response healthCheck = client.target(\n            String.format(\"http://localhost:%d%s\", EXTENSION.getLocalPort(), \"/health-check\"))\n        .request(\"application/json\")\n        .get();\n\n    assertEquals(200, healthCheck.getStatus());\n  }\n\n  @Test\n  void websocket() throws Exception {\n    // test unauthenticated websocket\n    final long start = System.currentTimeMillis();\n\n    final TestWebsocketListener testWebsocketListener = new TestWebsocketListener();\n\n    EXTENSION.getTestSupport().getEnvironment().getApplicationContext().getServer()\n        .addEventListener(new LifeCycle.Listener() {\n          @Override\n          public void lifeCycleStopped(final LifeCycle event) {\n            // closed by org.eclipse.jetty.websocket.common.SessionTracker during the container Lifecycle stopping phase\n            assertEquals(StatusCode.SHUTDOWN, testWebsocketListener.closeFuture().getNow(-1));\n          }\n        });\n\n    // Session is Closeable, but we intentionally keep it open so that we can confirm the container Lifecycle behavior\n    final Session session = webSocketClient.connect(testWebsocketListener,\n            URI.create(String.format(\"ws://localhost:%d/v1/websocket/\", EXTENSION.getLocalPort())))\n        .join();\n    final long sessionTimestamp = Long.parseLong(session.getUpgradeResponse().getHeader(HeaderUtils.TIMESTAMP_HEADER));\n    assertTrue(sessionTimestamp >= start);\n\n    final WebSocketResponseMessage keepAlive = testWebsocketListener.doGet(\"/v1/keepalive\").join();\n    assertEquals(200, keepAlive.getStatus());\n    final long keepAliveTimestamp = Long.parseLong(\n        keepAlive.getHeaders().get(HeaderUtils.TIMESTAMP_HEADER.toLowerCase()));\n    assertTrue(keepAliveTimestamp >= start);\n\n    final WebSocketResponseMessage whoami = testWebsocketListener.doGet(\"/v1/accounts/whoami\").join();\n    assertEquals(401, whoami.getStatus());\n    final long whoamiTimestamp = Long.parseLong(whoami.getHeaders().get(HeaderUtils.TIMESTAMP_HEADER.toLowerCase()));\n    assertTrue(whoamiTimestamp >= start);\n  }\n\n  @Test\n  void rest() throws Exception {\n    // test unauthenticated rest\n    final long start = System.currentTimeMillis();\n\n    final Response whoami = EXTENSION.client().target(\n        \"http://localhost:%d/v1/accounts/whoami\".formatted(EXTENSION.getLocalPort())).request().get();\n\n    assertEquals(401, whoami.getStatus());\n    final List<Object> timestampValues = whoami.getHeaders().get(HeaderUtils.TIMESTAMP_HEADER.toLowerCase());\n    assertEquals(1, timestampValues.size());\n\n    final long whoamiTimestamp = Long.parseLong(timestampValues.getFirst().toString());\n    assertTrue(whoamiTimestamp >= start);\n  }\n\n  @Test\n  void dynamoDb() {\n    // confirm that local dynamodb nominally works\n\n    final DynamoDbClient dynamoDbClient = getDynamoDbClient();\n\n    final DynamoDbExtension.TableSchema numbers = DynamoDbExtensionSchema.Tables.NUMBERS;\n    final AttributeValue numberAV = AttributeValues.s(\"+12125550001\");\n\n    final GetItemResponse notFoundResponse = dynamoDbClient.getItem(GetItemRequest.builder()\n        .tableName(numbers.tableName())\n        .key(Map.of(numbers.hashKeyName(), numberAV))\n        .build());\n\n    assertFalse(notFoundResponse.hasItem());\n\n    dynamoDbClient.putItem(PutItemRequest.builder()\n        .tableName(numbers.tableName())\n        .item(Map.of(numbers.hashKeyName(), numberAV))\n        .build());\n\n    final GetItemResponse foundResponse = dynamoDbClient.getItem(GetItemRequest.builder()\n        .tableName(numbers.tableName())\n        .key(Map.of(numbers.hashKeyName(), numberAV))\n        .build());\n\n    assertTrue(foundResponse.hasItem());\n\n    dynamoDbClient.deleteItem(DeleteItemRequest.builder()\n        .tableName(numbers.tableName())\n        .key(Map.of(numbers.hashKeyName(), numberAV))\n        .build());\n  }\n\n  private static DynamoDbClient getDynamoDbClient() {\n    final AwsCredentialsProvider awsCredentialsProvider = EXTENSION.getConfiguration().getAwsCredentialsConfiguration()\n        .build();\n\n    return EXTENSION.getConfiguration().getDynamoDbClientConfiguration()\n        .buildSyncClient(awsCredentialsProvider, new NoopAwsSdkMetricPublisher());\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/asn/AsnInfoProviderImplTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.asn;\n\nimport static java.util.Objects.requireNonNull;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.whispersystems.textsecuregcm.asn.AsnInfoProviderImpl.ip4BytesToLong;\nimport static org.whispersystems.textsecuregcm.asn.AsnInfoProviderImpl.ip6BytesToBigInteger;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.math.BigInteger;\nimport java.net.Inet4Address;\nimport java.net.Inet6Address;\nimport java.net.InetAddress;\nimport org.junit.jupiter.api.Test;\n\npublic class AsnInfoProviderImplTest {\n\n  private static final String RESOURCE_NAME = \"ip2asn-test.tsv\";\n\n  @SuppressWarnings(\"OptionalGetWithoutIsPresent\")\n  @Test\n  void testAsnInfo() throws IOException {\n    try (final InputStream tsvInputStream = getClass().getResourceAsStream(RESOURCE_NAME)) {\n      final AsnInfoProvider asnInfoProvider = AsnInfoProviderImpl.fromTsv(requireNonNull(tsvInputStream));\n      assertEquals(16625L, asnInfoProvider.lookup(\"2.16.112.0\").get().asn());\n      assertEquals(16625L, asnInfoProvider.lookup(\"2.16.112.255\").get().asn());\n      assertEquals(16625L, asnInfoProvider.lookup(\"2.16.113.0\").get().asn());\n      assertEquals(16625L, asnInfoProvider.lookup(\"2.16.113.123\").get().asn());\n      assertEquals(16625L, asnInfoProvider.lookup(\"2.16.113.255\").get().asn());\n\n      assertEquals(\"US\", asnInfoProvider.lookup(\"2.16.113.255\").get().regionCode());\n\n      assertEquals(4690L, asnInfoProvider.lookup(\"2001:200:e00::\").get().asn());\n      assertEquals(4690L, asnInfoProvider.lookup(\"2001:200:ef0::\").get().asn());\n      assertEquals(4690L, asnInfoProvider.lookup(\"2001:200:eff:ffff::\").get().asn());\n      assertEquals(4690L, asnInfoProvider.lookup(\"2001:200:eff:ffff:ffff:ffff:ffff:ffff\").get().asn());\n\n      assertEquals(\"JP\", asnInfoProvider.lookup(\"2001:200:eff:ffff:ffff:ffff:ffff:ffff\").get().regionCode());\n\n      assertTrue(asnInfoProvider.lookup(\"1.3.0.0\").isEmpty());\n      assertTrue(asnInfoProvider.lookup(\"1.4.127.255\").isEmpty());\n      assertTrue(asnInfoProvider.lookup(\"2001:4:113::\").isEmpty());\n      assertTrue(asnInfoProvider.lookup(\"0.0.0.0\").isEmpty());\n      assertTrue(asnInfoProvider.lookup(\"127.0.0.1\").isEmpty());\n      assertTrue(asnInfoProvider.lookup(\"not an ip\").isEmpty());\n    }\n  }\n\n  @Test\n  public void testBytesToLong() throws Exception {\n    assertEquals(0x00000000ffffffffL, ip4BytesToLong((Inet4Address) InetAddress.getByName(\"255.255.255.255\")));\n    assertEquals(0x0000000000000001L, ip4BytesToLong((Inet4Address) InetAddress.getByName(\"0.0.0.1\")));\n    assertEquals(0x00000000ff00ff01L, ip4BytesToLong((Inet4Address) InetAddress.getByName(\"255.0.255.1\")));\n\n    final BigInteger start = ip6BytesToBigInteger((Inet6Address) InetAddress.getByName(\"2c0f:fff1:0:0:0:0:0:0\"));\n    final BigInteger end = ip6BytesToBigInteger((Inet6Address) InetAddress.getByName(\"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\"));\n    assertTrue(start.compareTo(BigInteger.ZERO) >= 0);\n    assertTrue(end.compareTo(BigInteger.ZERO) >= 0);\n    assertTrue(start.compareTo(end) <= 0);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/AccountAuthenticatorTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass AccountAuthenticatorTest {\n\n  private final long               today        = 1590451200000L;\n  private final long               yesterday    = today - 86_400_000L;\n  private final long               oldTime      = yesterday - 86_400_000L;\n  private final long               currentTime  = today + 68_000_000L;\n\n  private AccountsManager          accountsManager;\n  private AccountAuthenticator accountAuthenticator;\n  private TestClock                clock;\n  private Account                  acct1;\n  private Account                  acct2;\n  private Account                  oldAccount;\n\n  @BeforeEach\n  void setup() {\n    accountsManager = mock(AccountsManager.class);\n    clock = TestClock.now();\n    accountAuthenticator = new AccountAuthenticator(accountsManager, clock);\n\n    // We use static UUIDs here because the UUID affects the \"date last seen\" offset\n    acct1 = AccountsHelper.generateTestAccount(\"+14088675309\", UUID.fromString(\"c139cb3e-f70c-4460-b221-815e8bdf778f\"), UUID.randomUUID(), List.of(generateTestDevice(yesterday)), null);\n    acct2 = AccountsHelper.generateTestAccount(\"+14088675310\", UUID.fromString(\"30018a41-2764-4bc7-a935-775dfef84ad1\"), UUID.randomUUID(), List.of(generateTestDevice(yesterday)), null);\n    oldAccount = AccountsHelper.generateTestAccount(\"+14088675311\", UUID.fromString(\"adfce52b-9299-4c25-9c51-412fb420c6a6\"), UUID.randomUUID(), List.of(generateTestDevice(oldTime)), null);\n\n    AccountsHelper.setupMockUpdate(accountsManager);\n  }\n\n  private static Device generateTestDevice(final long lastSeen) {\n    final Device device = new Device();\n    device.setId(Device.PRIMARY_ID);\n    device.setLastSeen(lastSeen);\n\n    return device;\n  }\n\n  @Test\n  void testUpdateLastSeenMiddleOfDay() {\n    clock.pin(Instant.ofEpochMilli(currentTime));\n\n    final Device device1 = acct1.getDevices().stream().findFirst().orElseThrow();\n    final Device device2 = acct2.getDevices().stream().findFirst().orElseThrow();\n\n    final Account updatedAcct1 = accountAuthenticator.updateLastSeen(acct1, device1);\n    final Account updatedAcct2 = accountAuthenticator.updateLastSeen(acct2, device2);\n\n    verify(accountsManager, never()).updateDeviceLastSeen(eq(acct1), any(), anyLong());\n    verify(accountsManager).updateDeviceLastSeen(eq(acct2), eq(device2), anyLong());\n\n    assertThat(device1.getLastSeen()).isEqualTo(yesterday);\n    assertThat(device2.getLastSeen()).isEqualTo(today);\n\n    assertThat(acct1).isSameAs(updatedAcct1);\n    assertThat(acct2).isNotSameAs(updatedAcct2);\n  }\n\n  @Test\n  void testUpdateLastSeenStartOfDay() {\n    clock.pin(Instant.ofEpochMilli(today));\n\n    final Device device1 = acct1.getDevices().stream().findFirst().orElseThrow();\n    final Device device2 = acct2.getDevices().stream().findFirst().orElseThrow();\n\n    final Account updatedAcct1 = accountAuthenticator.updateLastSeen(acct1, device1);\n    final Account updatedAcct2 = accountAuthenticator.updateLastSeen(acct2, device2);\n\n    verify(accountsManager, never()).updateDeviceLastSeen(eq(acct1), any(), anyLong());\n    verify(accountsManager, never()).updateDeviceLastSeen(eq(acct2), any(), anyLong());\n\n    assertThat(device1.getLastSeen()).isEqualTo(yesterday);\n    assertThat(device2.getLastSeen()).isEqualTo(yesterday);\n\n    assertThat(acct1).isSameAs(updatedAcct1);\n    assertThat(acct2).isSameAs(updatedAcct2);\n  }\n\n  @Test\n  void testUpdateLastSeenEndOfDay() {\n    clock.pin(Instant.ofEpochMilli(today + 86_400_000L - 1));\n\n    final Device device1 = acct1.getDevices().stream().findFirst().orElseThrow();\n    final Device device2 = acct2.getDevices().stream().findFirst().orElseThrow();\n\n    final Account updatedAcct1 = accountAuthenticator.updateLastSeen(acct1, device1);\n    final Account updatedAcct2 = accountAuthenticator.updateLastSeen(acct2, device2);\n\n    verify(accountsManager).updateDeviceLastSeen(eq(acct1), eq(device1), anyLong());\n    verify(accountsManager).updateDeviceLastSeen(eq(acct2), eq(device2), anyLong());\n\n    assertThat(device1.getLastSeen()).isEqualTo(today);\n    assertThat(device2.getLastSeen()).isEqualTo(today);\n\n    assertThat(updatedAcct1).isNotSameAs(acct1);\n    assertThat(updatedAcct2).isNotSameAs(acct2);\n  }\n\n  @Test\n  void testNeverWriteYesterday() {\n    clock.pin(Instant.ofEpochMilli(today));\n\n    final Device device = oldAccount.getDevices().stream().findFirst().orElseThrow();\n\n    accountAuthenticator.updateLastSeen(oldAccount, device);\n\n    verify(accountsManager).updateDeviceLastSeen(eq(oldAccount), eq(device), anyLong());\n\n    assertThat(device.getLastSeen()).isEqualTo(today);\n  }\n\n  @Test\n  void testAuthenticate() {\n    final UUID uuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final String password = \"12345\";\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final SaltedTokenHash credentials = mock(SaltedTokenHash.class);\n\n    clock.unpin();\n    when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    when(account.getUuid()).thenReturn(uuid);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(account.getPrimaryDevice()).thenReturn(device);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getAuthTokenHash()).thenReturn(credentials);\n    when(credentials.verify(password)).thenReturn(true);\n    when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION);\n\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount =\n        accountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password));\n\n    assertThat(maybeAuthenticatedAccount).isPresent();\n    assertThat(maybeAuthenticatedAccount.orElseThrow().accountIdentifier()).isEqualTo(uuid);\n    assertThat(maybeAuthenticatedAccount.orElseThrow().deviceId()).isEqualTo(device.getId());\n    verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());\n  }\n\n  @Test\n  void testAuthenticateNonDefaultDevice() {\n    final UUID uuid = UUID.randomUUID();\n    final byte deviceId = 2;\n    final String password = \"12345\";\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final SaltedTokenHash credentials = mock(SaltedTokenHash.class);\n\n    clock.unpin();\n    when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    when(account.getUuid()).thenReturn(uuid);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(account.getPrimaryDevice()).thenReturn(device);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getAuthTokenHash()).thenReturn(credentials);\n    when(credentials.verify(password)).thenReturn(true);\n    when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION);\n\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount =\n        accountAuthenticator.authenticate(new BasicCredentials(uuid + \".\" + deviceId, password));\n\n    assertThat(maybeAuthenticatedAccount).isPresent();\n    assertThat(maybeAuthenticatedAccount.orElseThrow().accountIdentifier()).isEqualTo(uuid);\n    assertThat(maybeAuthenticatedAccount.orElseThrow().deviceId()).isEqualTo(device.getId());\n    verify(accountsManager, never()).updateDeviceAuthentication(any(), any(), any());\n  }\n\n  @CartesianTest\n  void testAuthenticateEnabled(\n      @CartesianTest.Values(booleans = {true, false}) final boolean authenticatedDeviceIsPrimary) {\n    final UUID uuid = UUID.randomUUID();\n    final byte deviceId = (byte) (authenticatedDeviceIsPrimary ? 1 : 2);\n    final String password = \"12345\";\n\n    final Account account = mock(Account.class);\n    final Device authenticatedDevice = mock(Device.class);\n    final SaltedTokenHash credentials = mock(SaltedTokenHash.class);\n\n    clock.unpin();\n    when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    when(account.getUuid()).thenReturn(uuid);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(authenticatedDevice));\n    when(account.getPrimaryDevice()).thenReturn(authenticatedDevice);\n    when(authenticatedDevice.getId()).thenReturn(deviceId);\n    when(authenticatedDevice.getAuthTokenHash()).thenReturn(credentials);\n    when(credentials.verify(password)).thenReturn(true);\n    when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION);\n\n    final String identifier;\n    if (authenticatedDeviceIsPrimary) {\n      identifier = uuid.toString();\n    } else {\n      identifier = uuid.toString() + AccountAuthenticator.DEVICE_ID_SEPARATOR + deviceId;\n    }\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount =\n        accountAuthenticator.authenticate(new BasicCredentials(identifier, password));\n\n    assertThat(maybeAuthenticatedAccount).isPresent();\n    assertThat(maybeAuthenticatedAccount.orElseThrow().accountIdentifier()).isEqualTo(uuid);\n    assertThat(maybeAuthenticatedAccount.orElseThrow().deviceId()).isEqualTo(authenticatedDevice.getId());\n  }\n\n  @Test\n  void testAuthenticateV1() {\n    final UUID uuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final String password = \"12345\";\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final SaltedTokenHash credentials = mock(SaltedTokenHash.class);\n\n    clock.unpin();\n    when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    when(account.getUuid()).thenReturn(uuid);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(account.getPrimaryDevice()).thenReturn(device);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getAuthTokenHash()).thenReturn(credentials);\n    when(credentials.verify(password)).thenReturn(true);\n    when(credentials.getVersion()).thenReturn(SaltedTokenHash.Version.V1);\n\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount =\n        accountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), password));\n\n    assertThat(maybeAuthenticatedAccount).isPresent();\n    assertThat(maybeAuthenticatedAccount.orElseThrow().accountIdentifier()).isEqualTo(uuid);\n    assertThat(maybeAuthenticatedAccount.orElseThrow().deviceId()).isEqualTo(device.getId());\n    verify(accountsManager, times(1)).updateDeviceAuthentication(\n        any(), // this won't be 'account', because it'll already be updated by updateDeviceLastSeen\n        eq(device), any());\n  }\n  @Test\n  void testAuthenticateAccountNotFound() {\n    assertThat(accountAuthenticator.authenticate(new BasicCredentials(UUID.randomUUID().toString(), \"password\")))\n        .isEmpty();\n  }\n\n  @Test\n  void testAuthenticateDeviceNotFound() {\n    final UUID uuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final String password = \"12345\";\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final SaltedTokenHash credentials = mock(SaltedTokenHash.class);\n\n    clock.unpin();\n    when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    when(account.getUuid()).thenReturn(uuid);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(account.getPrimaryDevice()).thenReturn(device);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getAuthTokenHash()).thenReturn(credentials);\n    when(credentials.verify(password)).thenReturn(true);\n    when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION);\n\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount =\n        accountAuthenticator.authenticate(new BasicCredentials(uuid + \".\" + (deviceId + 1), password));\n\n    assertThat(maybeAuthenticatedAccount).isEmpty();\n    verify(account).getDevice((byte) (deviceId + 1));\n  }\n\n  @Test\n  void testAuthenticateIncorrectPassword() {\n    final UUID uuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final String password = \"12345\";\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final SaltedTokenHash credentials = mock(SaltedTokenHash.class);\n\n    clock.unpin();\n    when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    when(account.getUuid()).thenReturn(uuid);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(account.getPrimaryDevice()).thenReturn(device);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getAuthTokenHash()).thenReturn(credentials);\n    when(credentials.verify(password)).thenReturn(true);\n    when(credentials.getVersion()).thenReturn(SaltedTokenHash.CURRENT_VERSION);\n\n    final String incorrectPassword = password + \"incorrect\";\n\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount =\n        accountAuthenticator.authenticate(new BasicCredentials(uuid.toString(), incorrectPassword));\n\n    assertThat(maybeAuthenticatedAccount).isEmpty();\n    verify(credentials).verify(incorrectPassword);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testAuthenticateMalformedCredentials(final String username) {\n    final Optional<AuthenticatedDevice> maybeAuthenticatedAccount = assertDoesNotThrow(\n        () -> accountAuthenticator.authenticate(new BasicCredentials(username, \"password\")));\n\n    assertThat(maybeAuthenticatedAccount).isEmpty();\n    verify(accountsManager, never()).getByAccountIdentifier(any(UUID.class));\n  }\n\n  private static Stream<String> testAuthenticateMalformedCredentials() {\n    return Stream.of(\n        \"\",\n        \".4\",\n        \"This is definitely not a valid UUID\",\n        UUID.randomUUID() + \".\");\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testGetIdentifierAndDeviceId(final String username, final String expectedIdentifier,\n      final byte expectedDeviceId) {\n    final Pair<String, Byte> identifierAndDeviceId = AccountAuthenticator.getIdentifierAndDeviceId(username);\n\n    assertEquals(expectedIdentifier, identifierAndDeviceId.first());\n    assertEquals(expectedDeviceId, identifierAndDeviceId.second());\n  }\n\n  private static Stream<Arguments> testGetIdentifierAndDeviceId() {\n    return Stream.of(\n        Arguments.of(\"\", \"\", Device.PRIMARY_ID),\n        Arguments.of(\"test\", \"test\", Device.PRIMARY_ID),\n        Arguments.of(\"test.7\", \"test\", (byte) 7));\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\n      \".\",\n      \".....\",\n      \"test.7.8\",\n      \"test.\"\n  })\n  void testGetIdentifierAndDeviceIdMalformed(final String malformedUsername) {\n    assertThrows(IllegalArgumentException.class,\n        () -> AccountAuthenticator.getIdentifierAndDeviceId(malformedUsername));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/BasicAuthorizationHeaderTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass BasicAuthorizationHeaderTest {\n\n  @Test\n  void fromString() throws InvalidAuthorizationHeaderException {\n    {\n      final BasicAuthorizationHeader header =\n          BasicAuthorizationHeader.fromString(\"Basic YWxhZGRpbjpvcGVuc2VzYW1l\");\n\n      assertEquals(\"aladdin\", header.getUsername());\n      assertEquals(\"opensesame\", header.getPassword());\n      assertEquals(Device.PRIMARY_ID, header.getDeviceId());\n    }\n\n    {\n      final BasicAuthorizationHeader header = BasicAuthorizationHeader.fromString(\"Basic \" +\n          Base64.getEncoder().encodeToString(\"username.7:password\".getBytes(StandardCharsets.UTF_8)));\n\n      assertEquals(\"username\", header.getUsername());\n      assertEquals(\"password\", header.getPassword());\n      assertEquals(7, header.getDeviceId());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void fromStringMalformed(final String header) {\n    assertThrows(InvalidAuthorizationHeaderException.class,\n        () -> BasicAuthorizationHeader.fromString(header));\n  }\n\n  private static Stream<String> fromStringMalformed() {\n    return Stream.of(\n        null,\n        \"\",\n        \"   \",\n        \"Obviously not a valid authorization header\",\n        \"Digest YWxhZGRpbjpvcGVuc2VzYW1l\",\n        \"Basic\",\n        \"Basic \",\n        \"Basic &&&&&&\",\n        \"Basic \" + Base64.getEncoder().encodeToString(\"\".getBytes(StandardCharsets.UTF_8)),\n        \"Basic \" + Base64.getEncoder().encodeToString(\":\".getBytes(StandardCharsets.UTF_8)),\n        \"Basic \" + Base64.getEncoder().encodeToString(\"test\".getBytes(StandardCharsets.UTF_8)),\n        \"Basic \" + Base64.getEncoder().encodeToString(\"test.\".getBytes(StandardCharsets.UTF_8)),\n        \"Basic \" + Base64.getEncoder().encodeToString(\"test.:\".getBytes(StandardCharsets.UTF_8)),\n        \"Basic \" + Base64.getEncoder().encodeToString(\"test.:password\".getBytes(StandardCharsets.UTF_8)),\n        \"Basic \" + Base64.getEncoder().encodeToString(\":password\".getBytes(StandardCharsets.UTF_8)));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/CertificateGeneratorTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.io.IOException;\nimport java.util.Base64;\nimport java.util.UUID;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECPrivateKey;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass CertificateGeneratorTest {\n\n  private static final byte[] SIGNING_CERTIFICATE_DATA;\n  // This arbitrary test ID is embedded in the serialized certificate\n  private static final int SIGNING_CERTIFICATE_ID = 12;\n  private static final ECPrivateKey SIGNING_KEY;\n  private static final IdentityKey IDENTITY_KEY;\n  private static final UUID ACI = UUID.randomUUID();\n  private static final String E164 = PhoneNumberUtil.getInstance()\n      .format(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n  static {\n    try {\n      SIGNING_CERTIFICATE_DATA = Base64.getDecoder().decode(\"CiUIDBIhBbTz4h1My+tt+vw+TVscgUe/DeHS0W02tPWAWbTO2xc3EkD+go4bJnU0AcnFfbOLKoiBfCzouZtDYMOVi69rE7r4U9cXREEqOkUmU2WJBjykAxWPCcSTmVTYHDw7hkSp/puG\");\n      SIGNING_KEY = new ECPrivateKey(Base64.getDecoder().decode(\"ABOxG29xrfq4E7IrW11Eg7+HBbtba9iiS0500YoBjn4=\"));\n      IDENTITY_KEY = new IdentityKey(Base64.getDecoder().decode(\"BcxxDU9FGMda70E7+Uvm7pnQcEdXQ64aJCpPUeRSfcFo\"));\n    } catch (org.signal.libsignal.protocol.InvalidKeyException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  @CartesianTest\n  @ValueSource(booleans = {true, false})\n  void testCreateFor(@CartesianTest.Values(booleans = {true, false}) boolean includeE164,\n      @CartesianTest.Values(booleans = {true, false}) boolean embedSigner)\n      throws IOException, org.signal.libsignal.protocol.InvalidKeyException {\n    final Account account = mock(Account.class);\n    final byte deviceId = 4;\n    final CertificateGenerator certificateGenerator = new CertificateGenerator(\n        SIGNING_CERTIFICATE_DATA, SIGNING_KEY, 1, embedSigner);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(IDENTITY_KEY);\n    when(account.getUuid()).thenReturn(ACI);\n    when(account.getNumber()).thenReturn(E164);\n\n    final byte[] contents = certificateGenerator.createFor(account, deviceId, includeE164);\n    final SenderCertificate fullCertificate = SenderCertificate.parseFrom(contents);\n    final SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom(fullCertificate.getCertificate());\n    assertEquals(deviceId, certificate.getSenderDevice());\n    assertEquals(UUIDUtil.toByteString(ACI), certificate.getSenderUuid());\n    assertEquals(includeE164 ? E164 : \"\", certificate.getSenderE164());\n    assertArrayEquals(IDENTITY_KEY.serialize(), certificate.getIdentityKey().toByteArray());\n    assertTrue(certificate.getExpires() > 0);\n\n    final ECPublicKey signingKey;\n    if (embedSigner) {\n      // Make sure we can produce certificates with embedded signers, in case of a future rotation\n      assertFalse(certificate.hasSignerId());\n      assertArrayEquals(SIGNING_CERTIFICATE_DATA, certificate.getSignerCertificate().toByteArray());\n\n      final byte[] signingKeyBytes = ServerCertificate.Certificate.parseFrom(\n          certificate.getSignerCertificate().getCertificate()).getKey().toByteArray();\n      signingKey = new ECPublicKey(signingKeyBytes);\n    } else {\n      assertFalse(certificate.hasSignerCertificate());\n      assertEquals(SIGNING_CERTIFICATE_ID, certificate.getSignerId());\n      signingKey = SIGNING_KEY.publicKey();\n    }\n\n    assertTrue(signingKey\n        .verifySignature(fullCertificate.getCertificate().toByteArray(), fullCertificate.getSignature().toByteArray()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/CloudflareTurnCredentialsManagerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static com.github.tomakehurst.wiremock.client.WireMock.created;\nimport static com.github.tomakehurst.wiremock.client.WireMock.equalTo;\nimport static com.github.tomakehurst.wiremock.client.WireMock.equalToJson;\nimport static com.github.tomakehurst.wiremock.client.WireMock.post;\nimport static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;\nimport static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.github.tomakehurst.wiremock.junit5.WireMockExtension;\nimport io.netty.resolver.dns.DnsNameResolver;\nimport io.netty.util.concurrent.GlobalEventExecutor;\nimport io.netty.util.concurrent.SucceededFuture;\nimport java.io.IOException;\nimport java.net.InetAddress;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.concurrent.CancellationException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\n\npublic class CloudflareTurnCredentialsManagerTest {\n  @RegisterExtension\n  private static final WireMockExtension wireMock = WireMockExtension.newInstance()\n      .options(wireMockConfig().dynamicPort().dynamicHttpsPort())\n      .build();\n\n  private ExecutorService httpExecutor;\n  private ScheduledExecutorService retryExecutor;\n  private DnsNameResolver dnsResolver;\n\n  private CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager;\n\n  private static final String GET_CREDENTIALS_PATH = \"/v1/turn/keys/LMNOP/credentials/generate\";\n  private static final String TURN_HOSTNAME = \"localhost\";\n\n  private static final String API_TOKEN = RandomStringUtils.insecure().nextAlphanumeric(16);\n  private static final String USERNAME = RandomStringUtils.insecure().nextAlphanumeric(16);\n  private static final String CREDENTIAL = RandomStringUtils.insecure().nextAlphanumeric(16);\n  private static final List<String> CLOUDFLARE_TURN_URLS = List.of(\"turn:cf.example.com\");\n  private static final Duration REQUESTED_CREDENTIAL_TTL = Duration.ofSeconds(100);\n  private static final Duration CLIENT_CREDENTIAL_TTL = REQUESTED_CREDENTIAL_TTL.dividedBy(2);\n  private static final List<String> IP_URL_PATTERNS = List.of(\"turn:%s\", \"turn:%s:80?transport=tcp\", \"turns:%s:443?transport=tcp\");\n\n  @BeforeEach\n  void setUp() {\n    httpExecutor = Executors.newSingleThreadExecutor();\n    retryExecutor = Executors.newSingleThreadScheduledExecutor();\n\n    dnsResolver = mock(DnsNameResolver.class);\n\n    cloudflareTurnCredentialsManager = new CloudflareTurnCredentialsManager(\n        API_TOKEN,\n        \"http://localhost:\" + wireMock.getPort() + GET_CREDENTIALS_PATH,\n        REQUESTED_CREDENTIAL_TTL,\n        CLIENT_CREDENTIAL_TTL,\n        CLOUDFLARE_TURN_URLS,\n        IP_URL_PATTERNS,\n        TURN_HOSTNAME,\n        2,\n        null,\n        httpExecutor,\n        null,\n        retryExecutor,\n        dnsResolver\n    );\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    httpExecutor.shutdown();\n    retryExecutor.shutdown();\n\n    //noinspection ResultOfMethodCallIgnored\n    httpExecutor.awaitTermination(1, TimeUnit.SECONDS);\n\n    //noinspection ResultOfMethodCallIgnored\n    retryExecutor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  public void testSuccess() throws IOException, CancellationException {\n    wireMock.stubFor(post(urlEqualTo(GET_CREDENTIALS_PATH))\n        .willReturn(created()\n            .withHeader(\"Content-Type\", \"application/json\")\n            .withBody(\"\"\"\n                {\n                   \"iceServers\": {\n                     \"urls\": [\n                       \"turn:cloudflare.example.com:3478?transport=udp\"\n                     ],\n                     \"username\": \"%s\",\n                     \"credential\": \"%s\"\n                   }\n                 }\n                \"\"\".formatted(USERNAME, CREDENTIAL))));\n\n    when(dnsResolver.resolveAll(TURN_HOSTNAME))\n        .thenReturn(new SucceededFuture<>(GlobalEventExecutor.INSTANCE,\n            List.of(InetAddress.getByName(\"127.0.0.1\"), InetAddress.getByName(\"::1\"))));\n\n    TurnToken token = cloudflareTurnCredentialsManager.retrieveFromCloudflare();\n\n    wireMock.verify(postRequestedFor(urlEqualTo(GET_CREDENTIALS_PATH))\n        .withHeader(\"Content-Type\", equalTo(\"application/json\"))\n        .withHeader(\"Authorization\", equalTo(\"Bearer \" + API_TOKEN))\n        .withRequestBody(equalToJson(\"\"\"\n            {\n              \"ttl\": %d\n            }\n            \"\"\".formatted(REQUESTED_CREDENTIAL_TTL.toSeconds()))));\n\n    assertThat(token.username()).isEqualTo(USERNAME);\n    assertThat(token.password()).isEqualTo(CREDENTIAL);\n    assertThat(token.hostname()).isEqualTo(TURN_HOSTNAME);\n    assertThat(token.urls()).isEqualTo(CLOUDFLARE_TURN_URLS);\n    assertThat(token.ttlSeconds()).isEqualTo(CLIENT_CREDENTIAL_TTL.toSeconds());\n\n    final List<String> expectedUrlsWithIps = new ArrayList<>();\n\n    for (final String ip : new String[] {\"127.0.0.1\", \"[0:0:0:0:0:0:0:1]\"}) {\n      for (final String pattern : IP_URL_PATTERNS) {\n        expectedUrlsWithIps.add(pattern.formatted(ip));\n      }\n    }\n\n    assertThat(token.urlsWithIps()).containsExactlyElementsOf(expectedUrlsWithIps);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/DisconnectionRequestManagerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.RedisServerExtension;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass DisconnectionRequestManagerTest {\n\n  private DisconnectionRequestManager disconnectionRequestManager;\n\n  @RegisterExtension\n  static final RedisServerExtension REDIS_EXTENSION = RedisServerExtension.builder().build();\n\n  @BeforeEach\n  void setUp() {\n\n    disconnectionRequestManager = new DisconnectionRequestManager(REDIS_EXTENSION.getRedisClient(),\n        Runnable::run,\n        mock(ScheduledExecutorService.class));\n\n    disconnectionRequestManager.start();\n  }\n\n  @AfterEach\n  void tearDown() {\n    disconnectionRequestManager.stop();\n  }\n\n  @Test\n  void addRemoveListener() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final DisconnectionRequestListener firstListener = mock(DisconnectionRequestListener.class);\n    final DisconnectionRequestListener secondListener = mock(DisconnectionRequestListener.class);\n\n    assertTrue(disconnectionRequestManager.getListeners(accountIdentifier, deviceId).isEmpty());\n\n    disconnectionRequestManager.addListener(accountIdentifier, deviceId, firstListener);\n\n    assertEquals(List.of(firstListener), disconnectionRequestManager.getListeners(accountIdentifier, deviceId));\n\n    disconnectionRequestManager.addListener(accountIdentifier, deviceId, secondListener);\n\n    assertEquals(List.of(firstListener, secondListener),\n        disconnectionRequestManager.getListeners(accountIdentifier, deviceId));\n\n    disconnectionRequestManager.removeListener(accountIdentifier, deviceId, mock(DisconnectionRequestListener.class));\n\n    assertEquals(List.of(firstListener, secondListener),\n        disconnectionRequestManager.getListeners(accountIdentifier, deviceId));\n\n    disconnectionRequestManager.removeListener(accountIdentifier, deviceId, firstListener);\n\n    assertEquals(List.of(secondListener), disconnectionRequestManager.getListeners(accountIdentifier, deviceId));\n  }\n\n  @Test\n  void requestDisconnection() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte primaryDeviceId = Device.PRIMARY_ID;\n    final byte linkedDeviceId = primaryDeviceId + 1;\n\n    final UUID otherAccountIdentifier = UUID.randomUUID();\n    final byte otherDeviceId = linkedDeviceId + 1;\n\n    final List<Byte> deviceIds = List.of(primaryDeviceId, linkedDeviceId);\n\n    final DisconnectionRequestListener primaryDeviceListener = mock(DisconnectionRequestListener.class);\n    final DisconnectionRequestListener linkedDeviceListener = mock(DisconnectionRequestListener.class);\n\n    disconnectionRequestManager.addListener(accountIdentifier, primaryDeviceId, primaryDeviceListener);\n    disconnectionRequestManager.addListener(accountIdentifier, linkedDeviceId, linkedDeviceListener);\n\n    disconnectionRequestManager.requestDisconnection(accountIdentifier, deviceIds).toCompletableFuture().join();\n\n    verify(primaryDeviceListener, timeout(1_000)).handleDisconnectionRequest();\n    verify(linkedDeviceListener, timeout(1_000)).handleDisconnectionRequest();\n\n    disconnectionRequestManager.requestDisconnection(otherAccountIdentifier, List.of(otherDeviceId));\n  }\n\n  @Test\n  void requestDisconnectionAllDevices() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte primaryDeviceId = Device.PRIMARY_ID;\n    final byte linkedDeviceId = primaryDeviceId + 1;\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(primaryDeviceId);\n\n    final Device linkedDevice = mock(Device.class);\n    when(linkedDevice.getId()).thenReturn(linkedDeviceId);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n\n    final DisconnectionRequestListener primaryDeviceListener = mock(DisconnectionRequestListener.class);\n    final DisconnectionRequestListener linkedDeviceListener = mock(DisconnectionRequestListener.class);\n\n    disconnectionRequestManager.addListener(accountIdentifier, primaryDeviceId, primaryDeviceListener);\n    disconnectionRequestManager.addListener(accountIdentifier, linkedDeviceId, linkedDeviceListener);\n\n    disconnectionRequestManager.requestDisconnection(account).toCompletableFuture().join();\n\n    verify(primaryDeviceListener, timeout(1_000)).handleDisconnectionRequest();\n    verify(linkedDeviceListener, timeout(1_000)).handleDisconnectionRequest();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsGeneratorTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.whispersystems.textsecuregcm.util.HmacUtils.hmac256TruncatedToHexString;\n\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\n\nclass ExternalServiceCredentialsGeneratorTest {\n  private static final String PREFIX = \"prefix\";\n\n  private static final String E164 = \"+14152222222\";\n\n  private static final long TIME_SECONDS = 12345;\n\n  private static final long TIME_MILLIS = TimeUnit.SECONDS.toMillis(TIME_SECONDS);\n\n  private static final String TIME_SECONDS_STRING = Long.toString(TIME_SECONDS);\n\n  private static final String USERNAME_TIMESTAMP = PREFIX + \":\" + Instant.ofEpochSecond(TIME_SECONDS).truncatedTo(ChronoUnit.DAYS).getEpochSecond();\n\n  private static final MutableClock clock = MockUtils.mutableClock(TIME_MILLIS);\n\n  private static final ExternalServiceCredentialsGenerator standardGenerator = ExternalServiceCredentialsGenerator\n      .builder(new byte[32])\n      .withClock(clock)\n      .build();\n\n  private static final ExternalServiceCredentials standardCredentials = standardGenerator.generateFor(E164);\n\n  private static final ExternalServiceCredentialsGenerator usernameIsTimestampGenerator = ExternalServiceCredentialsGenerator\n      .builder(new byte[32])\n      .withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), PREFIX)\n      .withClock(clock)\n      .build();\n\n  private static final ExternalServiceCredentials usernameIsTimestampCredentials = usernameIsTimestampGenerator.generateWithTimestampAsUsername();\n\n  @BeforeEach\n  public void before() throws Exception {\n    clock.setTimeMillis(TIME_MILLIS);\n  }\n\n  @Test\n  void testInvalidConstructor() {\n    assertThrows(RuntimeException.class, () -> ExternalServiceCredentialsGenerator\n        .builder(new byte[32])\n        .withUsernameTimestampTruncatorAndPrefix(null, PREFIX)\n        .build());\n\n    assertThrows(RuntimeException.class, () -> ExternalServiceCredentialsGenerator\n        .builder(new byte[32])\n        .withUsernameTimestampTruncatorAndPrefix(timestamp -> timestamp.truncatedTo(ChronoUnit.DAYS), null)\n        .build());\n  }\n\n  @Test\n  void testGenerateDerivedUsername() {\n    final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator\n        .builder(new byte[32])\n        .withUserDerivationKey(new byte[32])\n        .build();\n    final ExternalServiceCredentials credentials = generator.generateFor(E164);\n    assertNotEquals(credentials.username(), E164);\n    assertFalse(credentials.password().startsWith(E164));\n    assertEquals(credentials.password().split(\":\").length, 3);\n  }\n\n  @Test\n  void testGenerateNoDerivedUsername() {\n    assertEquals(standardCredentials.username(), E164);\n    assertTrue(standardCredentials.password().startsWith(E164));\n    assertEquals(standardCredentials.password().split(\":\").length, 3);\n  }\n\n  @Test\n  public void testNotPrependUsername() throws Exception {\n    final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator\n        .builder(new byte[32])\n        .prependUsername(false)\n        .withClock(clock)\n        .build();\n    final ExternalServiceCredentials credentials = generator.generateFor(E164);\n    assertEquals(credentials.username(), E164);\n    assertTrue(credentials.password().startsWith(TIME_SECONDS_STRING));\n    assertEquals(credentials.password().split(\":\").length, 2);\n  }\n\n  @Test\n  public void testWithUsernameIsTimestamp() {\n    assertEquals(USERNAME_TIMESTAMP, usernameIsTimestampCredentials.username());\n\n    final String[] passwordComponents = usernameIsTimestampCredentials.password().split(\":\");\n    assertEquals(USERNAME_TIMESTAMP, passwordComponents[0] + \":\" + passwordComponents[1]);\n    assertEquals(hmac256TruncatedToHexString(new byte[32], USERNAME_TIMESTAMP, 10), passwordComponents[2]);\n  }\n\n  @Test\n  public void testValidateValid() throws Exception {\n    assertEquals(standardGenerator.validateAndGetTimestamp(standardCredentials).orElseThrow(), TIME_SECONDS);\n  }\n\n  @Test\n  public void testValidateValidWithUsernameIsTimestamp() {\n    final long expectedTimestamp = Instant.ofEpochSecond(TIME_SECONDS).truncatedTo(ChronoUnit.DAYS).getEpochSecond();\n    assertEquals(expectedTimestamp, usernameIsTimestampGenerator.validateAndGetTimestamp(usernameIsTimestampCredentials).orElseThrow());\n  }\n\n  @Test\n  public void testValidateInvalid() throws Exception {\n    final ExternalServiceCredentials corruptedStandardUsername = new ExternalServiceCredentials(\n        standardCredentials.username(), standardCredentials.password().replace(E164, E164 + \"0\"));\n    final ExternalServiceCredentials corruptedStandardTimestamp = new ExternalServiceCredentials(\n        standardCredentials.username(), standardCredentials.password().replace(TIME_SECONDS_STRING, TIME_SECONDS_STRING + \"0\"));\n    final ExternalServiceCredentials corruptedStandardPassword = new ExternalServiceCredentials(\n        standardCredentials.username(), standardCredentials.password() + \"0\");\n\n    final ExternalServiceCredentials corruptedUsernameTimestamp = new ExternalServiceCredentials(\n        usernameIsTimestampCredentials.username(), usernameIsTimestampCredentials.password().replace(USERNAME_TIMESTAMP, USERNAME_TIMESTAMP\n        + \"0\"));\n    final ExternalServiceCredentials corruptedUsernameTimestampPassword = new ExternalServiceCredentials(\n        usernameIsTimestampCredentials.username(), usernameIsTimestampCredentials.password() + \"0\");\n\n    assertTrue(standardGenerator.validateAndGetTimestamp(corruptedStandardUsername).isEmpty());\n    assertTrue(standardGenerator.validateAndGetTimestamp(corruptedStandardTimestamp).isEmpty());\n    assertTrue(standardGenerator.validateAndGetTimestamp(corruptedStandardPassword).isEmpty());\n\n    assertTrue(usernameIsTimestampGenerator.validateAndGetTimestamp(corruptedUsernameTimestamp).isEmpty());\n    assertTrue(usernameIsTimestampGenerator.validateAndGetTimestamp(corruptedUsernameTimestampPassword).isEmpty());\n  }\n\n  @Test\n  public void testValidateWithExpiration() throws Exception {\n    final long elapsedSeconds = 10000;\n    clock.incrementSeconds(elapsedSeconds);\n\n    final Long timestamp = standardGenerator.validateAndGetTimestamp(standardCredentials).orElseThrow();\n\n    assertFalse(standardGenerator.isCredentialExpired(timestamp, elapsedSeconds + 1));\n    assertTrue(standardGenerator.isCredentialExpired(timestamp, elapsedSeconds - 1));\n  }\n\n  @Test\n  public void testGetIdentityFromSignature() {\n    final String identity = standardGenerator.identityFromSignature(standardCredentials.password()).orElseThrow();\n    assertEquals(E164, identity);\n  }\n\n  @Test\n  public void testGetIdentityFromSignatureIsTimestamp() {\n    final String identity = usernameIsTimestampGenerator.identityFromSignature(usernameIsTimestampCredentials.password()).orElseThrow();\n    assertEquals(USERNAME_TIMESTAMP, identity);\n  }\n\n  @Test\n  public void testTruncateLength() throws Exception {\n    final ExternalServiceCredentialsGenerator generator = ExternalServiceCredentialsGenerator.builder(new byte[32])\n            .withUserDerivationKey(new byte[32])\n            .withDerivedUsernameTruncateLength(14)\n            .build();\n    final ExternalServiceCredentials creds = generator.generateFor(E164);\n    assertEquals(14*2 /* 2 chars per byte, because hex */, creds.username().length());\n    assertEquals(\"805b84df7eff1e8fe1baf0c6e838\", creds.username());\n    generator.validateAndGetTimestamp(creds);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/ExternalServiceCredentialsSelectorTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsSelector.CredentialInfo;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\npublic class ExternalServiceCredentialsSelectorTest {\n\n  private static final UUID UUID1 = UUID.randomUUID();\n  private static final UUID UUID2 = UUID.randomUUID();\n  private static final MutableClock CLOCK = MockUtils.mutableClock(TimeUnit.DAYS.toSeconds(1));\n\n  private static final ExternalServiceCredentialsGenerator GEN1 =\n      ExternalServiceCredentialsGenerator\n          .builder(TestRandomUtil.nextBytes(32))\n          .prependUsername(true)\n          .withClock(CLOCK)\n          .build();\n\n  private static final ExternalServiceCredentialsGenerator GEN2 =\n      ExternalServiceCredentialsGenerator\n          .builder(TestRandomUtil.nextBytes(32))\n          .withUserDerivationKey(TestRandomUtil.nextBytes(32))\n          .prependUsername(false)\n          .withDerivedUsernameTruncateLength(16)\n          .withClock(CLOCK)\n          .build();\n\n  private static ExternalServiceCredentials atTime(\n      final ExternalServiceCredentialsGenerator gen,\n      final long deltaMillis,\n      final UUID identity) {\n    final Instant old = CLOCK.instant();\n    try {\n      CLOCK.incrementMillis(deltaMillis);\n      return gen.generateForUuid(identity);\n    } finally {\n      CLOCK.setTimeInstant(old);\n    }\n  }\n\n  private static String token(final ExternalServiceCredentials cred) {\n    return cred.username() + \":\" + cred.password();\n  }\n\n  @Test\n  void single() {\n    final ExternalServiceCredentials cred = GEN1.generateForUuid(UUID1);\n    var result = ExternalServiceCredentialsSelector.check(\n        List.of(token(cred)), GEN1, TimeUnit.MINUTES.toSeconds(1));\n    assertThat(result).singleElement()\n        .matches(CredentialInfo::valid)\n        .matches(info -> info.credentials().equals(cred));\n  }\n\n  @Test\n  void multipleUsernames() {\n    final ExternalServiceCredentials cred1New = GEN1.generateForUuid(UUID1);\n    final ExternalServiceCredentials cred1Old = atTime(GEN1, -1, UUID1);\n\n    final ExternalServiceCredentials cred2New = GEN1.generateForUuid(UUID2);\n    final ExternalServiceCredentials cred2Old = atTime(GEN1, -1, UUID2);\n\n    final List<String> tokens = Stream.of(cred1New, cred1Old, cred2New, cred2Old)\n        .map(ExternalServiceCredentialsSelectorTest::token)\n        .toList();\n\n    final List<CredentialInfo> result = ExternalServiceCredentialsSelector.check(tokens, GEN1,\n        TimeUnit.MINUTES.toSeconds(1));\n    assertThat(result).hasSize(4);\n    assertThat(result).filteredOn(CredentialInfo::valid)\n        .hasSize(2)\n        .map(CredentialInfo::credentials)\n        .containsExactlyInAnyOrder(cred1New, cred2New);\n    assertThat(result).filteredOn(info -> !info.valid())\n        .map(CredentialInfo::token)\n        .containsExactlyInAnyOrder(token(cred1Old), token(cred2Old));\n  }\n\n  @Test\n  void multipleGenerators() {\n    final ExternalServiceCredentials gen1Cred = GEN1.generateForUuid(UUID1);\n    final ExternalServiceCredentials gen2Cred = GEN2.generateForUuid(UUID1);\n\n    final List<CredentialInfo> result = ExternalServiceCredentialsSelector.check(\n        List.of(token(gen1Cred), token(gen2Cred)),\n        GEN2,\n        TimeUnit.MINUTES.toSeconds(1));\n\n    assertThat(result)\n        .hasSize(2)\n        .filteredOn(CredentialInfo::valid)\n        .singleElement()\n        .matches(info -> info.credentials().equals(gen2Cred));\n\n    assertThat(result)\n        .filteredOn(info -> !info.valid())\n        .singleElement()\n        .matches(info -> info.token().equals(token(gen1Cred)));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void invalidCredentials(final String invalidCredential) {\n    final ExternalServiceCredentials validCredential = GEN1.generateForUuid(UUID1);\n    var result = ExternalServiceCredentialsSelector.check(\n        List.of(invalidCredential, token(validCredential)), GEN1, TimeUnit.MINUTES.toSeconds(1));\n    assertThat(result).hasSize(2);\n    assertThat(result).filteredOn(CredentialInfo::valid).singleElement()\n        .matches(info -> info.credentials().equals(validCredential));\n    assertThat(result).filteredOn(info -> !info.valid()).singleElement()\n        .matches(info -> info.token().equals(invalidCredential));\n  }\n\n  static Stream<String> invalidCredentials() {\n    return Stream.of(\n        \"blah:blah\",\n        token(atTime(GEN1, -TimeUnit.MINUTES.toSeconds(2), UUID1)), // too old\n        \"nocolon\",\n        \"nothingaftercolon:\",\n        \":nothingbeforecolon\",\n        token(GEN2.generateForUuid(UUID1))\n    );\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilterTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilterTest {\n\n  private IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter filter;\n\n  private static final Duration MIN_IDLE_DURATION = Duration.ofDays(30);\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  @BeforeEach\n  void setUp() {\n    filter = new IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter(MIN_IDLE_DURATION, CLOCK);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void handleAuthentication(@Nullable final AuthenticatedDevice authenticatedDevice,\n      @Nullable final String expectedAlertHeader) {\n\n    final Optional<AuthenticatedDevice> reusableAuth = authenticatedDevice != null\n        ? Optional.of(authenticatedDevice)\n        : Optional.empty();\n\n    final JettyServerUpgradeResponse response = mock(JettyServerUpgradeResponse.class);\n\n    filter.handleAuthentication(reusableAuth, mock(JettyServerUpgradeRequest.class), response);\n\n    if (expectedAlertHeader != null) {\n      verify(response).addHeader(IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.ALERT_HEADER, expectedAlertHeader);\n    } else {\n      verifyNoInteractions(response);\n    }\n  }\n\n  private static List<Arguments> handleAuthentication() {\n    final Instant activePrimaryDeviceLastSeen = CLOCK.instant();\n    final Instant idlePrimaryDeviceLastSeen = CLOCK.instant().minus(MIN_IDLE_DURATION).minusSeconds(1);\n\n    return List.of(\n        Arguments.argumentSet(\"Anonymous\",\n            null,\n            null),\n\n        Arguments.argumentSet(\"Authenticated as active primary device\",\n            new AuthenticatedDevice(UUID.randomUUID(), Device.PRIMARY_ID, activePrimaryDeviceLastSeen),\n            null),\n\n        Arguments.argumentSet(\"Authenticated as idle primary device\",\n            new AuthenticatedDevice(UUID.randomUUID(), Device.PRIMARY_ID, idlePrimaryDeviceLastSeen),\n            null),\n\n        Arguments.argumentSet(\"Authenticated as linked device with active primary device\",\n            new AuthenticatedDevice(UUID.randomUUID(), (byte) (Device.PRIMARY_ID + 1), activePrimaryDeviceLastSeen),\n            null),\n\n        Arguments.argumentSet(\"Authenticated as linked device with idle primary device\",\n            new AuthenticatedDevice(UUID.randomUUID(), (byte) (Device.PRIMARY_ID + 1), idlePrimaryDeviceLastSeen),\n            IdlePrimaryDeviceAuthenticatedWebSocketUpgradeFilter.IDLE_PRIMARY_DEVICE_ALERT)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/OptionalAccessTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.OptionalInt;\nimport java.util.UUID;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\nclass OptionalAccessTest {\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void verify(final Optional<Account> requestAccount,\n      final Optional<Anonymous> accessKey,\n      final Optional<Account> targetAccount,\n      final ServiceIdentifier targetIdentifier,\n      final String deviceSelector,\n      final OptionalInt expectedStatusCode) {\n\n    expectedStatusCode.ifPresentOrElse(statusCode -> {\n      final WebApplicationException webApplicationException = assertThrows(WebApplicationException.class,\n          () -> OptionalAccess.verify(requestAccount, accessKey, targetAccount, targetIdentifier, deviceSelector));\n\n      assertEquals(statusCode, webApplicationException.getResponse().getStatus());\n    }, () -> assertDoesNotThrow(() ->\n        OptionalAccess.verify(requestAccount, accessKey, targetAccount, targetIdentifier, deviceSelector)));\n  }\n\n  private static List<Arguments> verify() {\n    final String unidentifiedAccessKey = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    final Anonymous correctUakHeader =\n        new Anonymous(Base64.getEncoder().encodeToString(unidentifiedAccessKey.getBytes()));\n\n    final Anonymous incorrectUakHeader =\n        new Anonymous(Base64.getEncoder().encodeToString((unidentifiedAccessKey + \"-incorrect\").getBytes()));\n\n    final Account targetAccount = mock(Account.class);\n    final ServiceIdentifier targetAccountAciIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final ServiceIdentifier targetAccountPniIdentifier = new PniServiceIdentifier(UUID.randomUUID());\n    when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(mock(Device.class)));\n    when(targetAccount.getUnidentifiedAccessKey())\n        .thenReturn(Optional.of(unidentifiedAccessKey.getBytes(StandardCharsets.UTF_8)));\n    when(targetAccount.isIdentifiedBy(targetAccountAciIdentifier)).thenReturn(true);\n    when(targetAccount.isIdentifiedBy(targetAccountPniIdentifier)).thenReturn(true);\n\n    final Account allowAllTargetAccount = mock(Account.class);\n    final ServiceIdentifier allowAllTargetAccountPniIdentifier = new PniServiceIdentifier(UUID.randomUUID());\n    when(allowAllTargetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(mock(Device.class)));\n    when(allowAllTargetAccount.isUnrestrictedUnidentifiedAccess()).thenReturn(true);\n    when(allowAllTargetAccount.isIdentifiedBy(allowAllTargetAccountPniIdentifier)).thenReturn(true);\n\n    final Account noUakTargetAccount = mock(Account.class);\n    final ServiceIdentifier noUakTargetAccountAciIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    when(noUakTargetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(mock(Device.class)));\n    when(noUakTargetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.empty());\n    when(noUakTargetAccount.isIdentifiedBy(noUakTargetAccountAciIdentifier)).thenReturn(true);\n\n    final Account inactiveTargetAccount = mock(Account.class);\n    final ServiceIdentifier inactiveTargetAccountAciIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    when(inactiveTargetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(mock(Device.class)));\n    when(inactiveTargetAccount.getUnidentifiedAccessKey())\n        .thenReturn(Optional.of(unidentifiedAccessKey.getBytes(StandardCharsets.UTF_8)));\n    when(inactiveTargetAccount.isIdentifiedBy(inactiveTargetAccountAciIdentifier)).thenReturn(true);\n\n    return List.of(\n        Arguments.argumentSet(\"Unidentified caller; correct UAK\",\n            Optional.empty(),\n            Optional.of(correctUakHeader),\n            Optional.of(targetAccount),\n            targetAccountAciIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.empty()),\n\n        Arguments.argumentSet(\"Identified caller; no UAK needed\",\n            Optional.of(mock(Account.class)),\n            Optional.empty(),\n            Optional.of(targetAccount),\n            targetAccountAciIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.empty()),\n\n        Arguments.argumentSet(\"Unidentified caller; target account not found\",\n            Optional.empty(),\n            Optional.empty(),\n            Optional.empty(),\n            new AciServiceIdentifier(UUID.randomUUID()),\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.of(401)),\n\n        Arguments.argumentSet(\"Identified caller; target account not found\",\n            Optional.of(mock(Account.class)),\n            Optional.empty(),\n            Optional.empty(),\n            new AciServiceIdentifier(UUID.randomUUID()),\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.of(404)),\n\n        Arguments.argumentSet(\"Unidentified caller; target account found, but target device not found\",\n            Optional.empty(),\n            Optional.of(correctUakHeader),\n            Optional.of(targetAccount),\n            targetAccountAciIdentifier,\n            String.valueOf(Device.PRIMARY_ID + 1),\n            OptionalInt.of(401)),\n\n        Arguments.argumentSet(\"Unidentified caller; target account found, but incorrect UAK provided\",\n            Optional.empty(),\n            Optional.of(incorrectUakHeader),\n            Optional.of(targetAccount),\n            targetAccountAciIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.of(401)),\n\n        Arguments.argumentSet(\"Unidentified caller; target account found, but has no UAK\",\n            Optional.empty(),\n            Optional.of(correctUakHeader),\n            Optional.of(noUakTargetAccount),\n            noUakTargetAccountAciIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.of(401)),\n\n        Arguments.argumentSet(\"Unidentified caller; target account found, PNI target has priority over allows-unrestricted-unidentified-access\",\n            Optional.empty(),\n            Optional.of(incorrectUakHeader),\n            Optional.of(allowAllTargetAccount),\n            allowAllTargetAccountPniIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.of(401)),\n\n        Arguments.argumentSet(\"Unidentified caller; target account found, but inactive\",\n            Optional.empty(),\n            Optional.of(correctUakHeader),\n            Optional.of(inactiveTargetAccount),\n            inactiveTargetAccountAciIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.empty()),\n\n        Arguments.argumentSet(\"Malformed device ID\",\n            Optional.empty(),\n            Optional.of(correctUakHeader),\n            Optional.of(targetAccount),\n            targetAccountAciIdentifier,\n            \"not a valid identifier\",\n            OptionalInt.of(422)),\n\n        Arguments.argumentSet(\"Unidentified caller; target account found, but PNI identifier\",\n            Optional.empty(),\n            Optional.of(correctUakHeader),\n            Optional.of(targetAccount),\n            targetAccountPniIdentifier,\n            OptionalAccess.ALL_DEVICES_SELECTOR,\n            OptionalInt.of(401))\n    );\n  }\n\n  @Test\n  void testTargetIdentifierIllegalArgument() {\n    final String unidentifiedAccessKey = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    final Anonymous correctUakHeader =\n        new Anonymous(Base64.getEncoder().encodeToString(unidentifiedAccessKey.getBytes()));\n\n    final Account targetAccount = mock(Account.class);\n    when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(mock(Device.class)));\n    when(targetAccount.getUnidentifiedAccessKey())\n        .thenReturn(Optional.of(unidentifiedAccessKey.getBytes(StandardCharsets.UTF_8)));\n\n    assertThrows(IllegalArgumentException.class,\n        () -> OptionalAccess.verify(Optional.empty(), Optional.of(correctUakHeader), Optional.of(targetAccount),\n            new AciServiceIdentifier(UUID.randomUUID())));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/RedemptionRangeTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType;\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.StreamSupport;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\n\nclass RedemptionRangeTest {\n\n  static List<Arguments> invalidCredentialTimeWindows() {\n    final Duration max = RedemptionRange.MAX_REDEMPTION_DURATION;\n    final Instant day0 = Instant.EPOCH;\n    final Instant day1 = Instant.EPOCH.plus(Duration.ofDays(1));\n    final Instant day2 = Instant.EPOCH.plus(Duration.ofDays(2));\n    return List.of(\n        Arguments.argumentSet(\"non-truncated start\", Instant.ofEpochSecond(100), day0.plus(max),\n            Instant.ofEpochSecond(100)),\n        Arguments.argumentSet(\"non-truncated end\", day0, Instant.ofEpochSecond(1).plus(max),\n            Instant.ofEpochSecond(100)),\n        Arguments.argumentSet(\"start too old\", day0, day0.plus(max), day2),\n        Arguments.argumentSet(\"end too far in the future\", day2, day2.plus(max), day0),\n        Arguments.argumentSet(\"end before start\", day1, day0, day1),\n        Arguments.argumentSet(\"window too big\", day0, day0.plus(max).plus(Duration.ofDays(1)),  day1)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void invalidCredentialTimeWindows(final Instant requestRedemptionStart, final Instant requestRedemptionEnd,\n      final Instant now) {\n    final Clock clock = TestClock.pinned(now);\n    assertThatExceptionOfType(IllegalArgumentException.class)\n        .isThrownBy(() -> RedemptionRange.inclusive(clock, requestRedemptionStart, requestRedemptionEnd));\n  }\n\n  @Test\n  void allowUpToMax() {\n    final Instant now = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(100));\n    final Instant today = now.truncatedTo(ChronoUnit.DAYS);\n    final Clock clock = TestClock.pinned(now);\n    for (Duration d = Duration.ofDays(0);\n        d.compareTo(RedemptionRange.MAX_REDEMPTION_DURATION) <= 0;\n        d = d.plus(Duration.ofDays(1))) {\n      final Duration fd = d;\n      assertThatNoException().isThrownBy(() -> RedemptionRange.inclusive(clock, today, today.plus(fd)));\n    }\n  }\n\n  @Test\n  void allowBackwardsSkew() {\n    final Instant now = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(100));\n    final Instant yesterday = now.minus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS);\n    final Clock clock = TestClock.pinned(now);\n    assertThatNoException().isThrownBy(() ->\n        RedemptionRange.inclusive(clock, yesterday, yesterday.plus(RedemptionRange.MAX_REDEMPTION_DURATION)));\n  }\n\n  @Test\n  void allowForwardsSkew() {\n    final Instant now = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(100));\n    final Instant tomorrow = now.plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS);\n    final Clock clock = TestClock.pinned(now);\n    assertThatNoException().isThrownBy(() ->\n        RedemptionRange.inclusive(clock, tomorrow, tomorrow.plus(RedemptionRange.MAX_REDEMPTION_DURATION)));\n  }\n\n  @Test\n  void inclusiveRange() {\n    final Instant now = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(100));\n    final Instant today = now.truncatedTo(ChronoUnit.DAYS);\n    final Clock clock = TestClock.pinned(now);\n    for (int numDays = 0; numDays < 7; numDays++) {\n      final RedemptionRange range = RedemptionRange.inclusive(clock, today, today.plus(Duration.ofDays(numDays)));\n      final List<Instant> instants = StreamSupport.stream(range.spliterator(), false).toList();\n      assertThat(instants.size()).isEqualTo(numDays + 1);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockError.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\npublic enum RegistrationLockError {\n  MISMATCH(RegistrationLockVerificationManager.FAILURE_HTTP_STATUS),\n  RATE_LIMITED(429)\n  ;\n\n  private final int expectedStatus;\n\n  RegistrationLockError(final int expectedStatus) {\n    this.expectedStatus = expectedStatus;\n  }\n\n  public int getExpectedStatus() {\n    return expectedStatus;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/RegistrationLockVerificationManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.fail;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.clearInvocations;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.ws.rs.WebApplicationException;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.PhoneVerificationRequest;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.push.NotPushRegisteredException;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.util.Pair;\n\nclass RegistrationLockVerificationManagerTest {\n\n  private final AccountsManager accountsManager = mock(AccountsManager.class);\n  private final DisconnectionRequestManager disconnectionRequestManager = mock(DisconnectionRequestManager.class);\n  private final ExternalServiceCredentialsGenerator svr2CredentialsGenerator = mock(\n      ExternalServiceCredentialsGenerator.class);\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock(\n      RegistrationRecoveryPasswordsManager.class);\n  private final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class);\n  private final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private final RegistrationLockVerificationManager registrationLockVerificationManager = new RegistrationLockVerificationManager(\n      accountsManager, disconnectionRequestManager, svr2CredentialsGenerator, registrationRecoveryPasswordsManager,\n      pushNotificationManager, rateLimiters);\n\n  private final RateLimiter pinLimiter = mock(RateLimiter.class);\n\n  private Account account;\n  private StoredRegistrationLock existingRegistrationLock;\n\n  @BeforeEach\n  void setUp() {\n    clearInvocations(pushNotificationManager);\n    when(rateLimiters.getPinLimiter()).thenReturn(pinLimiter);\n    when(svr2CredentialsGenerator.generateForUuid(any()))\n        .thenReturn(mock(ExternalServiceCredentials.class));\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    AccountsHelper.setupMockUpdate(accountsManager);\n\n    account = mock(Account.class);\n    when(account.getUuid()).thenReturn(UUID.randomUUID());\n    when(account.getNumber()).thenReturn(\"+18005551212\");\n    when(account.getDevices()).thenReturn(List.of(device));\n\n    existingRegistrationLock = mock(StoredRegistrationLock.class);\n    when(account.getRegistrationLock()).thenReturn(existingRegistrationLock);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testErrors(RegistrationLockError error,\n      PhoneVerificationRequest.VerificationType verificationType,\n      @Nullable String clientRegistrationLock,\n      boolean alreadyLocked) throws Exception {\n\n    when(existingRegistrationLock.getStatus()).thenReturn(StoredRegistrationLock.Status.REQUIRED);\n    when(account.hasLockedCredentials()).thenReturn(alreadyLocked);\n    doThrow(new NotPushRegisteredException()).when(pushNotificationManager).sendAttemptLoginNotification(any(), any());\n\n    when(registrationRecoveryPasswordsManager.remove(any())).thenReturn(CompletableFuture.completedFuture(true));\n\n    final Pair<Class<? extends Exception>, Consumer<Exception>> exceptionType = switch (error) {\n      case MISMATCH -> {\n        when(existingRegistrationLock.verify(clientRegistrationLock)).thenReturn(false);\n        yield new Pair<>(WebApplicationException.class, e -> {\n          if (e instanceof WebApplicationException wae) {\n            assertEquals(RegistrationLockVerificationManager.FAILURE_HTTP_STATUS, wae.getResponse().getStatus());\n            if (!verificationType.equals(PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD) || clientRegistrationLock != null) {\n              verify(registrationRecoveryPasswordsManager).remove(account.getIdentifier(IdentityType.PNI));\n            } else {\n              verify(registrationRecoveryPasswordsManager, never()).remove(any());\n            }\n            verify(disconnectionRequestManager).requestDisconnection(account.getUuid(), List.of(Device.PRIMARY_ID));\n            try {\n              verify(pushNotificationManager).sendAttemptLoginNotification(any(), eq(\"failedRegistrationLock\"));\n            } catch (final NotPushRegisteredException ignored) {\n            }\n            if (alreadyLocked) {\n              verify(account, never()).lockAuthTokenHash();\n            } else {\n              verify(account).lockAuthTokenHash();\n            }\n          } else {\n            fail(\"Exception was not of expected type\");\n          }\n        });\n      }\n      case RATE_LIMITED -> {\n        when(existingRegistrationLock.verify(any())).thenReturn(true);\n        doThrow(RateLimitExceededException.class).when(pinLimiter).validate(anyString());\n        yield new Pair<>(RateLimitExceededException.class, ignored -> {\n          verify(account, never()).lockAuthTokenHash();\n\n          try {\n            verify(pushNotificationManager, never()).sendAttemptLoginNotification(any(), eq(\"failedRegistrationLock\"));\n          } catch (final NotPushRegisteredException ignored2) {\n          }\n\n          verify(registrationRecoveryPasswordsManager, never()).remove(any());\n          verify(disconnectionRequestManager, never()).requestDisconnection(any(), any());\n        });\n      }\n    };\n\n    final Exception e = assertThrows(exceptionType.first(), () ->\n        registrationLockVerificationManager.verifyRegistrationLock(account, clientRegistrationLock,\n            \"Signal-Android/4.68.3\", RegistrationLockVerificationManager.Flow.REGISTRATION,\n            verificationType));\n\n    exceptionType.second().accept(e);\n  }\n\n  static Stream<Arguments> testErrors() {\n    return Stream.of(\n        Arguments.of(RegistrationLockError.MISMATCH, PhoneVerificationRequest.VerificationType.SESSION, \"reglock\", true),\n        Arguments.of(RegistrationLockError.MISMATCH, PhoneVerificationRequest.VerificationType.SESSION, \"reglock\", false),\n        Arguments.of(RegistrationLockError.MISMATCH, PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD, \"reglock\", false),\n        Arguments.of(RegistrationLockError.MISMATCH, PhoneVerificationRequest.VerificationType.RECOVERY_PASSWORD, null, false),\n        Arguments.of(RegistrationLockError.RATE_LIMITED, PhoneVerificationRequest.VerificationType.SESSION, \"reglock\", false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testSuccess(final StoredRegistrationLock.Status status, @Nullable final String submittedRegistrationLock) {\n\n    when(existingRegistrationLock.getStatus())\n        .thenReturn(status);\n    when(existingRegistrationLock.verify(submittedRegistrationLock)).thenReturn(true);\n\n    assertDoesNotThrow(\n        () -> registrationLockVerificationManager.verifyRegistrationLock(account, submittedRegistrationLock,\n            \"Signal-Android/4.68.3\", RegistrationLockVerificationManager.Flow.REGISTRATION,\n            PhoneVerificationRequest.VerificationType.SESSION));\n\n    verify(account, never()).lockAuthTokenHash();\n    verify(registrationRecoveryPasswordsManager, never()).remove(any());\n    verify(disconnectionRequestManager, never()).requestDisconnection(any(), any());\n  }\n\n  static Stream<Arguments> testSuccess() {\n    return Stream.of(\n        Arguments.of(StoredRegistrationLock.Status.ABSENT, null),\n        Arguments.of(StoredRegistrationLock.Status.EXPIRED, null),\n        Arguments.of(StoredRegistrationLock.Status.REQUIRED, null),\n        Arguments.of(StoredRegistrationLock.Status.ABSENT, \"reglock\"),\n        Arguments.of(StoredRegistrationLock.Status.EXPIRED, \"reglock\"),\n        Arguments.of(StoredRegistrationLock.Status.REQUIRED, \"reglock\")\n    );\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/SaltedTokenHashTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\n\nimport org.junit.jupiter.api.Test;\n\nclass SaltedTokenHashTest {\n\n  @Test\n  void testCreating() {\n    SaltedTokenHash credentials = SaltedTokenHash.generateFor(\"mypassword\");\n    assertThat(credentials.salt()).isNotEmpty();\n    assertThat(credentials.hash()).isNotEmpty();\n    assertThat(credentials.hash().length()).isEqualTo(66);\n  }\n\n  @Test\n  void testMatching() {\n    SaltedTokenHash credentials = SaltedTokenHash.generateFor(\"mypassword\");\n\n    SaltedTokenHash provided = new SaltedTokenHash(credentials.hash(), credentials.salt());\n    assertThat(provided.verify(\"mypassword\")).isTrue();\n  }\n\n  @Test\n  void testMisMatching() {\n    SaltedTokenHash credentials = SaltedTokenHash.generateFor(\"mypassword\");\n\n    SaltedTokenHash provided = new SaltedTokenHash(credentials.hash(), credentials.salt());\n    assertThat(provided.verify(\"wrong\")).isFalse();\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/StoredRegistrationLockTest.java",
    "content": "package org.whispersystems.textsecuregcm.auth;\n\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport javax.swing.text.html.Option;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.spy;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.auth.StoredRegistrationLock.REGISTRATION_LOCK_EXPIRATION_DAYS;\n\npublic class StoredRegistrationLockTest {\n  @ParameterizedTest\n  @MethodSource\n  void getStatus(final Optional<String> registrationLock, final Optional<String> salt, final long lastSeen,\n      final StoredRegistrationLock.Status expectedStatus) {\n    final StoredRegistrationLock storedLock = new StoredRegistrationLock(registrationLock, salt, Instant.ofEpochMilli(lastSeen));\n\n    assertEquals(expectedStatus, storedLock.getStatus());\n  }\n\n  private static Stream<Arguments> getStatus() {\n    return Stream.of(\n        Arguments.of(Optional.of(\"registrationLock\"), Optional.of(\"salt\"), System.currentTimeMillis() - Duration.ofDays(1).toMillis(), StoredRegistrationLock.Status.REQUIRED),\n        Arguments.of(Optional.empty(), Optional.empty(), 0L, StoredRegistrationLock.Status.ABSENT),\n        Arguments.of(Optional.of(\"registrationLock\"), Optional.of(\"salt\"), System.currentTimeMillis() - REGISTRATION_LOCK_EXPIRATION_DAYS.toMillis(), StoredRegistrationLock.Status.EXPIRED)\n    );\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessChecksumTest.java",
    "content": "package org.whispersystems.textsecuregcm.auth;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport java.security.SecureRandom;\nimport java.util.Base64;\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\npublic class UnidentifiedAccessChecksumTest {\n  @ParameterizedTest\n  @MethodSource\n  public void generateFor(final byte[] unidentifiedAccessKey, final byte[] expectedChecksum) {\n    final byte[] checksum = UnidentifiedAccessChecksum.generateFor(unidentifiedAccessKey);\n\n    assertArrayEquals(expectedChecksum, checksum);\n  }\n\n  private static Stream<Arguments> generateFor() {\n    return Stream.of(\n        Arguments.of(Base64.getDecoder().decode(\"hqqo9upWeC0HSHOSJcXl/Q==\"),\n            Base64.getDecoder().decode(\"2DNxpQCjTefuEhdvJayIbAVUcZSXotu8nqXwWr+q6hI=\")),\n        Arguments.of(Base64.getDecoder().decode(\"0bNEmhGzmxBsDYhEhk+bAw==\"),\n            Base64.getDecoder().decode(\"gJTodQfP8TUITZhvrWr0t1siDZXYxRQ/qdpNB8jC+yc=\"))\n    );\n  }\n\n  @Test\n  public void generateForIllegalArgument() {\n    final byte[] invalidLengthUnidentifiedAccessKey = new byte[15];\n    new SecureRandom().nextBytes(invalidLengthUnidentifiedAccessKey);\n\n    assertThrows(IllegalArgumentException.class, () -> UnidentifiedAccessChecksum.generateFor(invalidLengthUnidentifiedAccessKey));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/UnidentifiedAccessUtilTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.security.SecureRandom;\nimport java.util.Optional;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\nclass UnidentifiedAccessUtilTest {\n\n  @ParameterizedTest\n  @MethodSource\n  void checkUnidentifiedAccess(@Nullable final byte[] targetUak,\n      final boolean unrestrictedUnidentifiedAccess,\n      final byte[] presentedUak,\n      final boolean expectAccessAllowed) {\n\n    final Account account = mock(Account.class);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.ofNullable(targetUak));\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(unrestrictedUnidentifiedAccess);\n\n    assertEquals(expectAccessAllowed, UnidentifiedAccessUtil.checkUnidentifiedAccess(account, presentedUak));\n  }\n\n  private static Stream<Arguments> checkUnidentifiedAccess() {\n    final byte[] uak = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH];\n    new SecureRandom().nextBytes(uak);\n\n    final byte[] incorrectUak = new byte[uak.length + 1];\n\n    return Stream.of(\n        Arguments.of(null, false, uak, false),\n        Arguments.of(null, true, uak, true),\n        Arguments.of(uak, false, incorrectUak, false),\n        Arguments.of(uak, false, uak, true),\n        Arguments.of(uak, true, incorrectUak, true),\n        Arguments.of(uak, true, uak, true)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/BasicAuthCallCredentials.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.auth.grpc;\n\nimport io.grpc.CallCredentials;\nimport io.grpc.Metadata;\nimport io.grpc.Status;\nimport java.util.concurrent.Executor;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\n\npublic class BasicAuthCallCredentials extends CallCredentials {\n\n  private final String username;\n  private final String password;\n\n  public BasicAuthCallCredentials(String username, String password) {\n    this.username = username;\n    this.password = password;\n  }\n\n  @Override\n  public void applyRequestMetadata(final RequestInfo requestInfo, final Executor appExecutor,\n      final MetadataApplier applier) {\n    try {\n      Metadata headers = new Metadata();\n      headers.put(Metadata.Key.of(\"Authorization\", Metadata.ASCII_STRING_MARSHALLER),\n          HeaderUtils.basicAuthHeader(username, password));\n      applier.apply(headers);\n    } catch (Exception e) {\n      applier.fail(Status.UNAUTHENTICATED.withCause(e));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/MockAuthenticationInterceptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.auth.grpc;\n\nimport io.grpc.Context;\nimport io.grpc.Contexts;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\n\npublic class MockAuthenticationInterceptor implements ServerInterceptor {\n\n  @Nullable\n  private AuthenticatedDevice authenticatedDevice;\n\n  public void setAuthenticatedDevice(final UUID accountIdentifier, final byte deviceId) {\n    authenticatedDevice = new AuthenticatedDevice(accountIdentifier, deviceId);\n  }\n\n  public void clearAuthenticatedDevice() {\n    authenticatedDevice = null;\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n\n    return authenticatedDevice != null\n        ? Contexts.interceptCall(\n        Context.current().withValue(AuthenticationUtil.CONTEXT_AUTHENTICATED_DEVICE, authenticatedDevice),\n        call, headers, next)\n        : next.startCall(call, headers);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/ProhibitAuthenticationInterceptorTest.java",
    "content": "package org.whispersystems.textsecuregcm.auth.grpc;\n\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoServiceGrpc;\nimport org.whispersystems.textsecuregcm.grpc.EchoServiceImpl;\n\nimport java.util.concurrent.TimeUnit;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass ProhibitAuthenticationInterceptorTest  {\n  private Server server;\n  private ManagedChannel channel;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    server = InProcessServerBuilder.forName(\"RequestAttributesInterceptorTest\")\n        .directExecutor()\n        .intercept(new ProhibitAuthenticationInterceptor())\n        .addService(new EchoServiceImpl())\n        .build()\n        .start();\n\n    channel = InProcessChannelBuilder.forName(\"RequestAttributesInterceptorTest\")\n        .directExecutor()\n        .build();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    channel.shutdownNow();\n    server.shutdownNow();\n    channel.awaitTermination(5, TimeUnit.SECONDS);\n    server.awaitTermination(5, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void hasAuth() {\n    final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc\n        .newBlockingStub(channel)\n        .withCallCredentials(new BasicAuthCallCredentials(\"test\", \"password\"));\n\n    final StatusRuntimeException e = assertThrows(StatusRuntimeException.class,\n        () -> client.echo(EchoRequest.getDefaultInstance()));\n    assertEquals(Status.Code.INVALID_ARGUMENT, e.getStatus().getCode());\n  }\n\n  @Test\n  void noAuth() {\n    final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n    assertDoesNotThrow(() -> client.echo(EchoRequest.getDefaultInstance()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/auth/grpc/RequireAuthenticationInterceptorTest.java",
    "content": "package org.whispersystems.textsecuregcm.auth.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.signal.chat.rpc.GetAuthenticatedDeviceRequest;\nimport org.signal.chat.rpc.GetAuthenticatedDeviceResponse;\nimport org.signal.chat.rpc.GetRequestAttributesRequest;\nimport org.signal.chat.rpc.RequestAttributesGrpc;\nimport org.whispersystems.textsecuregcm.auth.AccountAuthenticator;\nimport org.whispersystems.textsecuregcm.grpc.RequestAttributesServiceImpl;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass RequireAuthenticationInterceptorTest {\n  private Server server;\n  private ManagedChannel channel;\n  private AccountAuthenticator authenticator;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    authenticator = mock(AccountAuthenticator.class);\n    server = InProcessServerBuilder.forName(\"RequestAttributesInterceptorTest\")\n        .directExecutor()\n        .intercept(new RequireAuthenticationInterceptor(authenticator))\n        .addService(new RequestAttributesServiceImpl())\n        .build()\n        .start();\n\n    channel = InProcessChannelBuilder.forName(\"RequestAttributesInterceptorTest\")\n        .directExecutor()\n        .build();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    channel.shutdownNow();\n    server.shutdownNow();\n    channel.awaitTermination(5, TimeUnit.SECONDS);\n    server.awaitTermination(5, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void hasAuth() {\n    final UUID aci = UUID.randomUUID();\n    final byte deviceId = 2;\n    when(authenticator.authenticate(eq(new BasicCredentials(\"test\", \"password\"))))\n        .thenReturn(Optional.of(\n            new org.whispersystems.textsecuregcm.auth.AuthenticatedDevice(aci, deviceId, Instant.now())));\n\n    final RequestAttributesGrpc.RequestAttributesBlockingStub client = RequestAttributesGrpc\n        .newBlockingStub(channel)\n        .withCallCredentials(new BasicAuthCallCredentials(\"test\", \"password\"));\n\n    final GetAuthenticatedDeviceResponse authenticatedDevice = client.getAuthenticatedDevice(\n        GetAuthenticatedDeviceRequest.getDefaultInstance());\n    assertEquals(deviceId, authenticatedDevice.getDeviceId());\n    assertEquals(UUIDUtil.fromByteString(authenticatedDevice.getAccountIdentifier()), aci);\n  }\n\n  @Test\n  void badCredentials() {\n    when(authenticator.authenticate(any())).thenReturn(Optional.empty());\n\n    final RequestAttributesGrpc.RequestAttributesBlockingStub client = RequestAttributesGrpc\n        .newBlockingStub(channel)\n        .withCallCredentials(new BasicAuthCallCredentials(\"test\", \"password\"));\n\n    final StatusRuntimeException e = assertThrows(StatusRuntimeException.class,\n        () -> client.getRequestAttributes(GetRequestAttributesRequest.getDefaultInstance()));\n    assertEquals(Status.Code.UNAUTHENTICATED, e.getStatus().getCode());\n  }\n\n  @Test\n  void missingCredentials() {\n    when(authenticator.authenticate(any())).thenReturn(Optional.empty());\n\n    final RequestAttributesGrpc.RequestAttributesBlockingStub client = RequestAttributesGrpc.newBlockingStub(channel);\n\n    final StatusRuntimeException e = assertThrows(StatusRuntimeException.class,\n        () -> client.getRequestAttributes(GetRequestAttributesRequest.getDefaultInstance()));\n    assertEquals(Status.Code.UNAUTHENTICATED, e.getStatus().getCode());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Consumer;\nimport org.assertj.core.api.Assertions;\nimport org.assertj.core.api.ThrowableAssert;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredential;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiterConfig;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;\nimport org.whispersystems.textsecuregcm.tests.util.ExperimentHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\npublic class BackupAuthManagerTest {\n\n  private static final Instant NOW = Instant.now();\n\n  private final UUID aci = UUID.randomUUID();\n  private final byte[] messagesBackupKey = TestRandomUtil.nextBytes(32);\n  private final byte[] mediaBackupKey = TestRandomUtil.nextBytes(32);\n  private final ServerSecretParams receiptParams = ServerSecretParams.generate();\n  private final TestClock clock = TestClock.pinned(NOW);\n  private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(clock);\n  private final AccountsManager accountsManager = mock(AccountsManager.class);\n  private final RedeemedReceiptsManager redeemedReceiptsManager = mock(RedeemedReceiptsManager.class);\n\n  @BeforeEach\n  void setUp() {\n    clock.pin(NOW);\n    reset(accountsManager);\n    reset(redeemedReceiptsManager);\n  }\n\n  BackupAuthManager create() {\n    return create(BackupLevel.FREE, rateLimiter(aci, false, false));\n  }\n\n  BackupAuthManager create(BackupLevel defaultBackupLevel, RateLimiters rateLimiters) {\n    return new BackupAuthManager(\n        switch (defaultBackupLevel) {\n          case FREE -> mock(ExperimentEnrollmentManager.class);\n          case PAID -> ExperimentHelper.withEnrollment(BackupAuthManager.BACKUP_MEDIA_EXPERIMENT_NAME, aci);\n        },\n        rateLimiters,\n        accountsManager,\n        new ServerZkReceiptOperations(receiptParams),\n        redeemedReceiptsManager,\n        backupAuthTestUtil.params,\n        clock);\n  }\n\n  @Test\n  void commitBackupId() throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    final BackupAuthManager authManager = create();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(aci);\n    when(accountsManager.update(any(), any()))\n        .thenAnswer(invocation -> {\n          final Account a = invocation.getArgument(0);\n          final Consumer<Account> updater = invocation.getArgument(1);\n\n          updater.accept(a);\n          return a;\n        });\n\n    final BackupAuthCredentialRequest messagesCredentialRequest = backupAuthTestUtil.getRequest(messagesBackupKey, aci);\n    final BackupAuthCredentialRequest mediaCredentialRequest = backupAuthTestUtil.getRequest(mediaBackupKey, aci);\n\n    authManager.commitBackupId(account, primaryDevice(),\n        Optional.of(messagesCredentialRequest),\n        Optional.of(mediaCredentialRequest));\n\n    verify(account).setBackupCredentialRequests(messagesCredentialRequest.serialize(),\n        mediaCredentialRequest.serialize());\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  void commitOnAnyBackupLevel(final BackupLevel backupLevel) {\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder().backupLevel(backupLevel).build();\n    when(accountsManager.update(any(), any())).thenReturn(account);\n\n    final ThrowableAssert.ThrowingCallable commit = () ->\n        authManager.commitBackupId(account,\n            primaryDevice(),\n            Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),\n            Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci)));\n    Assertions.assertThatNoException().isThrownBy(commit);\n  }\n\n  @Test\n  void commitRequiresPrimary() {\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder().build();\n    when(accountsManager.update(any(), any())).thenReturn(account);\n\n    final ThrowableAssert.ThrowingCallable commit = () ->\n        authManager.commitBackupId(account,\n            linkedDevice(),\n            Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),\n            Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci)));\n    assertThatExceptionOfType(BackupPermissionException.class)\n        .isThrownBy(commit);\n  }\n\n  @CartesianTest\n  void paidTierCredentialViaConfiguration(@CartesianTest.Enum final BackupCredentialType credentialType)\n      throws VerificationFailedException, BackupNotFoundException {\n    final BackupAuthManager authManager = create(BackupLevel.PAID, rateLimiter(aci, false, false));\n\n    final byte[] backupKey = switch (credentialType) {\n      case MESSAGES -> messagesBackupKey;\n      case MEDIA -> mediaBackupKey;\n    };\n\n    // Account does not have PAID tier set\n    final Account account = new MockAccountBuilder()\n        .messagesCredential(backupAuthTestUtil.getRequest(messagesBackupKey, aci))\n        .mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))\n        .build();\n\n    final BackupAuthCredentialRequestContext requestContext =\n        BackupAuthCredentialRequestContext.create(backupKey, aci);\n\n    final RedemptionRange range = range(Duration.ofDays(1));\n    final Map<BackupCredentialType, List<BackupAuthManager.Credential>> credsByType =\n        authManager.getBackupAuthCredentials(account, range(Duration.ofDays(1)));\n    final List<BackupAuthManager.Credential> creds = credsByType.get(credentialType);\n\n    assertThat(creds).hasSize(2);\n    assertThat(requestContext\n        .receiveResponse(creds.getFirst().credential(), range.iterator().next(), backupAuthTestUtil.params.getPublicParams())\n        .getBackupLevel())\n        .isEqualTo(BackupLevel.PAID);\n  }\n\n  @CartesianTest\n  void getBackupAuthCredentials(@CartesianTest.Enum final BackupLevel backupLevel,\n      @CartesianTest.Enum final BackupCredentialType credentialType) throws BackupNotFoundException {\n\n    final BackupAuthManager authManager = create();\n\n    final Account account = new MockAccountBuilder()\n        .backupLevel(backupLevel)\n        .messagesCredential(backupAuthTestUtil.getRequest(messagesBackupKey, aci))\n        .mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))\n        .build();\n\n    assertThat(authManager.getBackupAuthCredentials(account, range(Duration.ofDays(1))).get(credentialType))\n        .hasSize(2);\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  void getBackupAuthCredentialsNoCommittedId(final BackupCredentialType credentialType) {\n    final BackupAuthManager authManager = create();\n\n    final Account account = new MockAccountBuilder().build();\n\n    assertThatExceptionOfType(BackupNotFoundException.class)\n        .isThrownBy(() -> authManager.getBackupAuthCredentials(account, range(Duration.ofDays(1))));\n  }\n\n  @CartesianTest\n  void getReceiptCredentials(@CartesianTest.Enum final BackupLevel backupLevel,\n      @CartesianTest.Enum final BackupCredentialType credentialType)\n      throws VerificationFailedException, BackupNotFoundException {\n    final BackupAuthManager authManager = create();\n\n    final byte[] backupKey = switch (credentialType) {\n      case MESSAGES -> messagesBackupKey;\n      case MEDIA -> mediaBackupKey;\n    };\n\n    final BackupAuthCredentialRequestContext requestContext =\n        BackupAuthCredentialRequestContext.create(backupKey, aci);\n\n    final Account account = new MockAccountBuilder()\n        .backupLevel(backupLevel)\n        .mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))\n        .messagesCredential(backupAuthTestUtil.getRequest(messagesBackupKey, aci))\n        .build();\n\n    final List<BackupAuthManager.Credential> creds = authManager\n        .getBackupAuthCredentials(account, range(Duration.ofDays(7)))\n        .get(credentialType);\n\n    assertThat(creds).hasSize(8);\n    Instant redemptionTime = clock.instant().truncatedTo(ChronoUnit.DAYS);\n    for (BackupAuthManager.Credential cred : creds) {\n      assertThat(requestContext\n          .receiveResponse(cred.credential(), redemptionTime, backupAuthTestUtil.params.getPublicParams())\n          .getBackupLevel())\n          .isEqualTo(backupLevel);\n      assertThat(cred.redemptionTime().getEpochSecond()).isEqualTo(redemptionTime.getEpochSecond());\n      redemptionTime = redemptionTime.plus(Duration.ofDays(1));\n    }\n  }\n\n  @Test\n  void expiringBackupPayment() throws VerificationFailedException, BackupNotFoundException {\n    clock.pin(Instant.ofEpochSecond(1));\n    final Instant day4 = Instant.EPOCH.plus(Duration.ofDays(4));\n\n    final BackupAuthManager authManager = create();\n\n    final Account account = new MockAccountBuilder()\n        .messagesCredential(backupAuthTestUtil.getRequest(messagesBackupKey, aci))\n        .mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))\n        .backupVoucher(new Account.BackupVoucher(201, day4))\n        .build();\n\n    final List<BackupAuthManager.Credential> creds = authManager.getBackupAuthCredentials(\n            account,\n            range(RedemptionRange.MAX_REDEMPTION_DURATION))\n        .get(BackupCredentialType.MESSAGES);\n    Instant redemptionTime = Instant.EPOCH;\n    final BackupAuthCredentialRequestContext requestContext = BackupAuthCredentialRequestContext.create(\n        messagesBackupKey, aci);\n    for (int i = 0; i < creds.size(); i++) {\n      // Before the expiration, credentials should have a media receipt, otherwise messages only\n      final BackupLevel level = i < 5 ? BackupLevel.PAID : BackupLevel.FREE;\n      final BackupAuthManager.Credential cred = creds.get(i);\n      assertThat(requestContext\n          .receiveResponse(cred.credential(), redemptionTime, backupAuthTestUtil.params.getPublicParams())\n          .getBackupLevel())\n          .isEqualTo(level);\n      assertThat(cred.redemptionTime().getEpochSecond()).isEqualTo(redemptionTime.getEpochSecond());\n      redemptionTime = redemptionTime.plus(Duration.ofDays(1));\n    }\n  }\n\n  @Test\n  void expiredBackupPayment() throws BackupNotFoundException {\n    final Instant day1 = Instant.EPOCH.plus(Duration.ofDays(1));\n    final Instant day2 = Instant.EPOCH.plus(Duration.ofDays(2));\n    final Instant day3 = Instant.EPOCH.plus(Duration.ofDays(3));\n\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder()\n        .messagesCredential(backupAuthTestUtil.getRequest(messagesBackupKey, aci))\n        .mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))\n        .backupVoucher(new Account.BackupVoucher(3, day1))\n        .build();\n\n    final Account updated = new MockAccountBuilder()\n        .messagesCredential(backupAuthTestUtil.getRequest(messagesBackupKey, aci))\n        .mediaCredential(backupAuthTestUtil.getRequest(mediaBackupKey, aci))\n        .backupVoucher(null)\n        .build();\n\n    when(accountsManager.update(any(), any())).thenReturn(updated);\n\n    clock.pin(day2.plus(Duration.ofSeconds(1)));\n    assertThat(authManager\n        .getBackupAuthCredentials(account, range(Duration.ofDays(7)))\n        .get(BackupCredentialType.MESSAGES))\n        .hasSize(8);\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Consumer<Account>> accountUpdater = ArgumentCaptor.forClass(\n        Consumer.class);\n    verify(accountsManager, times(1)).update(any(), accountUpdater.capture());\n\n    // If the account is not expired when we go to update it, we shouldn't wipe it out\n    final Account alreadyUpdated = mock(Account.class);\n    when(alreadyUpdated.getBackupVoucher()).thenReturn(new Account.BackupVoucher(3, day3));\n    accountUpdater.getValue().accept(alreadyUpdated);\n    verify(alreadyUpdated, never()).setBackupVoucher(any());\n\n    // If the account is still expired when we go to update it, we can wipe it out\n    final Account expired = mock(Account.class);\n    when(expired.getBackupVoucher()).thenReturn(new Account.BackupVoucher(3, day1));\n    accountUpdater.getValue().accept(expired);\n    verify(expired, times(1)).setBackupVoucher(null);\n  }\n\n\n  @Test\n  void redeemReceipt()\n      throws InvalidInputException, VerificationFailedException, BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {\n    final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder()\n        .mediaCredential(Optional.of(new byte[0]))\n        .build();\n    clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));\n    when(accountsManager.update(any(), any())).thenReturn(account);\n    when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))\n        .thenReturn(CompletableFuture.completedFuture(true));\n    authManager.redeemReceipt(account, receiptPresentation(201, expirationTime));\n    verify(accountsManager, times(1)).update(any(), any());\n  }\n\n  @Test\n  void redeemReceiptNoBackupRequest() {\n    final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder().mediaCredential(Optional.empty()).build();\n\n    clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));\n    when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))\n        .thenReturn(CompletableFuture.completedFuture(true));\n    assertThatExceptionOfType(BackupMissingIdCommitmentException.class)\n        .isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)));\n  }\n\n  @Test\n  void mergeRedemptions()\n      throws InvalidInputException, VerificationFailedException, BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {\n    final Instant newExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1));\n    final Instant existingExpirationTime = Instant.EPOCH.plus(Duration.ofDays(1)).plus(Duration.ofSeconds(1));\n\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder()\n        .mediaCredential(Optional.of(new byte[0]))\n        // The account has an existing voucher with a later expiration date\n        .backupVoucher(new Account.BackupVoucher(201, existingExpirationTime))\n        .build();\n\n    clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));\n    when(accountsManager.update(any(), any())).thenReturn(account);\n    when(redeemedReceiptsManager.put(any(), eq(newExpirationTime.getEpochSecond()), eq(201L), eq(aci)))\n        .thenReturn(CompletableFuture.completedFuture(true));\n    authManager.redeemReceipt(account, receiptPresentation(201, newExpirationTime));\n\n    final ArgumentCaptor<Consumer<Account>> updaterCaptor = ArgumentCaptor.captor();\n    verify(accountsManager, times(1)).update(any(), updaterCaptor.capture());\n\n    updaterCaptor.getValue().accept(account);\n    // Should select the voucher with the later expiration time\n    verify(account).setBackupVoucher(eq(new Account.BackupVoucher(201, existingExpirationTime)));\n  }\n\n  @Test\n  void redeemExpiredReceipt() {\n    final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));\n    clock.pin(expirationTime.plus(Duration.ofSeconds(1)));\n    final BackupAuthManager authManager = create();\n    assertThatExceptionOfType(BackupBadReceiptException.class)\n        .isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(3, expirationTime)));\n    verifyNoInteractions(accountsManager);\n    verifyNoInteractions(redeemedReceiptsManager);\n  }\n\n  @ParameterizedTest\n  @ValueSource(longs = {0, 1, 2, 200, 500})\n  void redeemInvalidLevel(long level) {\n    final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));\n    clock.pin(expirationTime.plus(Duration.ofSeconds(1)));\n    final BackupAuthManager authManager = create();\n    assertThatExceptionOfType(BackupBadReceiptException.class)\n        .isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), receiptPresentation(level, expirationTime)));\n    verifyNoInteractions(accountsManager);\n    verifyNoInteractions(redeemedReceiptsManager);\n  }\n\n  @Test\n  void redeemInvalidPresentation() throws InvalidInputException, VerificationFailedException {\n    final BackupAuthManager authManager = create();\n    final ReceiptCredentialPresentation invalid = receiptPresentation(ServerSecretParams.generate(), 3L, Instant.EPOCH);\n    assertThatExceptionOfType(BackupBadReceiptException.class)\n        .isThrownBy(() -> authManager.redeemReceipt(mock(Account.class), invalid));\n    verifyNoInteractions(accountsManager);\n    verifyNoInteractions(redeemedReceiptsManager);\n  }\n\n  @Test\n  void receiptAlreadyRedeemed()  {\n    final Instant expirationTime = Instant.EPOCH.plus(Duration.ofDays(1));\n    final BackupAuthManager authManager = create();\n    final Account account = new MockAccountBuilder()\n        .mediaCredential(Optional.of(new byte[0]))\n        .build();\n\n    clock.pin(Instant.EPOCH.plus(Duration.ofDays(1)));\n    when(accountsManager.update(any(), any())).thenReturn(account);\n    when(redeemedReceiptsManager.put(any(), eq(expirationTime.getEpochSecond()), eq(201L), eq(aci)))\n        .thenReturn(CompletableFuture.completedFuture(false));\n\n    assertThatExceptionOfType(BackupBadReceiptException.class)\n        .isThrownBy(() -> authManager.redeemReceipt(account, receiptPresentation(201, expirationTime)));\n    verifyNoInteractions(accountsManager);\n  }\n\n  private ReceiptCredentialPresentation receiptPresentation(long level, Instant redemptionTime)\n      throws InvalidInputException, VerificationFailedException {\n    return receiptPresentation(receiptParams, level, redemptionTime);\n  }\n\n  private ReceiptCredentialPresentation receiptPresentation(ServerSecretParams params, long level,\n      Instant redemptionTime)\n      throws InvalidInputException, VerificationFailedException {\n    final ServerZkReceiptOperations serverOps = new ServerZkReceiptOperations(params);\n    final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(params.getPublicParams());\n\n    final ReceiptCredentialRequestContext rcrc = clientOps\n        .createReceiptCredentialRequestContext(new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE)));\n\n    final ReceiptCredentialResponse response =\n        serverOps.issueReceiptCredential(rcrc.getRequest(), redemptionTime.getEpochSecond(), level);\n    final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, response);\n    return clientOps.createReceiptCredentialPresentation(receiptCredential);\n  }\n\n  @CartesianTest\n  void testCheckLimits(\n      @CartesianTest.Values(booleans = {true, false}) boolean messageLimited,\n      @CartesianTest.Values(booleans = {true, false}) boolean mediaLimited,\n      @CartesianTest.Values(booleans = {true, false}) boolean hasVoucher) {\n    clock.pin(Instant.EPOCH);\n    final BackupAuthManager authManager = create(BackupLevel.FREE, rateLimiter(aci, messageLimited, mediaLimited));\n    final Account account = new MockAccountBuilder()\n        .backupVoucher(hasVoucher\n            ? new Account.BackupVoucher(1, Instant.EPOCH.plus(Duration.ofSeconds(1)))\n            : null)\n        .build();\n    final BackupAuthManager.BackupIdRotationLimit limit = authManager.checkBackupIdRotationLimit(account);\n    final boolean expectHasPermits = !messageLimited && (!mediaLimited || !hasVoucher);\n    final Duration expectedDuration = expectHasPermits ? Duration.ZERO : Duration.ofDays(1);\n    assertThat(limit.hasPermitsRemaining()).isEqualTo(expectHasPermits);\n    assertThat(limit.nextPermitAvailable()).isEqualTo(expectedDuration);\n  }\n\n  enum CredentialChangeType {\n    // Provided a new credential that matches the stored credential\n    MATCH,\n    // Provided a new credential that did not match the stored credential\n    MISMATCH,\n    // Provided no credential (should not update the credential)\n    NO_UPDATE\n  }\n\n\n  @CartesianTest\n  void testChangeIdRateLimits(\n      @CartesianTest.Enum CredentialChangeType messageChange,\n      @CartesianTest.Enum CredentialChangeType mediaChange,\n      @CartesianTest.Values(booleans = {true, false}) boolean paid,\n      @CartesianTest.Values(booleans = {true, false}) boolean rateLimitMessagesBackupId,\n      @CartesianTest.Values(booleans = {true, false}) boolean rateLimitMediaBackupId) {\n\n    final BackupAuthManager authManager =\n        create(BackupLevel.FREE, rateLimiter(aci, rateLimitMessagesBackupId, rateLimitMediaBackupId));\n    final BackupAuthCredentialRequest storedMessagesCredential = backupAuthTestUtil.getRequest(messagesBackupKey, aci);\n    final BackupAuthCredentialRequest storedMediaCredential = backupAuthTestUtil.getRequest(mediaBackupKey, aci);\n\n    // Set clock before the voucher expires if paid, otherwise after\n    final Account.BackupVoucher backupVoucher = new Account.BackupVoucher(1, Instant.ofEpochSecond(100));\n    clock.pin(paid ? Instant.ofEpochSecond(99) : Instant.ofEpochSecond(101));\n    final Account account = new MockAccountBuilder()\n        .mediaCredential(storedMediaCredential)\n        .messagesCredential(storedMessagesCredential)\n        .backupVoucher(backupVoucher)\n        .build();\n\n    when(accountsManager.update(any(), any())).thenReturn(account);\n\n    final Optional<BackupAuthCredentialRequest> newMessagesCredential = switch (messageChange) {\n      case MATCH -> Optional.of(storedMessagesCredential);\n      case MISMATCH -> Optional.of(backupAuthTestUtil.getRequest(TestRandomUtil.nextBytes(32), aci));\n      case NO_UPDATE -> Optional.empty();\n    };\n    final Optional<BackupAuthCredentialRequest> newMediaCredential = switch (mediaChange) {\n      case MATCH -> Optional.of(storedMediaCredential);\n      case MISMATCH -> Optional.of(backupAuthTestUtil.getRequest(TestRandomUtil.nextBytes(32), aci));\n      case NO_UPDATE -> Optional.empty();\n    };\n\n    // We should get rate limited if we try to change and\n    // 1. we are out of media changes on a paid account, or\n    // 2. we are out of messages changes\n    final boolean expectRateLimit = ((mediaChange == CredentialChangeType.MISMATCH) && rateLimitMediaBackupId && paid)\n        || ((messageChange == CredentialChangeType.MISMATCH) && rateLimitMessagesBackupId);\n    final ThrowableAssert.ThrowingCallable commit = () ->\n        authManager.commitBackupId(account, primaryDevice(), newMessagesCredential, newMediaCredential);\n\n    if (messageChange == CredentialChangeType.NO_UPDATE && mediaChange == CredentialChangeType.NO_UPDATE) {\n      assertThatExceptionOfType(BackupInvalidArgumentException.class)\n          .isThrownBy(commit);\n    } else if (expectRateLimit) {\n      assertThatExceptionOfType(RateLimitExceededException.class).isThrownBy(commit);\n    } else {\n      assertThatNoException().isThrownBy(commit);\n    }\n  }\n\n  private Device primaryDevice() {\n    final Device device = mock(Device.class);\n    when(device.isPrimary()).thenReturn(true);\n    return device;\n  }\n\n  private Device linkedDevice() {\n    final Device device = mock(Device.class);\n    when(device.isPrimary()).thenReturn(false);\n    return device;\n  }\n\n  private class MockAccountBuilder {\n\n    private final Account account = mock(Account.class);\n\n    MockAccountBuilder() {\n      when(account.getUuid()).thenReturn(aci);\n    }\n\n    MockAccountBuilder backupLevel(BackupLevel backupLevel) {\n      if (backupLevel == BackupLevel.PAID) {\n        return backupVoucher(new Account.BackupVoucher(201L, clock.instant().plus(Duration.ofDays(8))));\n      }\n      return this;\n    }\n\n    MockAccountBuilder backupVoucher(Account.BackupVoucher backupVoucher) {\n      when(account.getBackupVoucher()).thenReturn(backupVoucher);\n      return this;\n    }\n\n    MockAccountBuilder mediaCredential(final BackupAuthCredentialRequest storedMediaCredential) {\n      return mediaCredential(Optional.of(storedMediaCredential.serialize()));\n    }\n\n    MockAccountBuilder mediaCredential(final Optional<byte[]> serializedMediaCredential) {\n      when(account.getBackupCredentialRequest(BackupCredentialType.MEDIA))\n          .thenReturn(serializedMediaCredential);\n      return this;\n    }\n\n    MockAccountBuilder messagesCredential(final BackupAuthCredentialRequest storedMessagesCredential) {\n      when(account.getBackupCredentialRequest(BackupCredentialType.MESSAGES))\n          .thenReturn(Optional.of(storedMessagesCredential.serialize()));\n      return this;\n    }\n\n    Account build() {\n      return account;\n    }\n  }\n\n\n  private static RateLimiters rateLimiter(final UUID aci, boolean rateLimitBackupId, boolean rateLimitPaidMediaBackupId) {\n    try {\n      final RateLimiters limiters = mock(RateLimiters.class);\n\n      final RateLimiter allowLimiter = mock(RateLimiter.class);\n      when(allowLimiter.hasAvailablePermitsAsync(eq(aci), anyLong())).thenReturn(\n          CompletableFuture.completedFuture(true));\n      when(allowLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false));\n\n      final RateLimiter denyLimiter = mock(RateLimiter.class);\n      when(denyLimiter.hasAvailablePermitsAsync(eq(aci), anyLong())).thenReturn(\n          CompletableFuture.completedFuture(false));\n      doThrow(new RateLimitExceededException(null)).when(denyLimiter).validate(aci);\n      when(denyLimiter.config()).thenReturn(new RateLimiterConfig(1, Duration.ofDays(1), false));\n\n      when(limiters.forDescriptor(RateLimiters.For.SET_BACKUP_ID))\n          .thenReturn(rateLimitBackupId ? denyLimiter : allowLimiter);\n      when(limiters.forDescriptor(RateLimiters.For.SET_PAID_MEDIA_BACKUP_ID))\n          .thenReturn(rateLimitPaidMediaBackupId ? denyLimiter : allowLimiter);\n      return limiters;\n    } catch (RateLimitExceededException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private RedemptionRange range(Duration length) {\n    final Instant start = clock.instant().truncatedTo(ChronoUnit.DAYS);\n    return RedemptionRange.inclusive(clock, start, start.plus(length));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupAuthTestUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.junit.jupiter.api.Assertions;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\npublic class BackupAuthTestUtil {\n\n  final GenericServerSecretParams params = GenericServerSecretParams.generate();\n  final Clock clock;\n\n  public BackupAuthTestUtil(final Clock clock) {\n    this.clock = clock;\n  }\n\n  public BackupAuthCredentialRequest getRequest(final byte[] backupKey, final UUID aci) {\n    return BackupAuthCredentialRequestContext.create(backupKey, aci).getRequest();\n  }\n\n  public BackupAuthCredentialPresentation getPresentation(\n      final BackupLevel backupLevel, final byte[] backupKey, final UUID aci)\n      throws VerificationFailedException {\n    return getPresentation(params, backupLevel, backupKey, aci);\n  }\n\n  public BackupAuthCredentialPresentation getPresentation(\n      GenericServerSecretParams params, final BackupLevel backupLevel, final byte[] backupKey, final UUID aci)\n      throws VerificationFailedException {\n    final Instant redemptionTime = clock.instant().truncatedTo(ChronoUnit.DAYS);\n    final BackupAuthCredentialRequestContext ctx = BackupAuthCredentialRequestContext.create(backupKey, aci);\n    return ctx.receiveResponse(\n            ctx.getRequest()\n                .issueCredential(clock.instant().truncatedTo(ChronoUnit.DAYS), backupLevel, BackupCredentialType.MESSAGES, params),\n            redemptionTime,\n            params.getPublicParams())\n        .present(params.getPublicParams());\n  }\n\n  public List<BackupAuthManager.Credential> getCredentials(\n      final BackupLevel backupLevel,\n      final BackupAuthCredentialRequest request,\n      final BackupCredentialType credentialType,\n      final Instant redemptionStart,\n      final Instant redemptionEnd) {\n    final UUID aci = UUID.randomUUID();\n\n    final BackupAuthManager issuer = new BackupAuthManager(\n        mock(ExperimentEnrollmentManager.class), null, null, null, null, params, clock);\n    Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(aci);\n    when(account.getBackupCredentialRequest(any())).thenReturn(Optional.of(request.serialize()));\n    when(account.getBackupVoucher()).thenReturn(switch (backupLevel) {\n      case FREE -> null;\n      case PAID -> new Account.BackupVoucher(201L, redemptionEnd.plus(1, ChronoUnit.SECONDS));\n    });\n    final RedemptionRange redemptionRange;\n    redemptionRange = RedemptionRange.inclusive(clock, redemptionStart, redemptionEnd);\n    try {\n      return issuer.getBackupAuthCredentials(account, redemptionRange).get(credentialType);\n    } catch (BackupNotFoundException e) {\n      return Assertions.fail(\"Backup credential request not found even though we set one\");\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;\n\nimport io.dropwizard.util.DataSize;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ArrayBlockingQueue;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Function;\nimport java.util.stream.IntStream;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.apache.commons.lang3.StringUtils;\nimport org.assertj.core.api.ThrowableAssert;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPrivateKey;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicBackupConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Schedulers;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\n\npublic class BackupManagerTest {\n\n  @RegisterExtension\n  public static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.BACKUPS);\n\n  private static final MediaEncryptionParameters COPY_ENCRYPTION_PARAM = new MediaEncryptionParameters(\n      TestRandomUtil.nextBytes(32),\n      TestRandomUtil.nextBytes(32));\n  private static final CopyParameters COPY_PARAM = new CopyParameters(\n      3, \"abc\", 100,\n      COPY_ENCRYPTION_PARAM, TestRandomUtil.nextBytes(15));\n  private static final long MAX_TOTAL_MEDIA_BYTES = DataSize.mebibytes(1).toBytes();\n\n  private final TestClock testClock = TestClock.now();\n  private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(testClock);\n  private final RateLimiter mediaUploadLimiter = mock(RateLimiter.class);\n  private final TusAttachmentGenerator tusAttachmentGenerator = mock(TusAttachmentGenerator.class);\n  private final Cdn3BackupCredentialGenerator tusCredentialGenerator = mock(Cdn3BackupCredentialGenerator.class);\n  private final RemoteStorageManager remoteStorageManager = mock(RemoteStorageManager.class);\n  private final byte[] backupKey = TestRandomUtil.nextBytes(32);\n  private final UUID aci = UUID.randomUUID();\n  private final DynamicBackupConfiguration backupConfiguration = new DynamicBackupConfiguration(\n    3, 4, 5, Duration.ofSeconds(30), MAX_TOTAL_MEDIA_BYTES);\n\n\n  private static final SecureValueRecoveryConfiguration CFG = new SecureValueRecoveryConfiguration(\n      \"\",\n      randomSecretBytes(32),\n      randomSecretBytes(32),\n      null,\n      null,\n      null);\n  private final ExternalServiceCredentialsGenerator svrbCredentialGenerator =\n      SecureValueRecoveryBCredentialsGeneratorFactory.svrbCredentialsGenerator(CFG, testClock);\n  private final SecureValueRecoveryClient svrbClient = mock(SecureValueRecoveryClient.class);\n\n  private BackupManager backupManager;\n  private BackupsDb backupsDb;\n\n  @BeforeEach\n  public void setup() {\n    reset(tusCredentialGenerator, mediaUploadLimiter);\n    testClock.unpin();\n\n    final RateLimiters rateLimiters = mock(RateLimiters.class);\n    when(rateLimiters.forDescriptor(RateLimiters.For.BACKUP_ATTACHMENT)).thenReturn(mediaUploadLimiter);\n\n    when(remoteStorageManager.cdnNumber()).thenReturn(3);\n\n    this.backupsDb = new BackupsDb(\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.BACKUPS.tableName(),\n        testClock);\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    when(dynamicConfiguration.getBackupConfiguration()).thenReturn(backupConfiguration);\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n\n    this.backupManager = new BackupManager(\n        backupsDb,\n        backupAuthTestUtil.params,\n        rateLimiters,\n        tusAttachmentGenerator,\n        tusCredentialGenerator,\n        remoteStorageManager,\n        svrbCredentialGenerator,\n        svrbClient,\n        testClock,\n        dynamicConfigurationManager);\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"FREE, FREE, false\",\n      \"FREE, PAID, true\",\n      \"PAID, FREE, false\",\n      \"PAID, PAID, false\"\n  })\n  void checkBackupLevel(final BackupLevel authenticateBackupLevel,\n      final BackupLevel requiredLevel,\n      final boolean expectException) {\n\n    final AuthenticatedBackupUser backupUser =\n        backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, authenticateBackupLevel);\n\n    final ThrowableAssert.ThrowingCallable checkBackupLevel =\n        () -> BackupManager.checkBackupLevel(backupUser, requiredLevel);\n\n    if (expectException) {\n      assertThatExceptionOfType(BackupPermissionException.class).isThrownBy(checkBackupLevel);\n    } else {\n      assertThatNoException().isThrownBy(checkBackupLevel);\n    }\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"MESSAGES, MESSAGES, false\",\n      \"MESSAGES, MEDIA, true\",\n      \"MEDIA, MESSAGES, true\",\n      \"MEDIA, MEDIA, false\"\n  })\n  void checkBackupCredentialType(final BackupCredentialType authenticateCredentialType,\n      final BackupCredentialType requiredCredentialType,\n      final boolean expectException) {\n\n    final AuthenticatedBackupUser backupUser =\n        backupUser(TestRandomUtil.nextBytes(16), authenticateCredentialType, BackupLevel.FREE);\n\n    final ThrowableAssert.ThrowingCallable checkCredentialType =\n        () -> BackupManager.checkBackupCredentialType(backupUser, requiredCredentialType);\n\n    if (expectException) {\n      assertThatExceptionOfType(BackupWrongCredentialTypeException.class).isThrownBy(checkCredentialType);\n    } else {\n      assertThatNoException().isThrownBy(checkCredentialType);\n    }\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  public void createBackup(final BackupLevel backupLevel) throws BackupException {\n\n    final Instant now = Instant.ofEpochSecond(Duration.ofDays(1).getSeconds());\n    testClock.pin(now);\n\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);\n\n    backupManager.createMessageBackupUploadDescriptor(backupUser);\n    verify(tusCredentialGenerator, times(1))\n        .generateUpload(\"%s/%s\".formatted(backupUser.backupDir(), BackupManager.MESSAGE_BACKUP_NAME));\n\n    final BackupManager.BackupInfo info = backupManager.backupInfo(backupUser);\n    assertThat(info.backupSubdir()).isEqualTo(backupUser.backupDir()).isNotBlank();\n    assertThat(info.messageBackupKey()).isEqualTo(BackupManager.MESSAGE_BACKUP_NAME);\n    assertThat(info.mediaUsedSpace()).isEqualTo(Optional.empty());\n\n    // Check that the initial expiration times are the initial write times\n    checkExpectedExpirations(now, backupLevel == BackupLevel.PAID ? now : null, backupUser);\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  public void createBackupWrongCredentialType(final BackupLevel backupLevel) {\n\n    final Instant now = Instant.ofEpochSecond(Duration.ofDays(1).getSeconds());\n    testClock.pin(now);\n\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, backupLevel);\n\n    assertThatExceptionOfType(BackupWrongCredentialTypeException.class)\n        .isThrownBy(() -> backupManager.createMessageBackupUploadDescriptor(backupUser));\n  }\n\n  @Test\n  public void createTemporaryMediaAttachmentRateLimited() throws RateLimitExceededException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    doThrow(new RateLimitExceededException(null))\n        .when(mediaUploadLimiter).validate(eq(BackupManager.rateLimitKey(backupUser)));\n    assertThatExceptionOfType(RateLimitExceededException.class)\n        .isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));\n  }\n\n  @Test\n  public void createTemporaryMediaAttachmentWrongTier() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.FREE);\n    assertThatExceptionOfType(BackupPermissionException.class)\n        .isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));\n  }\n\n  @Test\n  public void createTemporaryMediaAttachmentWrongCredentialType() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n    assertThatExceptionOfType(BackupWrongCredentialTypeException.class)\n        .isThrownBy(() -> backupManager.createTemporaryAttachmentUploadDescriptor(backupUser));\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  public void ttlRefresh(final BackupLevel backupLevel) throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);\n\n    final Instant tstart = Instant.ofEpochSecond(1).plus(Duration.ofDays(1));\n    final Instant tnext = tstart.plus(Duration.ofDays(1));\n\n    // create backup at t=tstart\n    testClock.pin(tstart);\n    backupManager.createMessageBackupUploadDescriptor(backupUser);\n\n    // refresh at t=tnext\n    testClock.pin(tnext);\n    backupManager.ttlRefresh(backupUser);\n\n    checkExpectedExpirations(\n        tnext.truncatedTo(ChronoUnit.DAYS),\n        backupLevel == BackupLevel.PAID ? tnext.truncatedTo(ChronoUnit.DAYS) : null,\n        backupUser);\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  public void createBackupRefreshesTtl(final BackupLevel backupLevel) throws BackupException {\n    final Instant tstart = Instant.ofEpochSecond(1).plus(Duration.ofDays(1));\n    final Instant tnext = tstart.plus(Duration.ofDays(1));\n\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);\n\n    // create backup at t=tstart\n    testClock.pin(tstart);\n    backupManager.createMessageBackupUploadDescriptor(backupUser);\n\n    // create again at t=tnext\n    testClock.pin(tnext);\n    backupManager.createMessageBackupUploadDescriptor(backupUser);\n\n    checkExpectedExpirations(\n        tnext.truncatedTo(ChronoUnit.DAYS),\n        backupLevel == BackupLevel.PAID ? tnext.truncatedTo(ChronoUnit.DAYS) : null,\n        backupUser);\n  }\n\n  @Test\n  public void invalidPresentationNoPublicKey() throws VerificationFailedException {\n    final BackupAuthCredentialPresentation invalidPresentation = backupAuthTestUtil.getPresentation(\n        GenericServerSecretParams.generate(),\n        BackupLevel.FREE, backupKey, aci);\n\n    final ECKeyPair keyPair = ECKeyPair.generate();\n\n    // haven't set a public key yet, but should fail before hitting the database anyway\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.authenticateBackupUser(\n            invalidPresentation,\n            keyPair.getPrivateKey().calculateSignature(invalidPresentation.serialize()),\n            null));\n  }\n\n\n  @Test\n  public void invalidPresentationCorrectSignature() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.FREE, backupKey, aci);\n    final BackupAuthCredentialPresentation invalidPresentation = backupAuthTestUtil.getPresentation(\n        GenericServerSecretParams.generate(),\n        BackupLevel.FREE, backupKey, aci);\n\n    final ECKeyPair keyPair = ECKeyPair.generate();\n    backupManager.setPublicKey(\n        presentation,\n        keyPair.getPrivateKey().calculateSignature(presentation.serialize()),\n        keyPair.getPublicKey());\n\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.authenticateBackupUser(\n            invalidPresentation,\n            keyPair.getPrivateKey().calculateSignature(invalidPresentation.serialize()),\n            null));\n  }\n\n  @Test\n  public void unknownPublicKey() throws VerificationFailedException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.FREE, backupKey, aci);\n\n    final ECKeyPair keyPair = ECKeyPair.generate();\n    final byte[] signature = keyPair.getPrivateKey().calculateSignature(presentation.serialize());\n\n    // haven't set a public key yet\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.authenticateBackupUser(presentation, signature, null));\n  }\n\n  @Test\n  public void mismatchedPublicKey() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.FREE, backupKey, aci);\n\n    final ECKeyPair keyPair1 = ECKeyPair.generate();\n    final ECKeyPair keyPair2 = ECKeyPair.generate();\n    final byte[] signature1 = keyPair1.getPrivateKey().calculateSignature(presentation.serialize());\n    final byte[] signature2 = keyPair2.getPrivateKey().calculateSignature(presentation.serialize());\n\n    backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey());\n\n    // shouldn't be able to set a different public key\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.setPublicKey(presentation, signature2, keyPair2.getPublicKey()));\n\n    // should be able to set the same public key again (noop)\n    backupManager.setPublicKey(presentation, signature1, keyPair1.getPublicKey());\n  }\n\n  @Test\n  public void signatureValidation() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.FREE, backupKey, aci);\n\n    final ECKeyPair keyPair = ECKeyPair.generate();\n    final byte[] signature = keyPair.getPrivateKey().calculateSignature(presentation.serialize());\n\n    // an invalid signature\n    final byte[] wrongSignature = Arrays.copyOf(signature, signature.length);\n    wrongSignature[1] += 1;\n\n    // shouldn't be able to set a public key with an invalid signature\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.setPublicKey(presentation, wrongSignature, keyPair.getPublicKey()));\n\n    backupManager.setPublicKey(presentation, signature, keyPair.getPublicKey());\n\n    // shouldn't be able to authenticate with an invalid signature\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.authenticateBackupUser(presentation, wrongSignature, null));\n\n    // correct signature\n    final AuthenticatedBackupUser user = backupManager.authenticateBackupUser(presentation, signature, null);\n    assertThat(user.backupId()).isEqualTo(presentation.getBackupId());\n    assertThat(user.backupLevel()).isEqualTo(BackupLevel.FREE);\n  }\n\n  @Test\n  public void credentialExpiration() throws VerificationFailedException, BackupException {\n\n    // credential for 1 day after epoch\n    testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(1)));\n    final BackupAuthCredentialPresentation oldCredential = backupAuthTestUtil.getPresentation(BackupLevel.FREE,\n        backupKey, aci);\n    final ECKeyPair keyPair = ECKeyPair.generate();\n    final byte[] signature = keyPair.getPrivateKey().calculateSignature(oldCredential.serialize());\n    backupManager.setPublicKey(oldCredential, signature, keyPair.getPublicKey());\n\n    // should be accepted the day before to forgive clock skew\n    testClock.pin(Instant.ofEpochSecond(1));\n    assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null));\n\n    // should be accepted the day after to forgive clock skew\n    testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(2)));\n    assertThatNoException().isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null));\n\n    // should be rejected the day after that\n    testClock.pin(Instant.ofEpochSecond(1).plus(Duration.ofDays(3)));\n    assertThatExceptionOfType(BackupFailedZkAuthenticationException.class)\n        .isThrownBy(() -> backupManager.authenticateBackupUser(oldCredential, signature, null));\n  }\n\n  @Test\n  public void copySuccess() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final CopyResult copied = copy(backupUser);\n\n    assertThat(copied.cdn()).isEqualTo(3);\n    assertThat(copied.mediaId()).isEqualTo(COPY_PARAM.destinationMediaId());\n    assertThat(copied.outcome()).isEqualTo(CopyResult.Outcome.SUCCESS);\n\n    final Map<String, AttributeValue> backup = getBackupItem(backupUser);\n    final long bytesUsed = AttributeValues.getLong(backup, BackupsDb.ATTR_MEDIA_BYTES_USED, 0L);\n    assertThat(bytesUsed).isEqualTo(COPY_PARAM.destinationObjectSize());\n\n    final long mediaCount = AttributeValues.getLong(backup, BackupsDb.ATTR_MEDIA_COUNT, 0L);\n    assertThat(mediaCount).isEqualTo(1);\n  }\n\n  @Test\n  public void copyUsageCheckpoints() throws InterruptedException, BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(0, 0)).join();\n\n    final List<String> sourceKeys = IntStream.range(0, 50)\n        .mapToObj(ignore -> RandomStringUtils.insecure().nextAlphanumeric(10))\n        .toList();\n    final List<CopyParameters> toCopy = sourceKeys.stream()\n        .map(source -> new CopyParameters(3, source, 100, COPY_ENCRYPTION_PARAM, TestRandomUtil.nextBytes(15)))\n        .toList();\n\n    final int slowIndex = backupConfiguration.usageCheckpointCount() - 1;\n    final CompletableFuture<Void> slow = new CompletableFuture<>();\n    when(remoteStorageManager.copy(eq(3), anyString(), eq(100), any(), anyString()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n    when(remoteStorageManager.copy(eq(3), eq(sourceKeys.get(slowIndex)), eq(100), any(), anyString()))\n        .thenReturn(slow);\n    final ArrayBlockingQueue<CopyResult> copyResults = new ArrayBlockingQueue<>(100);\n    final CompletableFuture<Void> future = backupManager\n        .copyToBackup(backupManager.getCopyQuota(backupUser, toCopy))\n        .doOnNext(copyResults::add).then().toFuture();\n\n    for (int i = 0; i < slowIndex; i++) {\n      assertThat(copyResults.poll(1, TimeUnit.SECONDS)).isNotNull();\n    }\n\n    // Copying can start on the next batch of USAGE_CHECKPOINT_COUNT before the current one is done, so we should see\n    // at least one usage update, and at most 2\n    final long bytesPerObject = COPY_ENCRYPTION_PARAM.outputSize(100);\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo()).isIn(\n        new UsageInfo(\n            bytesPerObject * backupConfiguration.usageCheckpointCount(),\n            backupConfiguration.usageCheckpointCount()),\n        new UsageInfo(\n            2 * bytesPerObject * backupConfiguration.usageCheckpointCount(),\n            2L * backupConfiguration.usageCheckpointCount()));\n\n    // We should still be waiting since we have a slow delete\n    assertThat(future).isNotDone();\n\n    slow.complete(null);\n    future.join();\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())\n        .isEqualTo(new UsageInfo(bytesPerObject * 50, 50));\n  }\n\n  @Test\n  public void copyFailure() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    assertThat(copyError(backupUser, new SourceObjectNotFoundException()).outcome())\n        .isEqualTo(CopyResult.Outcome.SOURCE_NOT_FOUND);\n\n    // usage should be rolled back after a known copy failure\n    final Map<String, AttributeValue> backup = getBackupItem(backupUser);\n    assertThat(AttributeValues.getLong(backup, BackupsDb.ATTR_MEDIA_BYTES_USED, -1L)).isEqualTo(0L);\n    assertThat(AttributeValues.getLong(backup, BackupsDb.ATTR_MEDIA_COUNT, -1L)).isEqualTo(0L);\n  }\n\n  @Test\n  public void copyPartialSuccess() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final List<CopyParameters> toCopy = List.of(\n        new CopyParameters(3, \"success\", 100, COPY_ENCRYPTION_PARAM, TestRandomUtil.nextBytes(15)),\n        new CopyParameters(3, \"missing\", 200, COPY_ENCRYPTION_PARAM, TestRandomUtil.nextBytes(15)),\n        new CopyParameters(3, \"badlength\", 300, COPY_ENCRYPTION_PARAM, TestRandomUtil.nextBytes(15)));\n\n    when(tusCredentialGenerator.generateUpload(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"\", Collections.emptyMap(), \"\"));\n    when(remoteStorageManager.copy(eq(3), eq(\"success\"), eq(100), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n    when(remoteStorageManager.copy(eq(3), eq(\"missing\"), eq(200), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new SourceObjectNotFoundException()));\n    when(remoteStorageManager.copy(eq(3), eq(\"badlength\"), eq(300), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new InvalidLengthException(\"\")));\n\n    final List<CopyResult> results = backupManager.copyToBackup(backupManager.getCopyQuota(backupUser, toCopy))\n        .collectList().block();\n\n    assertThat(results).hasSize(3);\n    assertThat(results.get(0).outcome()).isEqualTo(CopyResult.Outcome.SUCCESS);\n    assertThat(results.get(1).outcome()).isEqualTo(CopyResult.Outcome.SOURCE_NOT_FOUND);\n    assertThat(results.get(2).outcome()).isEqualTo(CopyResult.Outcome.SOURCE_WRONG_LENGTH);\n\n    // usage should be rolled back after a known copy failure\n    final Map<String, AttributeValue> backup = getBackupItem(backupUser);\n    assertThat(AttributeValues.getLong(backup, BackupsDb.ATTR_MEDIA_BYTES_USED, -1L))\n        .isEqualTo(toCopy.getFirst().destinationObjectSize());\n    assertThat(AttributeValues.getLong(backup, BackupsDb.ATTR_MEDIA_COUNT, -1L)).isEqualTo(1L);\n  }\n\n  @Test\n  public void copyWrongCredentialType() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n\n    assertThatExceptionOfType(BackupWrongCredentialTypeException.class).isThrownBy(() -> copy(backupUser));\n  }\n\n  @Test\n  public void quotaEnforcementNoRecalculation() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    verifyNoInteractions(remoteStorageManager);\n\n    // set the backupsDb to be out of quota at t=0\n    testClock.pin(Instant.ofEpochSecond(1));\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(MAX_TOTAL_MEDIA_BYTES, 1000)).join();\n    // check still within staleness bound (t=0 + 1 day - 1 sec)\n    testClock.pin(Instant.ofEpochSecond(0)\n        .plus(backupConfiguration.maxQuotaStaleness())\n        .minus(Duration.ofSeconds(1)));\n\n    // Try to copy\n    assertThat(copy(backupUser).outcome()).isEqualTo(CopyResult.Outcome.OUT_OF_QUOTA);\n  }\n\n  @Test\n  public void quotaEnforcementRecalculation() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final String backupMediaPrefix = \"%s/%s/\".formatted(backupUser.backupDir(), backupUser.mediaDir());\n\n    final long remainingAfterRecalc = MAX_TOTAL_MEDIA_BYTES - COPY_PARAM.destinationObjectSize();\n\n    // on recalculation, say there's actually enough left to do the copy\n    when(remoteStorageManager.calculateBytesUsed(eq(backupMediaPrefix)))\n        .thenReturn(CompletableFuture.completedFuture(new UsageInfo(remainingAfterRecalc, 1000)));\n\n    // set the backupsDb to be totally out of quota at t=0\n    testClock.pin(Instant.ofEpochSecond(0));\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(MAX_TOTAL_MEDIA_BYTES, 1000)).join();\n    testClock.pin(Instant.ofEpochSecond(0).plus(backupConfiguration.maxQuotaStaleness()));\n\n    // Should recalculate quota and copy can succeed\n    assertThat(copy(backupUser).outcome()).isEqualTo(CopyResult.Outcome.SUCCESS);\n\n    // backupsDb should have the new value\n    final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();\n    assertThat(info.lastRecalculationTime())\n        .isEqualTo(Instant.ofEpochSecond(0).plus(backupConfiguration.maxQuotaStaleness()));\n    assertThat(info.usageInfo().bytesUsed()).isEqualTo(MAX_TOTAL_MEDIA_BYTES);\n    assertThat(info.usageInfo().numObjects()).isEqualTo(1001);\n  }\n\n  @CartesianTest()\n  public void quotaEnforcement(\n      @CartesianTest.Values(booleans = {true, false}) boolean hasSpaceBeforeRecalc,\n      @CartesianTest.Values(booleans = {true, false}) boolean hasSpaceAfterRecalc,\n      @CartesianTest.Values(booleans = {true, false}) boolean doesReaclc) throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final String backupMediaPrefix = \"%s/%s/\".formatted(backupUser.backupDir(), backupUser.mediaDir());\n\n    final long destSize = COPY_PARAM.destinationObjectSize();\n    final long originalRemainingSpace =\n        MAX_TOTAL_MEDIA_BYTES - (hasSpaceBeforeRecalc ? destSize : (destSize - 1));\n    final long afterRecalcRemainingSpace =\n        MAX_TOTAL_MEDIA_BYTES - (hasSpaceAfterRecalc ? destSize : (destSize - 1));\n\n    // set the backupsDb to be out of quota at t=0\n    testClock.pin(Instant.ofEpochSecond(0));\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(originalRemainingSpace, 1000)).join();\n\n    if (doesReaclc) {\n      testClock.pin(Instant.ofEpochSecond(0).plus(backupConfiguration.maxQuotaStaleness()).plus(Duration.ofSeconds(1)));\n      when(remoteStorageManager.calculateBytesUsed(eq(backupMediaPrefix)))\n          .thenReturn(CompletableFuture.completedFuture(new UsageInfo(afterRecalcRemainingSpace, 1000)));\n    }\n    final CopyResult copyResult = copy(backupUser);\n    if (hasSpaceBeforeRecalc || (hasSpaceAfterRecalc && doesReaclc)) {\n      assertThat(copyResult.outcome()).isEqualTo(CopyResult.Outcome.SUCCESS);\n    } else {\n      assertThat(copyResult.outcome()).isEqualTo(CopyResult.Outcome.OUT_OF_QUOTA);\n    }\n    if (doesReaclc && !hasSpaceBeforeRecalc) {\n      // should have recalculated if we exceeded quota\n      verify(remoteStorageManager, times(1)).calculateBytesUsed(anyString());\n    }\n  }\n\n  @Test\n  public void requestRecalculation() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final String backupMediaPrefix = \"%s/%s/\".formatted(backupUser.backupDir(), backupUser.mediaDir());\n    final UsageInfo oldUsage = new UsageInfo(1000, 100);\n    final UsageInfo newUsage = new UsageInfo(2000, 200);\n\n    testClock.pin(Instant.ofEpochSecond(123));\n    backupsDb.setMediaUsage(backupUser, oldUsage).join();\n    when(remoteStorageManager.calculateBytesUsed(eq(backupMediaPrefix)))\n        .thenReturn(CompletableFuture.completedFuture(newUsage));\n    final StoredBackupAttributes attrs = backupManager.listBackupAttributes(1)\n        .single()\n        .blockOptional().orElseThrow();\n\n    testClock.pin(Instant.ofEpochSecond(456));\n    assertThat(backupManager.recalculateQuota(attrs).toCompletableFuture().join())\n        .get()\n        .isEqualTo(new BackupManager.RecalculationResult(oldUsage, newUsage));\n\n    // backupsDb should have the new value\n    final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();\n    assertThat(info.lastRecalculationTime()).isEqualTo(Instant.ofEpochSecond(456));\n    assertThat(info.usageInfo()).isEqualTo(newUsage);\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"\", \"cursor\"})\n  public void list(final String cursorVal) throws BackupException {\n    final Optional<String> cursor = Optional.of(cursorVal).filter(StringUtils::isNotBlank);\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n    final String backupMediaPrefix = \"%s/%s/\".formatted(backupUser.backupDir(), backupUser.mediaDir());\n\n    when(remoteStorageManager.cdnNumber()).thenReturn(13);\n    when(remoteStorageManager.list(eq(backupMediaPrefix), eq(cursor), eq(17L)))\n        .thenReturn(CompletableFuture.completedFuture(new RemoteStorageManager.ListResult(\n            List.of(new RemoteStorageManager.ListResult.Entry(\"aaa\", 123)),\n            Optional.of(\"newCursor\")\n        )));\n\n    final BackupManager.ListMediaResult result = backupManager.list(backupUser, cursor, 17);\n    assertThat(result.media()).hasSize(1);\n    assertThat(result.media().getFirst().cdn()).isEqualTo(13);\n    assertThat(result.media().getFirst().key()).isEqualTo(\n        Base64.getDecoder().decode(\"aaa\".getBytes(StandardCharsets.UTF_8)));\n    assertThat(result.media().getFirst().length()).isEqualTo(123);\n    assertThat(result.cursor().orElseThrow()).isEqualTo(\"newCursor\");\n\n  }\n\n  @Test\n  public void deleteEntireBackup() throws BackupException {\n    final AuthenticatedBackupUser original = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n\n    testClock.pin(Instant.ofEpochSecond(10));\n\n    when(svrbClient.removeData(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n\n    // Deleting should swap the backupDir for the user\n    backupManager.deleteEntireBackup(original);\n    verifyNoInteractions(remoteStorageManager);\n    verify(svrbClient).removeData(HexFormat.of().formatHex(BackupsDb.hashedBackupId(original.backupId())));\n\n    final AuthenticatedBackupUser after = retrieveBackupUser(original.backupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n    assertThat(original.backupDir()).isNotEqualTo(after.backupDir());\n    assertThat(original.mediaDir()).isNotEqualTo(after.mediaDir());\n\n    // Trying again should do the deletion inline\n    when(remoteStorageManager.list(anyString(), any(), anyLong()))\n        .thenReturn(CompletableFuture.completedFuture(new RemoteStorageManager.ListResult(\n            Collections.emptyList(),\n            Optional.empty()\n        )));\n    backupManager.deleteEntireBackup(after);\n    verify(remoteStorageManager, times(1))\n        .list(eq(after.backupDir() + \"/\"), eq(Optional.empty()), anyLong());\n\n    // The original prefix to expire should be flagged as requiring expiration\n    final ExpiredBackup expiredBackup = backupManager\n        .getExpiredBackups(1, Schedulers.immediate(), Instant.ofEpochSecond(1L))\n        .collectList()\n        .blockOptional().orElseThrow()\n        .getFirst();\n    assertThat(expiredBackup.hashedBackupId()).isEqualTo(hashedBackupId(original.backupId()));\n    assertThat(expiredBackup.prefixToDelete()).isEqualTo(original.backupDir());\n    assertThat(expiredBackup.expirationType()).isEqualTo(ExpiredBackup.ExpirationType.GARBAGE_COLLECTION);\n  }\n\n  @Test\n  public void delete() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final byte[] mediaId = TestRandomUtil.nextBytes(16);\n    final String backupMediaKey = \"%s/%s/%s\".formatted(\n        backupUser.backupDir(),\n        backupUser.mediaDir(),\n        BackupManager.encodeMediaIdForCdn(mediaId));\n\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(100, 1000)).join();\n\n    when(remoteStorageManager.delete(backupMediaKey))\n        .thenReturn(CompletableFuture.completedFuture(7L));\n    when(remoteStorageManager.cdnNumber()).thenReturn(5);\n    backupManager.deleteMedia(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId)))\n        .collectList().block();\n\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())\n        .isEqualTo(new UsageInfo(93, 999));\n  }\n\n  @Test\n  public void deleteWrongCredentialType() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n    final byte[] mediaId = TestRandomUtil.nextBytes(16);\n    assertThatExceptionOfType(BackupWrongCredentialTypeException.class)\n        .isThrownBy(() -> backupManager.deleteMedia(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId))).then().block());\n  }\n\n  @Test\n  public void deleteUnknownCdn() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final BackupManager.StorageDescriptor sd = new BackupManager.StorageDescriptor(4, TestRandomUtil.nextBytes(15));\n    when(remoteStorageManager.cdnNumber()).thenReturn(5);\n    assertThatThrownBy(() -> backupManager.deleteMedia(backupUser, List.of(sd)).then().toFuture().join())\n        .hasCauseInstanceOf(BackupInvalidArgumentException.class);\n  }\n\n  @Test\n  public void deleteUsageCheckpoints() throws InterruptedException, BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA,\n        BackupLevel.PAID);\n\n    // 100 objects, each 2 bytes large\n    final List<byte[]> mediaIds = IntStream.range(0, 100).mapToObj(_ -> TestRandomUtil.nextBytes(16)).toList();\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(200, 100)).join();\n\n    // One object is slow to delete\n    final CompletableFuture<Long> slowFuture = new CompletableFuture<>();\n    final String slowMediaKey = \"%s/%s/%s\".formatted(\n        backupUser.backupDir(),\n        backupUser.mediaDir(),\n        BackupManager.encodeMediaIdForCdn(mediaIds.get(backupConfiguration.usageCheckpointCount() + 3)));\n\n    when(remoteStorageManager.delete(anyString())).thenReturn(CompletableFuture.completedFuture(2L));\n    when(remoteStorageManager.delete(slowMediaKey)).thenReturn(slowFuture);\n    when(remoteStorageManager.cdnNumber()).thenReturn(5);\n\n\n    final Flux<BackupManager.StorageDescriptor> flux = backupManager.deleteMedia(backupUser,\n        mediaIds.stream()\n            .map(i -> new BackupManager.StorageDescriptor(5, i))\n            .toList());\n    final ArrayBlockingQueue<BackupManager.StorageDescriptor> sds = new ArrayBlockingQueue<>(100);\n    final CompletableFuture<Void> future = flux.doOnNext(sds::add).then().toFuture();\n    for (int i = 0; i < backupConfiguration.usageCheckpointCount(); i++) {\n      sds.poll(1, TimeUnit.SECONDS);\n    }\n\n    // We should still be waiting since we have a slow delete\n    assertThat(future).isNotDone();\n    // But we should checkpoint the usage periodically\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())\n        .isEqualTo(new UsageInfo(\n            200 - (2L * backupConfiguration.usageCheckpointCount()),\n            100 - backupConfiguration.usageCheckpointCount()));\n\n    slowFuture.complete(2L);\n    future.join();\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())\n        .isEqualTo(new UsageInfo(0L, 0L));\n  }\n\n  @Test\n  public void deletePartialFailure() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n\n    final List<BackupManager.StorageDescriptor> descriptors = new ArrayList<>();\n    long initialBytes = 0;\n    for (int i = 1; i <= 5; i++) {\n      final BackupManager.StorageDescriptor descriptor = new BackupManager.StorageDescriptor(5,\n          TestRandomUtil.nextBytes(15));\n      descriptors.add(descriptor);\n      final String backupMediaKey = \"%s/%s/%s\".formatted(\n          backupUser.backupDir(),\n          backupUser.mediaDir(),\n          BackupManager.encodeMediaIdForCdn(descriptor.key()));\n\n      initialBytes += i;\n      // fail deletion 3, otherwise return the corresponding object's size as i\n      final CompletableFuture<Long> deleteResult = i == 3\n          ? CompletableFuture.failedFuture(new IOException(\"oh no\"))\n          : CompletableFuture.completedFuture((long) i);\n\n      when(remoteStorageManager.delete(backupMediaKey)).thenReturn(deleteResult);\n    }\n    when(remoteStorageManager.cdnNumber()).thenReturn(5);\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(initialBytes, 5)).join();\n\n    final List<BackupManager.StorageDescriptor> deleted = backupManager\n        .deleteMedia(backupUser, descriptors)\n        .onErrorComplete()\n        .collectList()\n        .blockOptional().orElseThrow();\n    // first two objects should be deleted\n    assertThat(deleted.size()).isEqualTo(2);\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())\n        .isEqualTo(new UsageInfo(initialBytes - 1 - 2, 3));\n\n  }\n\n  @Test\n  public void alreadyDeleted() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    final byte[] mediaId = TestRandomUtil.nextBytes(16);\n    final String backupMediaKey = \"%s/%s/%s\".formatted(\n        backupUser.backupDir(),\n        backupUser.mediaDir(),\n        BackupManager.encodeMediaIdForCdn(mediaId));\n\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(100, 5)).join();\n\n    // Deletion doesn't remove anything\n    when(remoteStorageManager.delete(backupMediaKey)).thenReturn(CompletableFuture.completedFuture(0L));\n    when(remoteStorageManager.cdnNumber()).thenReturn(5);\n    backupManager.deleteMedia(backupUser, List.of(new BackupManager.StorageDescriptor(5, mediaId))).then().block();\n\n    assertThat(backupsDb.getMediaUsage(backupUser).join().usageInfo())\n        .isEqualTo(new UsageInfo(100, 5));\n  }\n\n  @Test\n  public void listExpiredBackups() throws BackupException {\n    final List<AuthenticatedBackupUser> backupUsers = IntStream.range(0, 10)\n        .mapToObj(_ -> backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID))\n        .toList();\n    for (int i = 0; i < backupUsers.size(); i++) {\n      testClock.pin(days(i));\n      backupManager.createMessageBackupUploadDescriptor(backupUsers.get(i));\n    }\n\n    // set of backup-id hashes that should be expired (initially t=0)\n    final Set<ByteBuffer> expectedHashes = new HashSet<>();\n\n    for (int i = 0; i < backupUsers.size(); i++) {\n      final Instant day = days(i);\n      testClock.pin(day);\n\n      // get backups expired at t=i\n      final List<ExpiredBackup> expired = backupManager\n          .getExpiredBackups(1, Schedulers.immediate(), day)\n          .collectList()\n          .blockOptional().orElseThrow();\n\n      // all the backups tht should be expired at t=i should be returned (ones with expiration time 0,1,...i-1)\n      assertThat(expired.size()).isEqualTo(expectedHashes.size());\n      assertThat(expired.stream()\n          .map(ExpiredBackup::hashedBackupId)\n          .map(ByteBuffer::wrap)\n          .allMatch(expectedHashes::contains)).isTrue();\n      assertThat(expired.stream().allMatch(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.ALL)).isTrue();\n\n      // on next iteration, backup i should be expired\n      expectedHashes.add(ByteBuffer.wrap(hashedBackupId(backupUsers.get(i).backupId())));\n    }\n  }\n\n  @Test\n  public void listExpiredBackupsByTier() throws BackupException {\n    final byte[] backupId = TestRandomUtil.nextBytes(16);\n\n    // refreshed media timestamp at t=5\n    testClock.pin(days(5));\n    backupManager.createMessageBackupUploadDescriptor(backupUser(backupId, BackupCredentialType.MESSAGES, BackupLevel.PAID));\n\n    // refreshed messages timestamp at t=6\n    testClock.pin(days(6));\n    backupManager.createMessageBackupUploadDescriptor(backupUser(backupId, BackupCredentialType.MESSAGES, BackupLevel.FREE));\n\n    Function<Instant, List<ExpiredBackup>> getExpired = time -> backupManager\n        .getExpiredBackups(1, Schedulers.immediate(), time)\n        .collectList().block();\n\n    assertThat(getExpired.apply(days(5))).isEmpty();\n\n    assertThat(getExpired.apply(days(6)))\n        .hasSize(1).first()\n        .matches(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.MEDIA, \"is media tier\");\n\n    assertThat(getExpired.apply(days(7)))\n        .hasSize(1).first()\n        .matches(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.ALL, \"is messages tier\");\n  }\n\n  @ParameterizedTest\n  @EnumSource(mode = EnumSource.Mode.INCLUDE, names = {\"MEDIA\", \"ALL\"})\n  public void expireBackup(ExpiredBackup.ExpirationType expirationType) throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n    backupManager.createMessageBackupUploadDescriptor(backupUser);\n\n    final String expectedPrefixToDelete = switch (expirationType) {\n      case ALL -> backupUser.backupDir();\n      case MEDIA -> backupUser.backupDir() + \"/\" + backupUser.mediaDir();\n      case GARBAGE_COLLECTION -> throw new IllegalArgumentException();\n    } + \"/\";\n\n    when(remoteStorageManager.list(eq(expectedPrefixToDelete), eq(Optional.empty()), anyLong()))\n        .thenReturn(CompletableFuture.completedFuture(new RemoteStorageManager.ListResult(List.of(\n            new RemoteStorageManager.ListResult.Entry(\"abc\", 1),\n            new RemoteStorageManager.ListResult.Entry(\"def\", 1),\n            new RemoteStorageManager.ListResult.Entry(\"ghi\", 1)), Optional.empty())));\n    when(remoteStorageManager.delete(anyString())).thenReturn(CompletableFuture.completedFuture(1L));\n\n    when(svrbClient.removeData(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n\n    backupManager.expireBackup(expiredBackup(expirationType, backupUser)).join();\n    verify(remoteStorageManager, times(1)).list(anyString(), any(), anyLong());\n    verify(remoteStorageManager, times(1)).delete(expectedPrefixToDelete + \"abc\");\n    verify(remoteStorageManager, times(1)).delete(expectedPrefixToDelete + \"def\");\n    verify(remoteStorageManager, times(1)).delete(expectedPrefixToDelete + \"ghi\");\n    verify(svrbClient, times(expirationType == ExpiredBackup.ExpirationType.ALL ? 1 : 0))\n        .removeData(HexFormat.of().formatHex(BackupsDb.hashedBackupId(backupUser.backupId())));\n    verifyNoMoreInteractions(remoteStorageManager);\n\n    final BackupsDb.TimestampedUsageInfo usage = backupsDb.getMediaUsage(backupUser).join();\n    assertThat(usage.usageInfo().bytesUsed()).isEqualTo(0L);\n    assertThat(usage.usageInfo().numObjects()).isEqualTo(0L);\n\n    if (expirationType == ExpiredBackup.ExpirationType.ALL) {\n      // should have deleted the db row for the backup\n      CompletableFutureTestUtil.assertFailsWithCause(\n          BackupFailedZkAuthenticationException.class,\n          backupsDb.describeBackup(backupUser));\n    } else {\n      // should have deleted all the media, but left the backup descriptor in place\n      assertThatNoException().isThrownBy(() -> backupsDb.describeBackup(backupUser).join());\n    }\n  }\n\n  @Test\n  public void deleteBackupPaginated() throws BackupException {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, BackupLevel.PAID);\n    backupManager.createMessageBackupUploadDescriptor(backupUser);\n\n    final ExpiredBackup expiredBackup = expiredBackup(ExpiredBackup.ExpirationType.MEDIA, backupUser);\n    final String mediaPrefix = expiredBackup.prefixToDelete() + \"/\";\n\n    // Return 1 item per page. Initially the provided cursor is empty and we'll return the cursor string \"1\".\n    // When we get the cursor \"1\", we'll return \"2\", when \"2\" we'll return empty indicating listing\n    // is complete\n    when(remoteStorageManager.list(eq(mediaPrefix), any(), anyLong())).thenAnswer(a -> {\n      Optional<String> cursor = a.getArgument(1);\n      return CompletableFuture.completedFuture(\n          new RemoteStorageManager.ListResult(List.of(new RemoteStorageManager.ListResult.Entry(\n              switch (cursor.orElse(\"0\")) {\n                case \"0\" -> \"abc\";\n                case \"1\" -> \"def\";\n                case \"2\" -> \"ghi\";\n                default -> throw new IllegalArgumentException();\n              }, 1L)),\n              switch (cursor.orElse(\"0\")) {\n                case \"0\" -> Optional.of(\"1\");\n                case \"1\" -> Optional.of(\"2\");\n                case \"2\" -> Optional.empty();\n                default -> throw new IllegalArgumentException();\n              }));\n    });\n    when(remoteStorageManager.delete(anyString())).thenReturn(CompletableFuture.completedFuture(1L));\n    backupManager.expireBackup(expiredBackup).join();\n    verify(remoteStorageManager, times(3)).list(anyString(), any(), anyLong());\n    verify(remoteStorageManager, times(1)).delete(mediaPrefix + \"abc\");\n    verify(remoteStorageManager, times(1)).delete(mediaPrefix + \"def\");\n    verify(remoteStorageManager, times(1)).delete(mediaPrefix + \"ghi\");\n    verifyNoMoreInteractions(remoteStorageManager);\n  }\n\n  @ParameterizedTest\n  @EnumSource(BackupLevel.class)\n  void svrbAuthValid(BackupLevel backupLevel) throws BackupException {\n    testClock.pin(Instant.ofEpochSecond(123));\n    final AuthenticatedBackupUser backupUser =\n        backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MESSAGES, backupLevel);\n    final ExternalServiceCredentials creds = backupManager.generateSvrbAuth(backupUser);\n\n    assertThat(HexFormat.of().parseHex(creds.username())).hasSize(16);\n    final String[] split = creds.password().split(\":\", 2);\n    assertThat(Long.parseLong(split[0])).isEqualTo(123);\n  }\n\n  @ParameterizedTest\n  @EnumSource(BackupLevel.class)\n  void svrbAuthInvalid(BackupLevel backupLevel) {\n    // Can't use MEDIA for svrb auth\n    final AuthenticatedBackupUser backupUser =\n        backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, backupLevel);\n    assertThatExceptionOfType(BackupWrongCredentialTypeException.class)\n        .isThrownBy(() -> backupManager.generateSvrbAuth(backupUser));\n  }\n\n  private CopyResult copyError(final AuthenticatedBackupUser backupUser, Throwable copyException) throws BackupException {\n    when(tusCredentialGenerator.generateUpload(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"def\", Collections.emptyMap(), \"\"));\n    when(remoteStorageManager.copy(eq(3), eq(COPY_PARAM.sourceKey()), eq(COPY_PARAM.sourceLength()), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(copyException));\n    return backupManager.copyToBackup(backupManager.getCopyQuota(backupUser, List.of(COPY_PARAM))).single().block();\n  }\n\n  private CopyResult copy(final AuthenticatedBackupUser backupUser) throws BackupException {\n    when(tusCredentialGenerator.generateUpload(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"def\", Collections.emptyMap(), \"\"));\n    when(tusCredentialGenerator.generateUpload(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"def\", Collections.emptyMap(), \"\"));\n    when(remoteStorageManager.copy(eq(3), eq(COPY_PARAM.sourceKey()), eq(COPY_PARAM.sourceLength()), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n    return backupManager.copyToBackup(backupManager.getCopyQuota(backupUser, List.of(COPY_PARAM))).single().block();\n  }\n\n  private static ExpiredBackup expiredBackup(final ExpiredBackup.ExpirationType expirationType,\n      final AuthenticatedBackupUser backupUser) {\n    return new ExpiredBackup(\n        hashedBackupId(backupUser.backupId()),\n        expirationType,\n        Instant.now(),\n        switch (expirationType) {\n          case ALL -> backupUser.backupDir();\n          case MEDIA -> backupUser.backupDir() + \"/\" + backupUser.mediaDir();\n          case GARBAGE_COLLECTION -> null;\n        });\n  }\n\n  private Map<String, AttributeValue> getBackupItem(final AuthenticatedBackupUser backupUser) {\n    return DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n            .tableName(DynamoDbExtensionSchema.Tables.BACKUPS.tableName())\n            .key(Map.of(BackupsDb.KEY_BACKUP_ID_HASH, AttributeValues.b(hashedBackupId(backupUser.backupId()))))\n            .build())\n        .item();\n  }\n\n  private void checkExpectedExpirations(\n      final Instant expectedExpiration,\n      final @Nullable Instant expectedMediaExpiration,\n      final AuthenticatedBackupUser backupUser) {\n    final Map<String, AttributeValue> item = getBackupItem(backupUser);\n    final Instant refresh = Instant.ofEpochSecond(Long.parseLong(item.get(BackupsDb.ATTR_LAST_REFRESH).n()));\n    assertThat(refresh).isEqualTo(expectedExpiration);\n\n    if (expectedMediaExpiration == null) {\n      assertThat(item).doesNotContainKey(BackupsDb.ATTR_LAST_MEDIA_REFRESH);\n    } else {\n      assertThat(Instant.ofEpochSecond(Long.parseLong(item.get(BackupsDb.ATTR_LAST_MEDIA_REFRESH).n())))\n          .isEqualTo(expectedMediaExpiration);\n    }\n  }\n\n  private static byte[] hashedBackupId(final byte[] backupId) {\n    try {\n      return Arrays.copyOf(MessageDigest.getInstance(\"SHA-256\").digest(backupId), 16);\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  /**\n   * Create BackupUser with the provided backupId, credential type, and tier\n   */\n  private AuthenticatedBackupUser backupUser(final byte[] backupId, final BackupCredentialType credentialType, final BackupLevel backupLevel) {\n    // Won't actually validate the public key, but need to have a public key to perform BackupsDB operations\n    byte[] privateKey = new byte[32];\n    ByteBuffer.wrap(privateKey).put(backupId);\n    try {\n      backupsDb.setPublicKey(backupId, backupLevel, new ECPrivateKey(privateKey).publicKey()).join();\n    } catch (InvalidKeyException e) {\n      throw new RuntimeException(e);\n    }\n    return retrieveBackupUser(backupId, credentialType, backupLevel);\n  }\n\n  /**\n   * Retrieve an existing BackupUser from the database\n   */\n  private AuthenticatedBackupUser retrieveBackupUser(final byte[] backupId, final BackupCredentialType credentialType, final BackupLevel backupLevel) {\n    final BackupsDb.AuthenticationData authData = backupsDb.retrieveAuthenticationData(backupId).join().orElseThrow();\n    return new AuthenticatedBackupUser(backupId, credentialType, backupLevel, authData.backupDir(), authData.mediaDir(), null);\n  }\n\n  private static Instant days(int n) {\n    return Instant.EPOCH.plus(Duration.ofDays(n));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/backup/BackupsDbTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Comparator;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.function.Function;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.scheduler.Schedulers;\n\npublic class BackupsDbTest {\n\n  @RegisterExtension\n  public static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.BACKUPS);\n\n  private final TestClock testClock = TestClock.now();\n  private BackupsDb backupsDb;\n\n  @BeforeEach\n  public void setup() {\n    testClock.unpin();\n    backupsDb = new BackupsDb(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.BACKUPS.tableName(),\n        testClock);\n  }\n\n  @Test\n  public void trackMediaStats() {\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    int total = 0;\n    for (int i = 0; i < 5; i++) {\n      this.backupsDb.trackMedia(backupUser, 1, i).join();\n      total += i;\n      final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();\n      final StoredBackupAttributes storedAttrs = backupsDb.ttlRefresh(backupUser).join();\n      assertThat(description.mediaUsedSpace().orElseThrow())\n          .isEqualTo(total)\n          .isEqualTo(storedAttrs.bytesUsed());\n      assertThat(storedAttrs.numObjects()).isEqualTo(i + 1);\n    }\n\n\n    for (int i = 0; i < 5; i++) {\n      this.backupsDb.trackMedia(backupUser, -1, -i).join();\n      total -= i;\n      final BackupsDb.BackupDescription description = this.backupsDb.describeBackup(backupUser).join();\n      final StoredBackupAttributes storedAttrs = backupsDb.ttlRefresh(backupUser).join();\n      assertThat(description.mediaUsedSpace().orElseThrow())\n          .isEqualTo(total)\n          .isEqualTo(storedAttrs.bytesUsed());\n      assertThat(storedAttrs.numObjects()).isEqualTo(5 - i - 1);\n    }\n\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {false, true})\n  public void setUsage(boolean mediaAlreadyExists) {\n    testClock.pin(Instant.ofEpochSecond(5));\n    final AuthenticatedBackupUser backupUser = backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID);\n    if (mediaAlreadyExists) {\n      this.backupsDb.trackMedia(backupUser, 1, 10).join();\n    }\n    backupsDb.setMediaUsage(backupUser, new UsageInfo(113, 17)).join();\n    final BackupsDb.TimestampedUsageInfo info = backupsDb.getMediaUsage(backupUser).join();\n    assertThat(info.lastRecalculationTime()).isEqualTo(Instant.ofEpochSecond(5));\n    assertThat(info.usageInfo().bytesUsed()).isEqualTo(113L);\n    assertThat(info.usageInfo().numObjects()).isEqualTo(17L);\n  }\n\n  @Test\n  public void expirationDetectedOnce() {\n    final byte[] backupId = TestRandomUtil.nextBytes(16);\n    // Refresh media/messages at t=0D\n    testClock.pin(days(0));\n    backupsDb.setPublicKey(backupId, BackupLevel.PAID, ECKeyPair.generate().getPublicKey()).join();\n    this.backupsDb.ttlRefresh(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.PAID)).join();\n\n    // refresh only messages on t=2D\n    testClock.pin(days(2).plus(Duration.ofSeconds(123)));\n    this.backupsDb.ttlRefresh(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.FREE)).join();\n\n    final Function<Instant, List<ExpiredBackup>> expiredBackups = purgeTime -> backupsDb\n        .getExpiredBackups(1, Schedulers.immediate(), purgeTime)\n        .collectList()\n        .block();\n\n    // the media should be expired at t=1D\n    List<ExpiredBackup> expired = expiredBackups.apply(days(1));\n    assertThat(expired).hasSize(1).first()\n        .matches(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.MEDIA);\n\n    // Expire the media\n    backupsDb.startExpiration(expired.getFirst()).join();\n    backupsDb.finishExpiration(expired.getFirst()).join();\n\n    // should be nothing left to expire at t=1D\n    assertThat(expiredBackups.apply(days(1))).isEmpty();\n\n    // at t=3D, should now expire messages as well\n    expired = expiredBackups.apply(days(3));\n    assertThat(expired).hasSize(1).first()\n        .matches(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.ALL);\n\n    // Expire the messages\n    backupsDb.startExpiration(expired.getFirst()).join();\n    backupsDb.finishExpiration(expired.getFirst()).join();\n\n    // should be nothing to expire at t=3\n    assertThat(expiredBackups.apply(days(3))).isEmpty();\n  }\n\n  @ParameterizedTest\n  @EnumSource(names = {\"MEDIA\", \"ALL\"})\n  public void expirationFailed(ExpiredBackup.ExpirationType expirationType) {\n    final byte[] backupId = TestRandomUtil.nextBytes(16);\n    // Refresh media/messages at t=0D\n    testClock.pin(days(0));\n    backupsDb.setPublicKey(backupId, BackupLevel.PAID, ECKeyPair.generate().getPublicKey()).join();\n    this.backupsDb.ttlRefresh(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.PAID)).join();\n\n    if (expirationType == ExpiredBackup.ExpirationType.MEDIA) {\n      // refresh only messages at t=2D so that we only expire media at t=1D\n      testClock.pin(days(2));\n      this.backupsDb.ttlRefresh(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.FREE)).join();\n    }\n\n    final Function<Instant, Optional<ExpiredBackup>> expiredBackups = purgeTime -> {\n      final List<ExpiredBackup> res = backupsDb\n          .getExpiredBackups(1, Schedulers.immediate(), purgeTime)\n          .collectList()\n          .block();\n      assertThat(res).hasSizeLessThanOrEqualTo(1);\n      return res.stream().findFirst();\n    };\n\n    BackupsDb.AuthenticationData info = backupsDb.retrieveAuthenticationData(backupId).join().get();\n    final String originalBackupDir = info.backupDir();\n    final String originalMediaDir = info.mediaDir();\n\n    ExpiredBackup expired = expiredBackups.apply(days(1)).get();\n    assertThat(expired).matches(eb -> eb.expirationType() == expirationType);\n\n    // expire but fail (don't call finishExpiration)\n    backupsDb.startExpiration(expired).join();\n    info = backupsDb.retrieveAuthenticationData(backupId).join().get();\n    if (expirationType == ExpiredBackup.ExpirationType.MEDIA) {\n      // Media expiration should swap the media name and keep the backup name, marking the old media name for expiration\n      assertThat(expired.prefixToDelete())\n          .withFailMessage(\"Should expire media directory, expired %s\", expired.prefixToDelete())\n          .isEqualTo(originalBackupDir + \"/\" + originalMediaDir);\n      assertThat(info.backupDir()).withFailMessage(\"should keep backupDir\").isEqualTo(originalBackupDir);\n      assertThat(info.mediaDir()).withFailMessage(\"should change mediaDir\").isNotEqualTo(originalMediaDir);\n    } else {\n      // Full expiration should swap the media name and the backup name, marking the old backup name for expiration\n      assertThat(expired.prefixToDelete())\n          .withFailMessage(\"Should expire whole backupDir, expired %s\", expired.prefixToDelete())\n          .isEqualTo(originalBackupDir);\n      assertThat(info.backupDir()).withFailMessage(\"should change backupDir\").isNotEqualTo(originalBackupDir);\n      assertThat(info.mediaDir()).withFailMessage(\"should change mediaDir\").isNotEqualTo(originalMediaDir);\n    }\n    final String expiredPrefix = expired.prefixToDelete();\n\n    // We failed, so we should see the same prefix on the next expiration listing\n    expired = expiredBackups.apply(days(1)).get();\n    assertThat(expired).matches(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.GARBAGE_COLLECTION,\n        \"Expiration should be garbage collection \");\n    assertThat(expired.prefixToDelete()).isEqualTo(expiredPrefix);\n    backupsDb.startExpiration(expired).join();\n\n    // Successfully finish the expiration\n    backupsDb.finishExpiration(expired).join();\n\n    Optional<ExpiredBackup> opt = expiredBackups.apply(days(1));\n    if (expirationType == ExpiredBackup.ExpirationType.MEDIA) {\n      // should be nothing to expire at t=1\n      assertThat(opt).isEmpty();\n      // The backup should still exist\n      backupsDb.describeBackup(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.PAID)).join();\n    } else {\n      // Cleaned up the failed attempt, now should tell us to clean the whole backup\n      assertThat(opt.get()).matches(eb -> eb.expirationType() == ExpiredBackup.ExpirationType.ALL,\n          \"Expiration should be all \");\n      backupsDb.startExpiration(opt.get()).join();\n      backupsDb.finishExpiration(opt.get()).join();\n\n      // The backup entry should be gone\n      CompletableFutureTestUtil.assertFailsWithCause(\n          BackupFailedZkAuthenticationException.class,\n          backupsDb.describeBackup(backupUser(backupId, BackupCredentialType.MEDIA, BackupLevel.PAID)));\n      assertThat(expiredBackups.apply(Instant.ofEpochSecond(10))).isEmpty();\n    }\n  }\n\n  @Test\n  public void list() {\n    final List<AuthenticatedBackupUser> users = List.of(\n        backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.FREE),\n        backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID),\n        backupUser(TestRandomUtil.nextBytes(16), BackupCredentialType.MEDIA, BackupLevel.PAID));\n\n    final List<Instant> lastRefreshTimes = List.of(\n        days(1).plus(Duration.ofSeconds(12)),\n        days(2).plus(Duration.ofSeconds(34)),\n        days(3).plus(Duration.ofSeconds(56)));\n\n    // add at least one message backup, so we can describe it\n    for (int i = 0; i < users.size(); i++) {\n      testClock.pin(lastRefreshTimes.get(i));\n      backupsDb.addMessageBackup(users.get(i)).join();\n    }\n\n    backupsDb.trackMedia(users.get(1), 10, 100).join();\n    backupsDb.trackMedia(users.get(2), 1, 1000).join();\n\n    final List<StoredBackupAttributes> sbms = backupsDb.listBackupAttributes(1)\n        .sort(Comparator.comparing(StoredBackupAttributes::lastRefresh))\n        .collectList()\n        .block();\n\n    final StoredBackupAttributes sbm1 = sbms.get(0);\n    assertThat(sbm1.bytesUsed()).isEqualTo(0);\n    assertThat(sbm1.numObjects()).isEqualTo(0);\n    assertThat(sbm1.lastRefresh()).isEqualTo(lastRefreshTimes.get(0).truncatedTo(ChronoUnit.DAYS));\n    assertThat(sbm1.lastMediaRefresh()).isEqualTo(Instant.EPOCH);\n\n\n    final StoredBackupAttributes sbm2 = sbms.get(1);\n    assertThat(sbm2.bytesUsed()).isEqualTo(100);\n    assertThat(sbm2.numObjects()).isEqualTo(10);\n    assertThat(sbm2.lastRefresh()).isEqualTo(lastRefreshTimes.get(1).truncatedTo(ChronoUnit.DAYS));\n    assertThat(sbm2.lastMediaRefresh()).isEqualTo(lastRefreshTimes.get(1).truncatedTo(ChronoUnit.DAYS));\n\n    final StoredBackupAttributes sbm3 = sbms.get(2);\n    assertThat(sbm3.bytesUsed()).isEqualTo(1000);\n    assertThat(sbm3.numObjects()).isEqualTo(1);\n    assertThat(sbm3.lastRefresh()).isEqualTo(lastRefreshTimes.get(2).truncatedTo(ChronoUnit.DAYS));\n    assertThat(sbm3.lastMediaRefresh()).isEqualTo(lastRefreshTimes.get(2).truncatedTo(ChronoUnit.DAYS));\n  }\n\n  private static Instant days(int n) {\n    return Instant.EPOCH.plus(Duration.ofDays(n));\n  }\n\n  private AuthenticatedBackupUser backupUser(final byte[] backupId, final BackupCredentialType credentialType, final BackupLevel backupLevel) {\n    return new AuthenticatedBackupUser(backupId, credentialType, backupLevel, \"myBackupDir\", \"myMediaDir\", null);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/backup/Cdn3BackupCredentialGeneratorTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.backup;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.nio.charset.StandardCharsets;\nimport java.util.Base64;\nimport java.util.Map;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.attachments.TusConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\npublic class Cdn3BackupCredentialGeneratorTest {\n  @Test\n  public void uploadGenerator() {\n    Cdn3BackupCredentialGenerator generator = new Cdn3BackupCredentialGenerator(new TusConfiguration(\n        new SecretBytes(TestRandomUtil.nextBytes(32)),\n        \"https://example.org/upload\"));\n\n    final BackupUploadDescriptor messageBackupUploadDescriptor = generator.generateUpload(\"subdir/key\");\n    assertThat(messageBackupUploadDescriptor.signedUploadLocation()).isEqualTo(\"https://example.org/upload/backups\");\n    assertThat(messageBackupUploadDescriptor.key()).isEqualTo(\"subdir/key\");\n    assertThat(messageBackupUploadDescriptor.headers()).containsKey(\"Authorization\");\n    final String username = parseUsername(messageBackupUploadDescriptor.headers().get(\"Authorization\"));\n    assertThat(username).isEqualTo(\"write$backups/subdir/key\");\n  }\n\n  @Test\n  public void readCredential() {\n    Cdn3BackupCredentialGenerator generator = new Cdn3BackupCredentialGenerator(new TusConfiguration(\n        new SecretBytes(TestRandomUtil.nextBytes(32)),\n        \"https://example.org/upload\"));\n\n    final Map<String, String> headers = generator.readHeaders(\"subdir\");\n    assertThat(headers).containsKey(\"Authorization\");\n    final String username = parseUsername(headers.get(\"Authorization\"));\n    assertThat(username).isEqualTo(\"read$backups/subdir\");\n  }\n\n  private static String parseUsername(final String authHeader) {\n    assertThat(authHeader).startsWith(\"Basic\");\n    final String encoded = authHeader.substring(\"Basic\".length() + 1);\n    final String cred = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8);\n    return cred.split(\":\")[0];\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/backup/Cdn3RemoteStorageManagerTest.java",
    "content": "package org.whispersystems.textsecuregcm.backup;\n\nimport static com.github.tomakehurst.wiremock.client.WireMock.aResponse;\nimport static com.github.tomakehurst.wiremock.client.WireMock.equalTo;\nimport static com.github.tomakehurst.wiremock.client.WireMock.get;\nimport static com.github.tomakehurst.wiremock.client.WireMock.put;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;\nimport static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.github.tomakehurst.wiremock.client.WireMock;\nimport com.github.tomakehurst.wiremock.junit5.WireMockExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.Executors;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.configuration.Cdn3StorageManagerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretString;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class Cdn3RemoteStorageManagerTest {\n\n  private static final byte[] HMAC_KEY = TestRandomUtil.nextBytes(32);\n  private static final byte[] AES_KEY = TestRandomUtil.nextBytes(32);\n\n  @RegisterExtension\n  private static final WireMockExtension wireMock = WireMockExtension.newInstance()\n      .options(wireMockConfig().dynamicPort())\n      .build();\n\n  private RemoteStorageManager remoteStorageManager;\n\n  @BeforeEach\n  public void init() {\n    remoteStorageManager = new Cdn3RemoteStorageManager(\n        Executors.newCachedThreadPool(),\n        Executors.newSingleThreadScheduledExecutor(),\n        new Cdn3StorageManagerConfiguration(\n            wireMock.url(\"storage-manager/\"),\n            \"clientId\",\n            new SecretString(\"clientSecret\"),\n            Map.of(2, \"gcs\", 3, \"r2\"),\n            2,\n            null,\n            null));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {2, 3})\n  public void copy(final int sourceCdn) throws JsonProcessingException {\n    final MediaEncryptionParameters encryptionParameters = new MediaEncryptionParameters(AES_KEY, HMAC_KEY);\n    final String scheme = switch (sourceCdn) {\n      case 2 -> \"gcs\";\n      case 3 -> \"r2\";\n      default -> throw new AssertionError();\n    };\n    final Cdn3RemoteStorageManager.Cdn3CopyRequest expectedCopyRequest = new Cdn3RemoteStorageManager.Cdn3CopyRequest(\n        encryptionParameters,\n        new Cdn3RemoteStorageManager.Cdn3CopyRequest.SourceDescriptor(scheme, \"a/test/source\"),\n        100,\n        \"a/destination\");\n    wireMock.stubFor(put(urlEqualTo(\"/storage-manager/copy\"))\n        .withHeader(HttpHeaders.CONTENT_TYPE, equalTo(\"application/json\"))\n        .withRequestBody(WireMock.equalToJson(SystemMapper.jsonMapper().writeValueAsString(expectedCopyRequest)))\n        .willReturn(aResponse().withStatus(204)));\n    assertThatNoException().isThrownBy(() ->\n        remoteStorageManager.copy(\n                sourceCdn,\n                \"a/test/source\",\n                100,\n                encryptionParameters,\n                \"a/destination\")\n            .toCompletableFuture().join());\n  }\n\n  @Test\n  public void copyIncorrectLength() {\n    wireMock.stubFor(put(urlPathEqualTo(\"/storage-manager/copy\")).willReturn(aResponse().withStatus(409)));\n    CompletableFutureTestUtil.assertFailsWithCause(InvalidLengthException.class,\n        remoteStorageManager.copy(\n            2,\n            \"a/test/source\",\n            100,\n            new MediaEncryptionParameters(AES_KEY, HMAC_KEY),\n            \"a/destination\").toCompletableFuture());\n  }\n\n  @Test\n  public void copySourceMissing() {\n    wireMock.stubFor(put(urlPathEqualTo(\"/storage-manager/copy\")).willReturn(aResponse().withStatus(404)));\n    CompletableFutureTestUtil.assertFailsWithCause(SourceObjectNotFoundException.class,\n        remoteStorageManager.copy(\n            2,\n            \"a/test/source\",\n            100,\n            new MediaEncryptionParameters(AES_KEY, HMAC_KEY),\n            \"a/destination\").toCompletableFuture());\n  }\n\n  @Test\n  public void copyUnknownCdn() {\n    CompletableFutureTestUtil.assertFailsWithCause(SourceObjectNotFoundException.class,\n        remoteStorageManager.copy(\n            0,\n            \"a/test/source\",\n            100,\n            new MediaEncryptionParameters(AES_KEY, HMAC_KEY),\n            \"a/destination\").toCompletableFuture());\n  }\n\n  @Test\n  public void list() throws JsonProcessingException {\n    wireMock.stubFor(get(urlPathEqualTo(\"/storage-manager/backups/\"))\n        .withQueryParam(\"prefix\", equalTo(\"abc/\"))\n        .withQueryParam(\"limit\", equalTo(\"3\"))\n        .withHeader(Cdn3RemoteStorageManager.CLIENT_ID_HEADER, equalTo(\"clientId\"))\n        .withHeader(Cdn3RemoteStorageManager.CLIENT_SECRET_HEADER, equalTo(\"clientSecret\"))\n        .willReturn(aResponse()\n            .withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.Cdn3ListResponse(\n                List.of(\n                    new Cdn3RemoteStorageManager.Cdn3ListResponse.Entry(\"abc/x/y\", 3),\n                    new Cdn3RemoteStorageManager.Cdn3ListResponse.Entry(\"abc/y\", 4),\n                    new Cdn3RemoteStorageManager.Cdn3ListResponse.Entry(\"abc/z\", 5)\n                ), \"cursor\")))));\n    final RemoteStorageManager.ListResult result = remoteStorageManager\n        .list(\"abc/\", Optional.empty(), 3)\n        .toCompletableFuture().join();\n    assertThat(result.cursor()).get().isEqualTo(\"cursor\");\n    assertThat(result.objects()).hasSize(3);\n\n    // should strip the common prefix\n    assertThat(result.objects()).isEqualTo(List.of(\n        new RemoteStorageManager.ListResult.Entry(\"x/y\", 3),\n        new RemoteStorageManager.ListResult.Entry(\"y\", 4),\n        new RemoteStorageManager.ListResult.Entry(\"z\", 5)));\n  }\n\n  @Test\n  public void prefixMissing() throws JsonProcessingException {\n    wireMock.stubFor(get(urlPathEqualTo(\"/storage-manager/backups/\"))\n        .willReturn(aResponse()\n            .withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.Cdn3ListResponse(\n                List.of(new Cdn3RemoteStorageManager.Cdn3ListResponse.Entry(\"x\", 3)),\n                \"cursor\")))));\n    CompletableFutureTestUtil.assertFailsWithCause(IOException.class,\n        remoteStorageManager.list(\"abc/\", Optional.empty(), 3).toCompletableFuture());\n  }\n\n  @Test\n  public void usage() throws JsonProcessingException {\n    wireMock.stubFor(get(urlPathEqualTo(\"/storage-manager/usage\"))\n        .withQueryParam(\"prefix\", equalTo(\"abc/\"))\n        .withHeader(Cdn3RemoteStorageManager.CLIENT_ID_HEADER, equalTo(\"clientId\"))\n        .withHeader(Cdn3RemoteStorageManager.CLIENT_SECRET_HEADER, equalTo(\"clientSecret\"))\n        .willReturn(aResponse()\n            .withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.UsageResponse(\n                17,\n                113)))));\n    final UsageInfo result = remoteStorageManager.calculateBytesUsed(\"abc/\")\n        .toCompletableFuture()\n        .join();\n    assertThat(result.numObjects()).isEqualTo(17);\n    assertThat(result.bytesUsed()).isEqualTo(113);\n  }\n\n  @Test\n  public void delete() throws JsonProcessingException {\n    wireMock.stubFor(WireMock.delete(urlEqualTo(\"/storage-manager/backups/abc/def\"))\n        .withHeader(Cdn3RemoteStorageManager.CLIENT_ID_HEADER, equalTo(\"clientId\"))\n        .withHeader(Cdn3RemoteStorageManager.CLIENT_SECRET_HEADER, equalTo(\"clientSecret\"))\n        .willReturn(aResponse()\n            .withBody(SystemMapper.jsonMapper().writeValueAsString(new Cdn3RemoteStorageManager.DeleteResponse(9L)))));\n    final long deleted = remoteStorageManager.delete(\"abc/def\").toCompletableFuture().join();\n    assertThat(deleted).isEqualTo(9L);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/badges/ConfiguredProfileBadgeConverterTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.badges;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatNullPointerException;\nimport static org.junit.jupiter.params.provider.Arguments.arguments;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.mock;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.ListResourceBundle;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.ResourceBundle;\nimport java.util.ResourceBundle.Control;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.i18n.HeaderControlledResourceBundleLookup;\nimport org.signal.i18n.ResourceBundleFactory;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.entities.SelfBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\npublic class ConfiguredProfileBadgeConverterTest {\n\n  private final Clock clock = TestClock.pinned(Instant.ofEpochSecond(42));\n  private ResourceBundleFactory resourceBundleFactory;\n  private ResourceBundle resourceBundle;\n\n  @BeforeEach\n  void beforeEach() {\n    resourceBundleFactory = mock(ResourceBundleFactory.class, (invocation) -> {\n      throw new UnsupportedOperationException();\n    });\n  }\n\n  private static String idFor(int i) {\n    return \"Badge-\" + i;\n  }\n\n  private static String nameFor(int i) {\n    return \"TRANSLATED NAME \" + i;\n  }\n\n  private static String desriptionFor(int i) {\n    return \"TRANSLATED DESCRIPTION \" + i;\n  }\n\n  private static BadgeConfiguration newBadge(int i) {\n    return new BadgeConfiguration(\n        idFor(i), \"other\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n        List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")));\n  }\n\n  private BadgesConfiguration createBadges(int count) {\n    List<BadgeConfiguration> badges = new ArrayList<>(count);\n    Object[][] objects = new Object[count * 2][2];\n    for (int i = 0; i < count; i++) {\n      badges.add(newBadge(i));\n      objects[(i * 2)] = new Object[]{idFor(i) + \"_name\", nameFor(i)};\n      objects[(i * 2) + 1] = new Object[]{idFor(i) + \"_description\", desriptionFor(i)};\n    }\n    resourceBundle = new ListResourceBundle() {\n      @Override\n      protected Object[][] getContents() {\n        return objects;\n      }\n    };\n    return new BadgesConfiguration(badges, List.of(), Map.of());\n  }\n\n  private BadgeConfiguration getBadge(BadgesConfiguration badgesConfiguration, int i) {\n    return badgesConfiguration.getBadges().stream()\n        .filter(badgeConfiguration -> idFor(i).equals(badgeConfiguration.getId()))\n        .findFirst().orElse(null);\n  }\n\n  private ArgumentCaptor<ResourceBundle.Control> setupResourceBundle(Locale expectedLocale) {\n    ArgumentCaptor<ResourceBundle.Control> controlArgumentCaptor =\n        ArgumentCaptor.forClass(ResourceBundle.Control.class);\n    doReturn(resourceBundle).when(resourceBundleFactory).createBundle(\n        eq(ConfiguredProfileBadgeConverter.BASE_NAME), eq(expectedLocale), controlArgumentCaptor.capture());\n    return controlArgumentCaptor;\n  }\n\n  @Test\n  void testConvertEmptyList() {\n    BadgesConfiguration badgesConfiguration = createBadges(1);\n    ConfiguredProfileBadgeConverter badgeConverter = new ConfiguredProfileBadgeConverter(clock, badgesConfiguration,\n        new HeaderControlledResourceBundleLookup(resourceBundleFactory));\n    assertThat(badgeConverter.convert(List.of(Locale.getDefault()), List.of(), false)).isNotNull().isEmpty();\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testNoLocales(String name, Instant expiration, boolean visible, boolean isSelf, Badge expectedBadge) {\n    BadgesConfiguration badgesConfiguration = createBadges(1);\n    ConfiguredProfileBadgeConverter badgeConverter =\n        new ConfiguredProfileBadgeConverter(clock, badgesConfiguration,\n            new HeaderControlledResourceBundleLookup(resourceBundleFactory));\n    setupResourceBundle(Locale.getDefault());\n\n    if (expectedBadge != null) {\n      assertThat(badgeConverter.convert(List.of(), List.of(new AccountBadge(name, expiration, visible)), isSelf))\n          .isNotNull()\n          .hasSize(1)\n          .containsOnly(expectedBadge);\n    } else {\n      assertThat(badgeConverter.convert(List.of(), List.of(new AccountBadge(name, expiration, visible)), isSelf))\n          .isNotNull()\n          .isEmpty();\n    }\n  }\n\n  @SuppressWarnings(\"unused\")\n  static Stream<Arguments> testNoLocales() {\n    Instant expired = Instant.ofEpochSecond(41);\n    Instant notExpired = Instant.ofEpochSecond(43);\n    return Stream.of(\n        arguments(idFor(0), expired, false, false, null),\n        arguments(idFor(0), notExpired, false, false, null),\n        arguments(idFor(0), expired, true, false, null),\n        arguments(idFor(0), notExpired, true, false,\n            new Badge(idFor(0), \"other\", nameFor(0), desriptionFor(0), List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")))),\n        arguments(idFor(1), expired, false, false, null),\n        arguments(idFor(1), notExpired, false, false, null),\n        arguments(idFor(1), expired, true, false, null),\n        arguments(idFor(1), notExpired, true, false, null),\n        arguments(idFor(0), expired, false, true, null),\n        arguments(idFor(0), notExpired, false, true,\n            new SelfBadge(idFor(0), \"other\", nameFor(0), desriptionFor(0), List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"),\n                \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")),\n                notExpired, false)),\n        arguments(idFor(0), expired, true, true, null),\n        arguments(idFor(0), notExpired, true, true,\n            new SelfBadge(idFor(0), \"other\", nameFor(0), desriptionFor(0), List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"),\n                \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")),\n                notExpired, true)),\n        arguments(idFor(1), expired, false, true, null),\n        arguments(idFor(1), notExpired, false, true, null),\n        arguments(idFor(1), expired, true, true, null),\n        arguments(idFor(1), notExpired, true, true, null));\n  }\n\n  @Test\n  void testCustomControl() {\n    BadgesConfiguration badgesConfiguration = createBadges(1);\n    ConfiguredProfileBadgeConverter badgeConverter =\n        new ConfiguredProfileBadgeConverter(clock, badgesConfiguration,\n            new HeaderControlledResourceBundleLookup(resourceBundleFactory));\n\n    Locale defaultLocale = Locale.getDefault();\n    Locale enGb = new Locale(\"en\", \"GB\");\n    Locale en = new Locale(\"en\");\n    Locale esUs = new Locale(\"es\", \"US\");\n\n    ArgumentCaptor<Control> controlArgumentCaptor = setupResourceBundle(enGb);\n    badgeConverter.convert(List.of(enGb, en, esUs),\n        List.of(new AccountBadge(idFor(0), Instant.ofEpochSecond(43), true)), false);\n    Control control = controlArgumentCaptor.getValue();\n\n    assertThatNullPointerException().isThrownBy(() -> control.getFormats(null));\n    assertThatNullPointerException().isThrownBy(() -> control.getFallbackLocale(null, enGb));\n    assertThatNullPointerException().isThrownBy(\n        () -> control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, null));\n\n    assertThat(control.getFormats(ConfiguredProfileBadgeConverter.BASE_NAME)).isNotNull().hasSize(1).containsOnly(\n        Control.FORMAT_PROPERTIES.toArray(new String[0]));\n\n    try {\n      // temporarily override for purpose of ensuring this test doesn't change based on system default locale\n      Locale.setDefault(new Locale(\"xx\", \"XX\"));\n\n      assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, enGb)).isEqualTo(en);\n      assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, en)).isEqualTo(esUs);\n      assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, esUs)).isEqualTo(\n          Locale.getDefault());\n      assertThat(control.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, Locale.getDefault())).isNull();\n\n      // now test what happens if the system default locale is in the list\n      // this should always terminate at the system default locale since the development defined bundle should get\n      // returned at that point anyhow\n      badgeConverter.convert(List.of(enGb, Locale.getDefault(), en, esUs),\n          List.of(new AccountBadge(idFor(0), Instant.ofEpochSecond(43), true)), false);\n      Control control2 = controlArgumentCaptor.getValue();\n\n      assertThat(control2.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, enGb)).isEqualTo(\n          Locale.getDefault());\n      assertThat(control2.getFallbackLocale(ConfiguredProfileBadgeConverter.BASE_NAME, Locale.getDefault())).isNull();\n    } finally {\n      Locale.setDefault(defaultLocale);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/captcha/CaptchaCheckerTest.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.captcha.CaptchaChecker.SEPARATOR;\n\nimport jakarta.ws.rs.BadRequestException;\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\npublic class CaptchaCheckerTest {\n\n  private static final String CHALLENGE_SITE_KEY = \"challenge-site-key\";\n  private static final String REG_SITE_KEY = \"registration-site-key\";\n  private static final String TOKEN = \"some-token\";\n  private static final String PREFIX = \"prefix\";\n  private static final String PREFIX_A = \"prefix-a\";\n  private static final String PREFIX_B = \"prefix-b\";\n  private static final String USER_AGENT = \"user-agent\";\n  private static final UUID ACI = UUID.randomUUID();\n\n  static Stream<Arguments> parseInputToken() {\n    return Stream.of(\n        Arguments.of(\n            String.join(SEPARATOR, PREFIX, CHALLENGE_SITE_KEY, \"challenge\", TOKEN),\n            TOKEN,\n            CHALLENGE_SITE_KEY,\n            Action.CHALLENGE),\n        Arguments.of(\n            String.join(SEPARATOR, PREFIX, REG_SITE_KEY, \"registration\", TOKEN),\n            TOKEN,\n            REG_SITE_KEY,\n            Action.REGISTRATION),\n        Arguments.of(\n            String.join(SEPARATOR, PREFIX, CHALLENGE_SITE_KEY, \"challenge\", TOKEN, \"something-else\"),\n            TOKEN + SEPARATOR + \"something-else\",\n            CHALLENGE_SITE_KEY,\n            Action.CHALLENGE),\n        Arguments.of(\n            String.join(SEPARATOR, PREFIX, CHALLENGE_SITE_KEY, \"ChAlLeNgE\", TOKEN),\n            TOKEN,\n            CHALLENGE_SITE_KEY,\n            Action.CHALLENGE)\n    );\n  }\n\n  private static CaptchaClient mockClient(final String prefix) throws IOException {\n    final CaptchaClient captchaClient = mock(CaptchaClient.class);\n    when(captchaClient.scheme()).thenReturn(prefix);\n    when(captchaClient.validSiteKeys(eq(Action.CHALLENGE))).thenReturn(Collections.singleton(CHALLENGE_SITE_KEY));\n    when(captchaClient.validSiteKeys(eq(Action.REGISTRATION))).thenReturn(Collections.singleton(REG_SITE_KEY));\n    when(captchaClient.verify(any(), any(), any(), any(), any(), any())).thenReturn(AssessmentResult.invalid());\n    return captchaClient;\n  }\n\n\n  @ParameterizedTest\n  @MethodSource\n  void parseInputToken(\n      final String input,\n      final String expectedToken,\n      final String siteKey,\n      final Action expectedAction) throws IOException {\n    final CaptchaClient captchaClient = mockClient(PREFIX);\n    new CaptchaChecker(null, PREFIX -> captchaClient).verify(Optional.empty(), expectedAction, input, null, USER_AGENT);\n    verify(captchaClient, times(1)).verify(any(), eq(siteKey), eq(expectedAction), eq(expectedToken), any(), eq(USER_AGENT));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void scoreString(float score, String expected) {\n    assertThat(AssessmentResult.fromScore(score, 0.0f).getScoreString()).isEqualTo(expected);\n  }\n\n\n  static Stream<Arguments> scoreString() {\n    return Stream.of(\n        Arguments.of(0.3f, \"30\"),\n        Arguments.of(0.0f, \"0\"),\n        Arguments.of(0.333f, \"30\"),\n        Arguments.of(0.29f, \"30\"),\n        Arguments.of(Float.NaN, \"0\")\n    );\n  }\n\n  @Test\n  public void choose() throws IOException {\n    String ainput = String.join(SEPARATOR, PREFIX_A, CHALLENGE_SITE_KEY, \"challenge\", TOKEN);\n    String binput = String.join(SEPARATOR, PREFIX_B, CHALLENGE_SITE_KEY, \"challenge\", TOKEN);\n    final CaptchaClient a = mockClient(PREFIX_A);\n    final CaptchaClient b = mockClient(PREFIX_B);\n    final Map<String, CaptchaClient> captchaClientMap = Map.of(PREFIX_A, a, PREFIX_B, b);\n\n    new CaptchaChecker(null, captchaClientMap::get).verify(Optional.of(ACI), Action.CHALLENGE, ainput, null, USER_AGENT);\n    verify(a, times(1)).verify(any(), any(), any(), any(), any(), any());\n\n    new CaptchaChecker(null, captchaClientMap::get).verify(Optional.of(ACI), Action.CHALLENGE, binput, null, USER_AGENT);\n    verify(b, times(1)).verify(any(), any(), any(), any(), any(), any());\n  }\n\n  static Stream<Arguments> badArgs() {\n    return Stream.of(\n        Arguments.of(String.join(SEPARATOR, \"invalid\", CHALLENGE_SITE_KEY, \"challenge\", TOKEN)), // bad prefix\n        Arguments.of(String.join(SEPARATOR, PREFIX, \"challenge\", TOKEN)), // no site key\n        Arguments.of(String.join(SEPARATOR, CHALLENGE_SITE_KEY, PREFIX, \"challenge\", TOKEN)), // incorrect order\n        Arguments.of(String.join(SEPARATOR, PREFIX, CHALLENGE_SITE_KEY, \"unknown_action\", TOKEN)), // bad action\n        Arguments.of(String.join(SEPARATOR, PREFIX, CHALLENGE_SITE_KEY, \"registration\", TOKEN)), // action mismatch\n        Arguments.of(String.join(SEPARATOR, PREFIX, \"bad-site-key\", \"challenge\", TOKEN)), // invalid site key\n        Arguments.of(String.join(SEPARATOR, PREFIX, CHALLENGE_SITE_KEY, \"registration\", TOKEN)), // site key for wrong type\n        Arguments.of(String.join(SEPARATOR, PREFIX, REG_SITE_KEY, \"challenge\", TOKEN)) // site key for wrong type\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void badArgs(final String input) throws IOException {\n    final CaptchaClient cc = mockClient(PREFIX);\n    assertThrows(BadRequestException.class,\n        () -> new CaptchaChecker(null, prefix -> PREFIX.equals(prefix) ? cc : null).verify(Optional.of(ACI), Action.CHALLENGE, input, null, USER_AGENT));\n\n  }\n\n  @Test\n  public void testShortened() throws IOException {\n    final CaptchaClient captchaClient = mockClient(PREFIX);\n    final ShortCodeExpander retriever = mock(ShortCodeExpander.class);\n    when(retriever.retrieve(\"abc\")).thenReturn(Optional.of(TOKEN));\n    final String input = String.join(SEPARATOR, PREFIX + \"-short\", REG_SITE_KEY, \"registration\", \"abc\");\n    new CaptchaChecker(retriever, ignored -> captchaClient).verify(Optional.of(ACI), Action.REGISTRATION, input, null, USER_AGENT);\n    verify(captchaClient, times(1)).verify(any(), eq(REG_SITE_KEY), eq(Action.REGISTRATION), eq(TOKEN), any(), any());\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/captcha/ShortCodeExpanderTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.captcha;\n\nimport com.google.api.Http;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.ArgumentMatchers;\nimport javax.net.ssl.SSLSession;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpHeaders;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.util.Optional;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\npublic class ShortCodeExpanderTest {\n\n  @Test\n  public void testUriResolution() throws IOException, InterruptedException {\n    final HttpClient httpClient = mock(HttpClient.class);\n    final ShortCodeExpander expander = new ShortCodeExpander(httpClient, \"https://www.example.org/shortener/\");\n    when(httpClient\n        .send(argThat(req -> req.uri().toString().equals(\"https://www.example.org/shortener/shorturl\")), any()))\n        .thenReturn(new FakeResponse(200, \"longurl\"));\n    assertThat(expander.retrieve(\"shorturl\").get()).isEqualTo(\"longurl\");\n  }\n\n  private record FakeResponse(int statusCode, String body) implements HttpResponse<Object> {\n\n    @Override\n    public HttpRequest request() {\n      return null;\n    }\n\n    @Override\n    public Optional<HttpResponse<Object>> previousResponse() {\n      return Optional.empty();\n    }\n\n    @Override\n    public HttpHeaders headers() {\n      return null;\n    }\n\n    @Override\n    public Optional<SSLSession> sslSession() {\n      return Optional.empty();\n    }\n\n    @Override\n    public URI uri() {\n      return null;\n    }\n\n    @Override\n    public HttpClient.Version version() {\n      return null;\n    }\n\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/LocalDynamoDbFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.metrics.MetricPublisher;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\n\n@JsonTypeName(\"local\")\npublic class LocalDynamoDbFactory implements DynamoDbClientFactory {\n\n  private static final DynamoDbExtension EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.values());\n\n  private boolean initExtension = true;\n\n  /**\n   * If true, tables will be created the first time a DynamoDB client is built.\n   * <p>\n   * Defaults to {@code true}.\n   */\n  @JsonProperty\n  boolean initTables = true;\n\n  /**\n   * If specified, will be provided to {@link DynamoDbExtension} to use instead of its embedded container\n   */\n  @Nullable\n  @JsonProperty\n  DynamoDbLocalOverrides overrides;\n\n  @Override\n  public DynamoDbClient buildSyncClient(final AwsCredentialsProvider awsCredentialsProvider, final MetricPublisher metricPublisher) {\n    initExtensionIfNecessary();\n    initTablesIfNecessary();\n\n    return EXTENSION.getDynamoDbClient();\n  }\n\n  @Override\n  public DynamoDbAsyncClient buildAsyncClient(final AwsCredentialsProvider awsCredentialsProvider, final MetricPublisher metricPublisher) {\n    initExtensionIfNecessary();\n    initTablesIfNecessary();\n\n    return EXTENSION.getDynamoDbAsyncClient();\n  }\n\n  private void initExtensionIfNecessary() {\n    if (initExtension) {\n      try {\n        Optional.ofNullable(overrides)\n            .ifPresent(o -> {\n              Optional.ofNullable(o.endpoint).ifPresent(EXTENSION::setEndpointOverride);\n              Optional.ofNullable(o.region).ifPresent(EXTENSION::setRegion);\n              Optional.ofNullable(o.awsCredentialsProvider).ifPresent(p -> EXTENSION.setAwsCredentialsProvider(p.build()));\n            });\n\n        EXTENSION.beforeAll(null);\n\n        Runtime.getRuntime().addShutdownHook(new Thread(() -> {\n          try {\n            EXTENSION.close();\n          } catch (Throwable e) {\n            throw new RuntimeException(e);\n          }\n        }));\n\n        initExtension = false;\n      } catch (Exception e) {\n        throw new RuntimeException(e);\n      }\n    }\n  }\n\n  private void initTablesIfNecessary() {\n    try {\n      if (initTables) {\n        EXTENSION.beforeEach(null);\n        initTables = false;\n      }\n    } catch (Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private record DynamoDbLocalOverrides(@Nullable String endpoint, @Nullable AwsCredentialsProviderFactory awsCredentialsProvider, @Nullable String region) {}\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/LocalFaultTolerantRedisClientFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport io.lettuce.core.resource.ClientResources;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.RedisServerExtension;\n\n@JsonTypeName(\"local\")\npublic class LocalFaultTolerantRedisClientFactory implements FaultTolerantRedisClientFactory {\n\n  private static final RedisServerExtension REDIS_SERVER_EXTENSION = RedisServerExtension.builder().build();\n\n  private final AtomicBoolean shutdownHookConfigured = new AtomicBoolean();\n\n  private LocalFaultTolerantRedisClientFactory() {\n    try {\n      REDIS_SERVER_EXTENSION.beforeAll(null);\n      REDIS_SERVER_EXTENSION.beforeEach(null);\n    } catch (Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  @Override\n  public FaultTolerantRedisClient build(final String name, final ClientResources clientResources) {\n\n    if (shutdownHookConfigured.compareAndSet(false, true)) {\n      Runtime.getRuntime().addShutdownHook(new Thread(() -> {\n        try {\n          REDIS_SERVER_EXTENSION.close();\n        } catch (Throwable e) {\n          throw new RuntimeException(e);\n        }\n      }));\n    }\n\n    final RedisConfiguration config = new RedisConfiguration();\n    config.setUri(RedisServerExtension.getRedisURI().toString());\n\n    return new FaultTolerantRedisClient(name, config, clientResources.mutate());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/LocalFaultTolerantRedisClusterFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport io.lettuce.core.resource.ClientResources;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\n\n@JsonTypeName(\"local\")\npublic class LocalFaultTolerantRedisClusterFactory implements FaultTolerantRedisClusterFactory {\n\n  private static final RedisClusterExtension redisClusterExtension = RedisClusterExtension.builder().build();\n\n  private final AtomicBoolean shutdownHookConfigured = new AtomicBoolean();\n\n  private LocalFaultTolerantRedisClusterFactory() {\n    try {\n      redisClusterExtension.beforeAll(null);\n      redisClusterExtension.beforeEach(null);\n    } catch (final Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  @Override\n  public FaultTolerantRedisClusterClient build(final String name, final ClientResources.Builder clientResourcesBuilder) {\n\n    if (shutdownHookConfigured.compareAndSet(false, true)) {\n      Runtime.getRuntime().addShutdownHook(new Thread(() -> {\n        try {\n          redisClusterExtension.close();\n        } catch (Throwable e) {\n          throw new RuntimeException(e);\n        }\n      }));\n    }\n\n    final RedisClusterConfiguration config = new RedisClusterConfiguration();\n    config.setConfigurationUri(RedisClusterExtension.getRedisURIs().getFirst().toString());\n\n    return new FaultTolerantRedisClusterClient(name, config, clientResourcesBuilder.socketAddressResolver(redisClusterExtension.getSocketAddressResolver()));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/StaticS3ObjectMonitorFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport java.io.ByteArrayInputStream;\nimport java.io.InputStream;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Consumer;\n\n@JsonTypeName(\"static\")\npublic class StaticS3ObjectMonitorFactory implements S3ObjectMonitorFactory {\n\n  @JsonProperty\n  private String object = \"\";\n\n  @Override\n  public S3ObjectMonitor build(final AwsCredentialsProvider awsCredentialsProvider,\n      final ScheduledExecutorService refreshExecutorService) {\n    return new StaticS3ObjectMonitor(object, awsCredentialsProvider);\n  }\n\n  private static class StaticS3ObjectMonitor extends S3ObjectMonitor {\n\n    private final String object;\n\n    public StaticS3ObjectMonitor(final String object, final AwsCredentialsProvider awsCredentialsProvider) {\n      super(awsCredentialsProvider, \"local-test-region\", \"test-bucket\", null, 0L, null, null);\n\n      this.object = object;\n    }\n\n    @Override\n    public synchronized void start(final Consumer<InputStream> changeListener) {\n      changeListener.accept(new ByteArrayInputStream(object.getBytes()));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubPaymentsServiceClientsFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport java.math.BigDecimal;\nimport java.net.http.HttpClient;\nimport java.util.Collections;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.currency.CoinGeckoClient;\nimport org.whispersystems.textsecuregcm.currency.FixerClient;\n\n@JsonTypeName(\"stub\")\npublic class StubPaymentsServiceClientsFactory implements PaymentsServiceClientsFactory {\n\n  @Override\n  public FixerClient buildFixerClient(final HttpClient httpClient) {\n    return new StubFixerClient();\n  }\n\n  @Override\n  public CoinGeckoClient buildCoinGeckoClient(final HttpClient httpClient) {\n    return new StubCoinGeckoClient();\n  }\n\n  /**\n   * Always returns an empty map of conversions\n   */\n  private static class StubFixerClient extends FixerClient {\n\n    public StubFixerClient() {\n      super(null, null);\n    }\n\n    @Override\n    public Map<String, BigDecimal> getConversionsForBase(final String base) throws FixerException {\n      return Collections.emptyMap();\n    }\n  }\n\n  /**\n   * Always returns {@code 0} for spot price checks\n   */\n  private static class StubCoinGeckoClient extends CoinGeckoClient {\n\n    public StubCoinGeckoClient() {\n      super(null, null, null);\n    }\n\n    @Override\n    public BigDecimal getSpotPrice(final String currency, final String base) {\n      return BigDecimal.ZERO;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubPubSubPublisherFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport com.google.api.core.ApiFutures;\nimport com.google.cloud.pubsub.v1.PublisherInterface;\nimport java.util.UUID;\n\n@JsonTypeName(\"stub\")\npublic class StubPubSubPublisherFactory implements PubSubPublisherFactory {\n\n  @Override\n  public PublisherInterface build() {\n    return message -> ApiFutures.immediateFuture(UUID.randomUUID().toString());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/StubRegistrationServiceClientFactory.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.annotation.JsonTypeName;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport io.dropwizard.core.setup.Environment;\nimport jakarta.validation.constraints.NotNull;\nimport java.io.IOException;\nimport java.security.SecureRandom;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.checkerframework.checker.nullness.qual.Nullable;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.registration.ClientType;\nimport org.whispersystems.textsecuregcm.registration.MessageTransport;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\n\n@JsonTypeName(\"stub\")\npublic class StubRegistrationServiceClientFactory implements RegistrationServiceClientFactory {\n\n  @JsonProperty\n  @NotNull\n  private String registrationCaCertificate;\n\n  @JsonProperty\n  @NotNull\n  private SecretBytes collationKeySalt;\n\n  @Override\n  public RegistrationServiceClient build(final Environment environment, final Executor callbackExecutor,\n      final ScheduledExecutorService identityRefreshExecutor) {\n\n    try {\n      return new StubRegistrationServiceClient(registrationCaCertificate, collationKeySalt.value());\n    } catch (IOException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private static class StubRegistrationServiceClient extends RegistrationServiceClient {\n\n    private final static Map<String, RegistrationServiceSession> SESSIONS = new ConcurrentHashMap<>();\n\n    public StubRegistrationServiceClient(final String registrationCaCertificate, final byte[] collationKeySalt) throws IOException {\n      super(\"example.com\", 8080, null, registrationCaCertificate,  collationKeySalt, null);\n    }\n\n    @Override\n    public CompletableFuture<RegistrationServiceSession> createRegistrationSession(\n        final Phonenumber.PhoneNumber phoneNumber,\n        final String sourceHost,\n        final boolean accountExistsWithPhoneNumber,\n        @javax.annotation.Nullable final String clientMcc,\n        @javax.annotation.Nullable final String clientMnc,\n        final Duration timeout) {\n\n      final String e164 = PhoneNumberUtil.getInstance()\n          .format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);\n\n      final byte[] id = new byte[32];\n      new SecureRandom().nextBytes(id);\n      final RegistrationServiceSession session = new RegistrationServiceSession(id, e164, false, 0L, 0L, null,\n          Instant.now().plus(Duration.ofMinutes(10)).toEpochMilli());\n      SESSIONS.put(Base64.getEncoder().encodeToString(id), session);\n\n      return CompletableFuture.completedFuture(session);\n    }\n\n    @Override\n    public CompletableFuture<RegistrationServiceSession> sendVerificationCode(final byte[] sessionId,\n        final MessageTransport messageTransport, final ClientType clientType, final @Nullable String acceptLanguage,\n        final @Nullable String senderOverride, final Duration timeout) {\n      return CompletableFuture.completedFuture(SESSIONS.get(Base64.getEncoder().encodeToString(sessionId)));\n    }\n\n    @Override\n    public CompletableFuture<RegistrationServiceSession> checkVerificationCode(final byte[] sessionId,\n        final String verificationCode, final Duration timeout) {\n      final RegistrationServiceSession session = SESSIONS.get(Base64.getEncoder().encodeToString(sessionId));\n\n      final RegistrationServiceSession updatedSession = new RegistrationServiceSession(sessionId, session.number(),\n          true, 0L, 0L, 0L,\n          Instant.now().plus(Duration.ofMinutes(10)).toEpochMilli());\n\n      SESSIONS.put(Base64.getEncoder().encodeToString(sessionId), updatedSession);\n      return CompletableFuture.completedFuture(updatedSession);\n    }\n\n    @Override\n    public CompletableFuture<Optional<RegistrationServiceSession>> getSession(final byte[] sessionId,\n        final Duration timeout) {\n      return CompletableFuture.completedFuture(\n          Optional.ofNullable(SESSIONS.get(Base64.getEncoder().encodeToString(sessionId))));\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/dynamic/DynamicConfigurationTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.dynamic;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.vdurmont.semver4j.Semver;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.captcha.Action;\nimport org.whispersystems.textsecuregcm.limits.RateLimiterConfig;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\nclass DynamicConfigurationTest {\n\n  private static final String REQUIRED_CONFIG = \"\"\"\n      captcha:\n        scoreFloor: 1.0\n      \"\"\";\n\n  @Test\n  void testParseExperimentConfig() throws JsonProcessingException {\n    {\n      final String emptyConfigYaml = REQUIRED_CONFIG.concat(\"test: true\");\n      final DynamicConfiguration emptyConfig =\n          DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertFalse(emptyConfig.getExperimentEnrollmentConfiguration(\"test\").isPresent());\n    }\n\n    {\n      final String invalid = REQUIRED_CONFIG.concat(\"\"\"\n          experiments:\n            percentageOnly:\n              enrollmentPercentage: 12\n            uuidsAndPercentage:\n              uuidSelector:\n                # the below results in uuids = null\n                uuids:\n          \"\"\");\n      final Optional<DynamicConfiguration> maybeConfig =\n          DynamicConfigurationManager.parseConfiguration(invalid, DynamicConfiguration.class);\n\n      assertFalse(maybeConfig.isPresent());\n    }\n\n    {\n      final String experimentConfigYaml = REQUIRED_CONFIG.concat(\"\"\"\n          experiments:\n            percentageOnly:\n              enrollmentPercentage: 12\n            uuidsAndPercentage:\n              uuidSelector:\n                uuids:\n                  - 717b1c09-ed0b-4120-bb0e-f4697534b8e1\n                  - 279f264c-56d7-4bbf-b9da-de718ff90903\n              enrollmentPercentage: 77\n            uuidsOnly:\n              uuidSelector:\n                uuids:\n                - 71618739-114c-4b1f-bb0d-6478a44eb600\n            uuids-with-dash:\n              uuidSelector:\n                uuids:\n                  - 71618739-114c-4b1f-bb0d-6478ffffffff\n            uuidsAndSubSelection:\n              uuidSelector:\n                uuids:\n                  - 6664224c-20cc-45a0-829b-95059e8a04f5\n                uuidEnrollmentPercentage: 91\n              enrollmentPercentage: 71\n          \"\"\");\n\n      final DynamicConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(experimentConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertFalse(config.getExperimentEnrollmentConfiguration(\"unconfigured\").isPresent());\n\n      final DynamicExperimentEnrollmentConfiguration percentageOnly = config.getExperimentEnrollmentConfiguration(\"percentageOnly\").orElseThrow();\n      assertEquals(12, percentageOnly.getEnrollmentPercentage());\n      assertEquals(Collections.emptySet(), percentageOnly.getUuidSelector().getUuids());\n      assertEquals(100, percentageOnly.getUuidSelector().getUuidEnrollmentPercentage());\n\n      final DynamicExperimentEnrollmentConfiguration uuidsAndPercentage = config.getExperimentEnrollmentConfiguration(\"uuidsAndPercentage\").orElseThrow();\n      assertEquals(77, uuidsAndPercentage.getEnrollmentPercentage());\n      assertEquals(Set.of(UUID.fromString(\"717b1c09-ed0b-4120-bb0e-f4697534b8e1\"),\n          UUID.fromString(\"279f264c-56d7-4bbf-b9da-de718ff90903\")),\n          uuidsAndPercentage.getUuidSelector().getUuids());\n      assertEquals(100, uuidsAndPercentage.getUuidSelector().getUuidEnrollmentPercentage());\n\n      final DynamicExperimentEnrollmentConfiguration uuidsOnly = config.getExperimentEnrollmentConfiguration(\"uuidsOnly\").orElseThrow();\n      assertEquals(0, uuidsOnly.getEnrollmentPercentage());\n      assertEquals(Set.of(UUID.fromString(\"71618739-114c-4b1f-bb0d-6478a44eb600\")),\n          uuidsOnly.getUuidSelector().getUuids());\n      assertEquals(100, uuidsOnly.getUuidSelector().getUuidEnrollmentPercentage());\n\n      final DynamicExperimentEnrollmentConfiguration uuidsWithDash = config.getExperimentEnrollmentConfiguration(\"uuids-with-dash\").orElseThrow();\n      assertEquals(0, uuidsWithDash.getEnrollmentPercentage());\n      assertEquals(Set.of(UUID.fromString(\"71618739-114c-4b1f-bb0d-6478ffffffff\")),\n          uuidsWithDash.getUuidSelector().getUuids());\n      assertEquals(100, uuidsWithDash.getUuidSelector().getUuidEnrollmentPercentage());\n\n      final DynamicExperimentEnrollmentConfiguration uuidsAndSubSelection = config.getExperimentEnrollmentConfiguration(\"uuidsAndSubSelection\").orElseThrow();\n      assertEquals(71, uuidsAndSubSelection.getEnrollmentPercentage());\n      assertEquals(Set.of(UUID.fromString(\"6664224c-20cc-45a0-829b-95059e8a04f5\")),\n          uuidsAndSubSelection.getUuidSelector().getUuids());\n      assertEquals(91, uuidsAndSubSelection.getUuidSelector().getUuidEnrollmentPercentage());\n    }\n  }\n\n  @Test\n  void testParseE164Experiments() throws JsonProcessingException {\n    {\n      final String emptyConfigYaml = REQUIRED_CONFIG.concat(\"test: true\");\n      final DynamicConfiguration emptyConfig =\n          DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertFalse(emptyConfig.getE164ExperimentEnrollmentConfiguration(\"test\").isPresent());\n    }\n\n    {\n      final String experimentConfigYaml = REQUIRED_CONFIG.concat(\"\"\"\n          e164Experiments:\n            percentageOnly:\n              enrollmentPercentage: 17\n            e164sCountryCodesAndPercentage:\n              enrolledE164s:\n                - +120255551212\n                - +3655323174\n              excludedE164s:\n                - +120255551213\n                - +3655323175\n              enrollmentPercentage: 46\n              excludedCountryCodes:\n                - 47\n              includedCountryCodes:\n                - 56\n            e164sAndExcludedCodes:\n              enrolledE164s:\n                - +120255551212\n              excludedCountryCodes:\n                - 47\n          \"\"\");\n\n      final DynamicConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(experimentConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertFalse(config.getE164ExperimentEnrollmentConfiguration(\"unconfigured\").isPresent());\n\n      {\n        final Optional<DynamicE164ExperimentEnrollmentConfiguration> percentageOnly = config\n            .getE164ExperimentEnrollmentConfiguration(\"percentageOnly\");\n        assertTrue(percentageOnly.isPresent());\n        assertEquals(17,\n            percentageOnly.get().getEnrollmentPercentage());\n        assertEquals(Collections.emptySet(),\n            percentageOnly.get().getEnrolledE164s());\n        assertEquals(Collections.emptySet(),\n            percentageOnly.get().getExcludedE164s());\n      }\n\n      {\n        final Optional<DynamicE164ExperimentEnrollmentConfiguration> e164sCountryCodesAndPercentage = config\n            .getE164ExperimentEnrollmentConfiguration(\"e164sCountryCodesAndPercentage\");\n\n        assertTrue(e164sCountryCodesAndPercentage.isPresent());\n        assertEquals(46,\n            e164sCountryCodesAndPercentage.get().getEnrollmentPercentage());\n        assertEquals(Set.of(\"+120255551212\", \"+3655323174\"),\n            e164sCountryCodesAndPercentage.get().getEnrolledE164s());\n        assertEquals(Set.of(\"+120255551213\", \"+3655323175\"),\n            e164sCountryCodesAndPercentage.get().getExcludedE164s());\n        assertEquals(Set.of(\"47\"),\n            e164sCountryCodesAndPercentage.get().getExcludedCountryCodes());\n        assertEquals(Set.of(\"56\"),\n            e164sCountryCodesAndPercentage.get().getIncludedCountryCodes());\n      }\n\n      {\n        final Optional<DynamicE164ExperimentEnrollmentConfiguration> e164sAndExcludedCodes = config\n            .getE164ExperimentEnrollmentConfiguration(\"e164sAndExcludedCodes\");\n        assertTrue(e164sAndExcludedCodes.isPresent());\n        assertEquals(0, e164sAndExcludedCodes.get().getEnrollmentPercentage());\n        assertEquals(Set.of(\"+120255551212\"),\n            e164sAndExcludedCodes.get().getEnrolledE164s());\n        assertTrue(e164sAndExcludedCodes.get().getExcludedE164s().isEmpty());\n        assertEquals(Set.of(\"47\"),\n            e164sAndExcludedCodes.get().getExcludedCountryCodes());\n      }\n    }\n  }\n\n  @Test\n  void testParseRemoteDeprecationConfig() throws JsonProcessingException {\n    {\n      final String emptyConfigYaml = REQUIRED_CONFIG.concat(\"test: true\");\n      final DynamicConfiguration emptyConfig =\n          DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertNotNull(emptyConfig.getRemoteDeprecationConfiguration());\n    }\n\n    {\n      final String remoteDeprecationConfig = REQUIRED_CONFIG.concat(\"\"\"\n          remoteDeprecation:\n            minimumVersions:\n              IOS: 1.2.3\n              ANDROID: 4.5.6\n            versionsPendingDeprecation:\n              DESKTOP: 7.8.9\n            blockedVersions:\n              DESKTOP:\n                - 1.4.0-beta.2\n          \"\"\");\n\n      final DynamicConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(remoteDeprecationConfig, DynamicConfiguration.class).orElseThrow();\n\n      final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = config\n          .getRemoteDeprecationConfiguration();\n\n      assertEquals(Map.of(ClientPlatform.IOS, new Semver(\"1.2.3\"), ClientPlatform.ANDROID, new Semver(\"4.5.6\")),\n          remoteDeprecationConfiguration.getMinimumVersions());\n      assertEquals(Map.of(ClientPlatform.DESKTOP, new Semver(\"7.8.9\")),\n          remoteDeprecationConfiguration.getVersionsPendingDeprecation());\n      assertEquals(Map.of(ClientPlatform.DESKTOP, Set.of(new Semver(\"1.4.0-beta.2\"))),\n          remoteDeprecationConfiguration.getBlockedVersions());\n      assertTrue(remoteDeprecationConfiguration.getVersionsPendingBlock().isEmpty());\n    }\n  }\n\n  @Test\n  void testParsePaymentsConfiguration() throws JsonProcessingException {\n    {\n      final String emptyConfigYaml = REQUIRED_CONFIG.concat(\"test: true\");\n      final DynamicConfiguration emptyConfig =\n          DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertTrue(emptyConfig.getPaymentsConfiguration().getDisallowedPrefixes().isEmpty());\n    }\n\n    {\n      final String paymentsConfigYaml = REQUIRED_CONFIG.concat(\"\"\"\n          payments:\n            disallowedPrefixes:\n              - +44\n          \"\"\");\n\n      final DynamicPaymentsConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(paymentsConfigYaml, DynamicConfiguration.class).orElseThrow()\n              .getPaymentsConfiguration();\n\n      assertEquals(List.of(\"+44\"), config.getDisallowedPrefixes());\n    }\n  }\n\n  @Test\n  void testParseCaptchaConfiguration() throws JsonProcessingException {\n    {\n      final String emptyConfigYaml = \"test: true\";\n\n      assertTrue(DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).isEmpty(),\n          \"empty config should not validate\");\n    }\n\n    {\n      final String captchaConfig = \"\"\"\n          captcha:\n            scoreFloor: null\n          \"\"\";\n\n      assertTrue(DynamicConfigurationManager.parseConfiguration(captchaConfig, DynamicConfiguration.class).isEmpty(),\n          \"score floor must not be null\");\n    }\n\n    {\n      final String captchaConfig = \"\"\"\n          captcha:\n            scoreFloor: 0.9\n            scoreFloorByAction:\n              challenge: 0.1\n              registration: 0.2\n            hCaptchaSiteKeys:\n              challenge:\n                - ab317f2a-2b76-4098-84c9-ecdf8ea44f53\n              registration:\n                - e4ddb6ff-05e7-497b-9a29-b76e7331789c\n                - 52fdbc88-f246-4705-a7dd-05ad85b93420\n          \"\"\";\n\n      final DynamicCaptchaConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(captchaConfig, DynamicConfiguration.class).orElseThrow()\n              .getCaptchaConfiguration();\n\n      assertEquals(0.9f, config.getScoreFloor().floatValue());\n      assertEquals(0.1f, config.getScoreFloorByAction().get(Action.CHALLENGE).floatValue());\n      assertEquals(0.2f, config.getScoreFloorByAction().get(Action.REGISTRATION).floatValue());\n\n      assertThat(config.getHCaptchaSiteKeys().get(Action.CHALLENGE)).contains(\"ab317f2a-2b76-4098-84c9-ecdf8ea44f53\");\n      assertThat(config.getHCaptchaSiteKeys().get(Action.REGISTRATION)).contains(\"e4ddb6ff-05e7-497b-9a29-b76e7331789c\");\n      assertThat(config.getHCaptchaSiteKeys().get(Action.REGISTRATION)).contains(\"52fdbc88-f246-4705-a7dd-05ad85b93420\");\n    }\n  }\n\n  @Test\n  void testParseLimits() throws JsonProcessingException {\n    final String limitsConfig = REQUIRED_CONFIG.concat(\"\"\"\n        limits:\n          rateLimitReset:\n            bucketSize: 17\n            permitRegenerationDuration: PT0.000004S\n        \"\"\");\n\n    final RateLimiterConfig resetRateLimiterConfig =\n        DynamicConfigurationManager.parseConfiguration(limitsConfig, DynamicConfiguration.class).orElseThrow()\n            .getLimits().get(RateLimiters.For.RATE_LIMIT_RESET.id());\n\n    assertThat(resetRateLimiterConfig.bucketSize()).isEqualTo(17);\n    assertThat(resetRateLimiterConfig.permitRegenerationDuration()).isEqualTo(Duration.ofNanos(4_000));\n  }\n\n  @Test\n  void testMessagePersister() throws JsonProcessingException {\n    {\n      final String emptyConfigYaml = REQUIRED_CONFIG.concat(\"test: true\");\n      final DynamicConfiguration emptyConfig =\n          DynamicConfigurationManager.parseConfiguration(emptyConfigYaml, DynamicConfiguration.class).orElseThrow();\n\n      assertTrue(emptyConfig.getMessagePersisterConfiguration().isPersistenceEnabled());\n    }\n\n    {\n      final String messagePersisterEnabledYaml = REQUIRED_CONFIG.concat(\"\"\"\n          messagePersister:\n            persistenceEnabled: true\n            dedicatedProcessEnabled: true\n          \"\"\");\n\n      final DynamicConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(messagePersisterEnabledYaml, DynamicConfiguration.class)\n              .orElseThrow();\n\n      assertTrue(config.getMessagePersisterConfiguration().isPersistenceEnabled());\n    }\n\n    {\n      final String messagePersisterDisabledYaml = REQUIRED_CONFIG.concat(\"\"\"\n          messagePersister:\n            persistenceEnabled: false\n          \"\"\");\n\n      final DynamicConfiguration config =\n          DynamicConfigurationManager.parseConfiguration(messagePersisterDisabledYaml, DynamicConfiguration.class)\n              .orElseThrow();\n\n      assertFalse(config.getMessagePersisterConfiguration().isPersistenceEnabled());\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/configuration/secrets/SecretsTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.configuration.secrets;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.databind.JsonMappingException;\nimport jakarta.validation.Validation;\nimport jakarta.validation.Validator;\nimport jakarta.validation.constraints.NotEmpty;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.ExactlySize;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\npublic class SecretsTest {\n\n  private static final String SECRET_REF = \"secret_string\";\n\n  private static final String SECRET_LIST_REF = \"secret_string_list\";\n\n  private static final String SECRET_BYTES_REF = \"secret_bytes\";\n\n  private static final String SECRET_BYTES_LIST_REF = \"secret_bytes_list\";\n\n  public record TestData(SecretString secret,\n                         SecretBytes secretBytes,\n                         SecretStringList secretList,\n                         SecretBytesList secretBytesList) {\n  }\n\n  private static final String VALID_CONFIG_YAML = \"\"\"\n      secret: secret://%s\n      secretBytes: secret://%s\n      secretList: secret://%s\n      secretBytesList: secret://%s\n      \"\"\".formatted(SECRET_REF, SECRET_BYTES_REF, SECRET_LIST_REF, SECRET_BYTES_LIST_REF);\n\n\n  @Test\n  public void testDeserialization() throws Exception {\n    final String secretString = \"secret_string\";\n    final byte[] secretBytes = TestRandomUtil.nextBytes(16);\n    final String secretBytesBase64 = Base64.getEncoder().encodeToString(secretBytes);\n    final List<String> secretStringList = List.of(\"secret1\", \"secret2\", \"secret3\");\n    final List<byte[]> secretBytesList = List.of(TestRandomUtil.nextBytes(16), TestRandomUtil.nextBytes(16), TestRandomUtil.nextBytes(16));\n    final List<String> secretBytesListBase64 = secretBytesList.stream().map(Base64.getEncoder()::encodeToString).toList();\n    final Map<String, Secret<?>> storeMap = Map.of(\n        SECRET_REF, new SecretString(secretString),\n        SECRET_BYTES_REF, new SecretString(secretBytesBase64),\n        SECRET_LIST_REF, new SecretStringList(secretStringList),\n        SECRET_BYTES_LIST_REF, new SecretStringList(secretBytesListBase64)\n    );\n    SecretsModule.INSTANCE.setSecretStore(new SecretStore(storeMap));\n\n    final TestData result = SystemMapper.yamlMapper().readValue(VALID_CONFIG_YAML, TestData.class);\n    assertEquals(secretString, result.secret().value());\n    assertEquals(secretStringList, result.secretList().value());\n    assertArrayEquals(secretBytes, result.secretBytes().value());\n    for (int i = 0; i < secretBytesList.size(); i++) {\n      assertArrayEquals(secretBytesList.get(i), result.secretBytesList().value().get(i));\n    }\n  }\n\n  @Test\n  public void testValueWithoutPrefix() throws Exception {\n    final String config = \"\"\"\n      secret: ref\n      \"\"\";\n    SecretsModule.INSTANCE.setSecretStore(new SecretStore(Collections.emptyMap()));\n    assertThrows(JsonMappingException.class, () -> SystemMapper.yamlMapper().readValue(config, TestData.class));\n  }\n\n  @Test\n  public void testNoSecretInTheStore() throws Exception {\n    final String config = \"\"\"\n      secret: secret://missing\n      secretBytes: secret://missing\n      secretList: secret://missing\n      secretBytesList: secret://missing\n      \"\"\";\n    SecretsModule.INSTANCE.setSecretStore(new SecretStore(Collections.emptyMap()));\n    assertThrows(JsonMappingException.class, () -> SystemMapper.yamlMapper().readValue(config, TestData.class));\n  }\n\n  @Test\n  public void testSecretStoreNotSet() throws Exception {\n    assertThrows(JsonMappingException.class, () -> SystemMapper.yamlMapper().readValue(VALID_CONFIG_YAML, TestData.class));\n  }\n\n  @Test\n  public void testReadFromJson() throws Exception {\n    // checking that valid json secrets bundle is read correctly\n    final SecretStore secretStore = SecretStore.fromYamlStringSecretsBundle(\"\"\"\n        secret_string: value\n        secret_string_list:\n          - value1\n          - value2\n          - value3\n        \"\"\");\n    assertEquals(\"value\", secretStore.secretString(\"secret_string\").value());\n    assertEquals(List.of(\"value1\", \"value2\", \"value3\"), secretStore.secretStringList(\"secret_string_list\").value());\n\n    // checking that secrets bundle can't have objects as values\n    assertThrows(IllegalArgumentException.class, () -> SecretStore.fromYamlStringSecretsBundle(\"\"\"\n        secret_string: value\n        not_a_string_or_list:\n          k: v\n        \"\"\"));\n\n    // checking that secrets bundle can't have numbers as values\n    assertThrows(IllegalArgumentException.class, () -> SecretStore.fromYamlStringSecretsBundle(\"\"\"\n        secret_string: value\n        not_a_string_or_list: 42\n        \"\"\"));\n  }\n\n  record NotEmptySecretStringList(@NotEmpty SecretStringList secret) {\n  }\n\n  record NotEmptySecretBytesList(@NotEmpty SecretBytesList secret) {\n  }\n\n  record ExactlySizeBytesSecret(@ExactlySize(32) SecretBytes secret) {\n  }\n\n  @Test\n  public void testValidators() throws Exception {\n    final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();\n\n    // @NotEmpty SecretStringList\n    assertFalse(validator.validate(new NotEmptySecretStringList(new SecretStringList(List.of()))).isEmpty());\n    assertTrue(validator.validate(new NotEmptySecretStringList(new SecretStringList(List.of(\"smth\")))).isEmpty());\n\n    // @NotEmpty SecretBytesList\n    assertFalse(validator.validate(new NotEmptySecretBytesList(new SecretBytesList(List.of()))).isEmpty());\n    assertTrue(validator.validate(new NotEmptySecretBytesList(new SecretBytesList(List.of(new byte[4])))).isEmpty());\n\n    // @ExactlySize SecretBytes\n    assertFalse(validator.validate(new ExactlySizeBytesSecret(new SecretBytes(new byte[16]))).isEmpty());\n    assertTrue(validator.validate(new ExactlySizeBytesSecret(new SecretBytes(new byte[32]))).isEmpty());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/AbstractV1SubscriptionControllerTest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.time.Clock;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.configuration.OneTimeDonationConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;\nimport org.whispersystems.textsecuregcm.storage.IssuedReceiptsManager;\nimport org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.StripeManager;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\nclass AbstractV1SubscriptionControllerTest {\n\n  static final Clock CLOCK = mock(Clock.class);\n\n  private static final ObjectMapper YAML_MAPPER = SystemMapper.yamlMapper();\n\n  static final OneTimeDonationConfiguration ONETIME_CONFIG = ConfigHelper.getOneTimeConfig();\n  static final StripeManager STRIPE_MANAGER = MockUtils.buildMock(StripeManager.class, mgr ->\n      when(mgr.getProvider()).thenReturn(PaymentProvider.STRIPE));\n  static final BraintreeManager BRAINTREE_MANAGER = MockUtils.buildMock(BraintreeManager.class, mgr ->\n      when(mgr.getProvider()).thenReturn(PaymentProvider.BRAINTREE));\n  static final IssuedReceiptsManager ISSUED_RECEIPTS_MANAGER = mock(IssuedReceiptsManager.class);\n\n  static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);\n\n\n  /**\n   * Encapsulates {@code static} configuration, to keep the class header simpler and avoid illegal forward references\n   */\n  record ConfigHelper() {\n\n    static SubscriptionConfiguration getSubscriptionConfig() {\n      return readValue(SUBSCRIPTION_CONFIG_YAML, SubscriptionConfiguration.class);\n    }\n\n    static OneTimeDonationConfiguration getOneTimeConfig() {\n      return readValue(ONETIME_CONFIG_YAML, OneTimeDonationConfiguration.class);\n    }\n\n    private static <T> T readValue(String yaml, Class<T> type) {\n      try {\n        return YAML_MAPPER.readValue(yaml, type);\n      } catch (Exception e) {\n        throw new RuntimeException(e);\n      }\n    }\n\n    private static final String SUBSCRIPTION_CONFIG_YAML = \"\"\"\n        badgeExpiration: P30D\n        badgeGracePeriod: P15D\n        backupExpiration: P3D\n        backupGracePeriod: P10D\n        backupFreeTierMediaDuration: P30D\n        backupLevels:\n          201:\n            playProductId: testPlayProductId\n            mediaTtl: P40D\n            prices:\n              usd:\n                amount: '5'\n                processorIds:\n                  STRIPE: R4\n                  BRAINTREE: M4\n              jpy:\n                amount: '500'\n                processorIds:\n                  STRIPE: Q4\n                  BRAINTREE: N4\n              bif:\n                amount: '5000'\n                processorIds:\n                  STRIPE: S4\n                  BRAINTREE: O4\n              eur:\n                amount: '5'\n                processorIds:\n                  STRIPE: A4\n                  BRAINTREE: B4\n        levels:\n          5:\n            badge: B1\n            prices:\n              usd:\n                amount: '5'\n                processorIds:\n                  STRIPE: R1\n                  BRAINTREE: M1\n              jpy:\n                amount: '500'\n                processorIds:\n                  STRIPE: Q1\n                  BRAINTREE: N1\n              bif:\n                amount: '5000'\n                processorIds:\n                  STRIPE: S1\n                  BRAINTREE: O1\n              eur:\n                amount: '5'\n                processorIds:\n                  STRIPE: A1\n                  BRAINTREE: B1\n          15:\n            badge: B2\n            prices:\n              usd:\n                amount: '15'\n                processorIds:\n                  STRIPE: R2\n                  BRAINTREE: M2\n              jpy:\n                amount: '1500'\n                processorIds:\n                  STRIPE: Q2\n                  BRAINTREE: N2\n              bif:\n                amount: '15000'\n                processorIds:\n                  STRIPE: S2\n                  BRAINTREE: O2\n              eur:\n                amount: '15'\n                processorIds:\n                  STRIPE: A2\n                  BRAINTREE: B2\n          35:\n            badge: B3\n            prices:\n              usd:\n                amount: '35'\n                processorIds:\n                  STRIPE: R3\n                  BRAINTREE: M3\n              jpy:\n                amount: '3500'\n                processorIds:\n                  STRIPE: Q3\n                  BRAINTREE: N3\n              bif:\n                amount: '35000'\n                processorIds:\n                  STRIPE: S3\n                  BRAINTREE: O3\n              eur:\n                amount: '35'\n                processorIds:\n                  STRIPE: A3\n                  BRAINTREE: B3\n        \"\"\";\n\n    private static final String ONETIME_CONFIG_YAML = \"\"\"\n        boost:\n          level: 1\n          expiration: P45D\n          badge: BOOST\n        gift:\n          level: 100\n          expiration: P60D\n          badge: GIFT\n        currencies:\n          usd:\n            minimum: '2.50' # fractional to test BigDecimal conversion\n            gift: '20'\n            boosts:\n              - '5.50'\n              - '6'\n              - '7'\n              - '8'\n              - '9'\n              - '10'\n          eur:\n            minimum: '3'\n            gift: '5'\n            boosts:\n              - '5'\n              - '10'\n              - '20'\n              - '30'\n              - '50'\n              - '100'\n          jpy:\n            minimum: '250'\n            gift: '2000'\n            boosts:\n              - '550'\n              - '600'\n              - '700'\n              - '800'\n              - '900'\n              - '1000'\n          bif:\n            minimum: '2500'\n            gift: '20000'\n            boosts:\n              - '5500'\n              - '6000'\n              - '7000'\n              - '8000'\n              - '9000'\n              - '10000'\n        sepaMaximumEuros: '10000'\n        \"\"\";\n\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyList;\nimport static org.mockito.Mockito.clearInvocations;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.Response;\nimport java.security.SecureRandom;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.BiConsumer;\nimport java.util.stream.Stream;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.libsignal.usernames.BaseUsernameException;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.auth.StoredRegistrationLock;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentifierResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.ApnRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.ConfirmUsernameHashRequest;\nimport org.whispersystems.textsecuregcm.entities.DeviceName;\nimport org.whispersystems.textsecuregcm.entities.EncryptedUsername;\nimport org.whispersystems.textsecuregcm.entities.Entitlements;\nimport org.whispersystems.textsecuregcm.entities.GcmRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.RegistrationLock;\nimport org.whispersystems.textsecuregcm.entities.ReserveUsernameHashRequest;\nimport org.whispersystems.textsecuregcm.entities.ReserveUsernameHashResponse;\nimport org.whispersystems.textsecuregcm.entities.UsernameHashResponse;\nimport org.whispersystems.textsecuregcm.entities.UsernameLinkHandle;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.JsonMappingExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;\nimport org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\nimport org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass AccountControllerTest {\n  private static final String SENDER             = \"+14152222222\";\n  private static final String SENDER_OLD         = \"+14151111111\";\n  private static final String SENDER_PIN         = \"+14153333333\";\n  private static final String SENDER_OVER_PIN    = \"+14154444444\";\n  private static final String SENDER_PREAUTH     = \"+14157777777\";\n  private static final String SENDER_REG_LOCK    = \"+14158888888\";\n  private static final String SENDER_HAS_STORAGE = \"+14159999999\";\n  private static final String SENDER_TRANSFER    = \"+14151111112\";\n  private static final String BASE_64_URL_USERNAME_HASH_1 = \"9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE\";\n  private static final String BASE_64_URL_USERNAME_HASH_2 = \"NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_1 = \"md1votbj9r794DsqTNrBqA\";\n\n  private static final String TOO_SHORT_BASE_64_URL_USERNAME_HASH = \"P2oMuxx0xgGxSpTO0ACq3IztEOBDaV9t9YFu4bAGpQ\";\n  private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);\n  private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);\n  private static final byte[] ENCRYPTED_USERNAME_1 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_1);\n  private static final String BASE_64_URL_ZK_PROOF = \"2kambOgmdeeIO0faCMgR6HR4G2BQ5bnhXdIe9ZuZY0NmQXSra5BzDBQ7jzy1cvoEqUHYLpBYMrXudkYPJaWoQg\";\n  private static final byte[] ZK_PROOF = Base64.getUrlDecoder().decode(BASE_64_URL_ZK_PROOF);\n  private static final UUID   SENDER_REG_LOCK_UUID = UUID.randomUUID();\n  private static final UUID   SENDER_TRANSFER_UUID = UUID.randomUUID();\n\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter rateLimiter = mock(RateLimiter.class);\n  private static final RateLimiter usernameSetLimiter = mock(RateLimiter.class);\n  private static final RateLimiter usernameReserveLimiter = mock(RateLimiter.class);\n  private static final RateLimiter usernameLookupLimiter = mock(RateLimiter.class);\n  private static final RateLimiter checkAccountExistence = mock(RateLimiter.class);\n  private static final Account senderPinAccount = mock(Account.class);\n  private static final Account senderRegLockAccount = mock(Account.class);\n  private static final Account senderHasStorage = mock(Account.class);\n  private static final Account senderTransfer = mock(Account.class);\n  private static final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n      mock(RegistrationRecoveryPasswordsManager.class);\n  private static final UsernameHashZkProofVerifier usernameZkProofVerifier = mock(UsernameHashZkProofVerifier.class);\n\n  private final byte[] registration_lock_key = new byte[32];\n\n  private static final TestRemoteAddressFilterProvider TEST_REMOTE_ADDRESS_FILTER_PROVIDER\n      = new TestRemoteAddressFilterProvider(\"127.0.0.1\");\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new JsonMappingExceptionMapper())\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .addProvider(new ImpossiblePhoneNumberExceptionMapper())\n      .addProvider(new NonNormalizedPhoneNumberExceptionMapper())\n      .addProvider(TEST_REMOTE_ADDRESS_FILTER_PROVIDER)\n      .addProvider(new RateLimitByIpFilter(rateLimiters))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new AccountController(\n              accountsManager,\n          rateLimiters,\n          registrationRecoveryPasswordsManager,\n          usernameZkProofVerifier\n      ))\n      .build();\n\n\n  @BeforeEach\n  void setup() throws Exception {\n    clearInvocations(AuthHelper.VALID_ACCOUNT, AuthHelper.UNDISCOVERABLE_ACCOUNT);\n\n    new SecureRandom().nextBytes(registration_lock_key);\n    SaltedTokenHash registrationLockCredentials = SaltedTokenHash.generateFor(\n        HexFormat.of().formatHex(registration_lock_key));\n\n    AccountsHelper.setupMockUpdate(accountsManager);\n\n    when(rateLimiters.getUsernameSetLimiter()).thenReturn(usernameSetLimiter);\n    when(rateLimiters.getUsernameReserveLimiter()).thenReturn(usernameReserveLimiter);\n    when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameLookupLimiter);\n    when(rateLimiters.forDescriptor(eq(RateLimiters.For.USERNAME_LOOKUP))).thenReturn(usernameLookupLimiter);\n    when(rateLimiters.forDescriptor(eq(RateLimiters.For.CHECK_ACCOUNT_EXISTENCE))).thenReturn(checkAccountExistence);\n\n    when(usernameSetLimiter.validateAsync(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(senderPinAccount.getLastSeen()).thenReturn(System.currentTimeMillis());\n    when(senderPinAccount.getRegistrationLock()).thenReturn(\n        new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis())));\n\n    when(senderHasStorage.getUuid()).thenReturn(UUID.randomUUID());\n    when(senderHasStorage.hasCapability(DeviceCapability.STORAGE)).thenReturn(true);\n    when(senderHasStorage.getRegistrationLock()).thenReturn(\n        new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis())));\n\n    when(senderRegLockAccount.getRegistrationLock()).thenReturn(\n        new StoredRegistrationLock(Optional.of(registrationLockCredentials.hash()),\n            Optional.of(registrationLockCredentials.salt()), Instant.ofEpochMilli(System.currentTimeMillis())));\n    when(senderRegLockAccount.getLastSeen()).thenReturn(System.currentTimeMillis());\n    when(senderRegLockAccount.getUuid()).thenReturn(SENDER_REG_LOCK_UUID);\n    when(senderRegLockAccount.getNumber()).thenReturn(SENDER_REG_LOCK);\n\n    when(senderTransfer.getRegistrationLock()).thenReturn(\n        new StoredRegistrationLock(Optional.empty(), Optional.empty(), Instant.ofEpochMilli(System.currentTimeMillis())));\n    when(senderTransfer.getUuid()).thenReturn(SENDER_TRANSFER_UUID);\n    when(senderTransfer.getNumber()).thenReturn(SENDER_TRANSFER);\n\n    when(accountsManager.getByE164(eq(SENDER_PIN))).thenReturn(Optional.of(senderPinAccount));\n    when(accountsManager.getByE164(eq(SENDER_REG_LOCK))).thenReturn(Optional.of(senderRegLockAccount));\n    when(accountsManager.getByE164(eq(SENDER_OVER_PIN))).thenReturn(Optional.of(senderPinAccount));\n    when(accountsManager.getByE164(eq(SENDER))).thenReturn(Optional.empty());\n    when(accountsManager.getByE164(eq(SENDER_OLD))).thenReturn(Optional.empty());\n    when(accountsManager.getByE164(eq(SENDER_PREAUTH))).thenReturn(Optional.empty());\n    when(accountsManager.getByE164(eq(SENDER_HAS_STORAGE))).thenReturn(Optional.of(senderHasStorage));\n    when(accountsManager.getByE164(eq(SENDER_TRANSFER))).thenReturn(Optional.of(senderTransfer));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_TWO));\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_3)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_3));\n    when(accountsManager.getByAccountIdentifier(AuthHelper.UNDISCOVERABLE_UUID)).thenReturn(Optional.of(AuthHelper.UNDISCOVERABLE_ACCOUNT));\n\n    doAnswer(invocation -> {\n      final byte[] proof = invocation.getArgument(0);\n      final byte[] hash = invocation.getArgument(1);\n\n      if (proof == null || hash == null) {\n        throw new NullPointerException();\n      }\n\n      return null;\n    }).when(usernameZkProofVerifier).verifyProof(any(), any());\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(\n        accountsManager,\n        rateLimiters,\n        rateLimiter,\n        usernameSetLimiter,\n        usernameReserveLimiter,\n        usernameLookupLimiter,\n        senderPinAccount,\n        senderRegLockAccount,\n        senderHasStorage,\n        senderTransfer,\n        usernameZkProofVerifier);\n\n    clearInvocations(AuthHelper.VALID_DEVICE_3_PRIMARY);\n  }\n\n  @Test\n  void testSetRegistrationLock() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/registration_lock/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new RegistrationLock(\"1234567890123456789012345678901234567890123456789012345678901234\")))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n\n      ArgumentCaptor<String> pinCapture     = ArgumentCaptor.forClass(String.class);\n      ArgumentCaptor<String> pinSaltCapture = ArgumentCaptor.forClass(String.class);\n\n      verify(AuthHelper.VALID_ACCOUNT, times(1)).setRegistrationLock(pinCapture.capture(), pinSaltCapture.capture());\n\n      assertThat(pinCapture.getValue()).isNotEmpty();\n      assertThat(pinSaltCapture.getValue()).isNotEmpty();\n\n      assertThat(pinCapture.getValue().length()).isEqualTo(66);\n    }\n  }\n\n  @Test\n  void testSetShortRegistrationLock() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/registration_lock/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new RegistrationLock(\"313\")))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testSetGcmId() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/gcm/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION,\n            AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n        .put(Entity.json(new GcmRegistrationId(\"z000\")))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n\n      verify(AuthHelper.VALID_DEVICE_3_PRIMARY, times(1)).setGcmId(eq(\"z000\"));\n      verify(accountsManager, times(1)).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), anyByte(), any());\n    }\n  }\n\n  @Test\n  void testSetGcmIdInvalidrequest() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/gcm/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION,\n            AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n        .put(Entity.json(\"{}\"))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testSetApnId() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/apn/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION,\n            AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n        .put(Entity.json(new ApnRegistrationId(\"first\")))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n\n      verify(AuthHelper.VALID_DEVICE_3_PRIMARY, times(1)).setApnId(eq(\"first\"));\n      verify(accountsManager, times(1)).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), anyByte(), any());\n    }\n  }\n\n  @Test\n  void testWhoAmI() {\n    final Instant expiration = Instant.now().plus(Duration.ofHours(1)).plusMillis(101);\n    final Instant truncatedExpiration = Instant.ofEpochSecond(expiration.getEpochSecond());\n    final AccountBadge badge1 = new AccountBadge(\"badge1\", expiration, true);\n    final AccountBadge badge2 = new AccountBadge(\"badge2\", expiration, true);\n\n    when(AuthHelper.VALID_ACCOUNT.getBackupVoucher())\n        .thenReturn(new Account.BackupVoucher(100, expiration));\n    when(AuthHelper.VALID_ACCOUNT.getBadges()).thenReturn(List.of(badge1, badge2));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/whoami\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      final AccountIdentityResponse identityResponse = response.readEntity(AccountIdentityResponse.class);\n      assertThat(identityResponse.uuid()).isEqualTo(AuthHelper.VALID_UUID);\n\n      final BiConsumer<Entitlements.BadgeEntitlement, AccountBadge> compareBadge = (actual, expected) -> {\n        assertThat(actual.expiration()).isEqualTo(truncatedExpiration);\n        assertThat(actual.id()).isEqualTo(expected.id());\n        assertThat(actual.visible()).isEqualTo(expected.visible());\n      };\n      compareBadge.accept(identityResponse.entitlements().badges().getFirst(), badge1);\n      compareBadge.accept(identityResponse.entitlements().badges().getLast(), badge2);\n\n      assertThat(identityResponse.entitlements().backup().backupLevel()).isEqualTo(100);\n      assertThat(identityResponse.entitlements().backup().expiration()).isEqualTo(truncatedExpiration);\n    }\n  }\n\n  static Stream<Arguments> testSetUsernameLink() {\n    return Stream.of(\n        Arguments.of(false, true, true, 32, 401),\n        Arguments.of(true, true, false, 32, 409),\n        Arguments.of(true, true, true, 129, 422),\n        Arguments.of(true, true, true, 0, 422),\n        Arguments.of(true, false, true, 32, 429),\n        Arguments.of(true, true, true, 128, 200)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void testSetUsernameLink(\n      final boolean auth,\n      final boolean passRateLimiting,\n      final boolean setUsernameHash,\n      final int payloadSize,\n      final int expectedStatus) {\n\n    // checking if rate limiting needs to pass or fail for this test\n    if (passRateLimiting) {\n      MockUtils.updateRateLimiterResponseToAllow(\n          rateLimiters, RateLimiters.For.USERNAME_LINK_OPERATION, AuthHelper.VALID_UUID);\n    } else {\n      MockUtils.updateRateLimiterResponseToFail(\n          rateLimiters, RateLimiters.For.USERNAME_LINK_OPERATION, AuthHelper.VALID_UUID, Duration.ofMinutes(10));\n    }\n\n    // checking if username is to be set for this test\n    if (setUsernameHash) {\n      when(AuthHelper.VALID_ACCOUNT.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH_1));\n    } else {\n      when(AuthHelper.VALID_ACCOUNT.getUsernameHash()).thenReturn(Optional.empty());\n    }\n\n    final Invocation.Builder builder = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_link\")\n        .request();\n\n    // checking if auth is needed for this test\n    if (auth) {\n      builder.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n    }\n\n    // make sure `update()` works\n    doReturn(AuthHelper.VALID_ACCOUNT).when(accountsManager).update(any(), any());\n\n    try (final Response response =\n        builder.put(Entity.json(new EncryptedUsername(TestRandomUtil.nextBytes(payloadSize))))) {\n\n      assertEquals(expectedStatus, response.getStatus());\n    }\n  }\n\n  static Stream<Arguments> testDeleteUsernameLink() {\n    return Stream.of(\n        Arguments.of(false, true, 401),\n        Arguments.of(true, false, 429),\n        Arguments.of(true, true, 204)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void testDeleteUsernameLink(\n      final boolean auth,\n      final boolean passRateLimiting,\n      final int expectedStatus) {\n\n    // checking if rate limiting needs to pass or fail for this test\n    if (passRateLimiting) {\n      MockUtils.updateRateLimiterResponseToAllow(\n          rateLimiters, RateLimiters.For.USERNAME_LINK_OPERATION, AuthHelper.VALID_UUID);\n    } else {\n      MockUtils.updateRateLimiterResponseToFail(\n          rateLimiters, RateLimiters.For.USERNAME_LINK_OPERATION, AuthHelper.VALID_UUID, Duration.ofMinutes(10));\n    }\n\n    final Invocation.Builder builder = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_link\")\n        .request();\n\n    // checking if auth is needed for this test\n    if (auth) {\n      builder.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n    }\n\n    // make sure `update()` works\n    doReturn(AuthHelper.VALID_ACCOUNT).when(accountsManager).update(any(), any());\n\n    try (final Response delete = builder.delete()) {\n      assertEquals(expectedStatus, delete.getStatus());\n    }\n  }\n\n  static Stream<Arguments> testLookupUsernameLink() {\n    return Stream.of(\n        Arguments.of(false, true, true, true, 400),\n        Arguments.of(true, false, true, true, 429),\n        Arguments.of(true, true, false, true, 404),\n        Arguments.of(true, true, true, false, 404),\n        Arguments.of(true, true, true, true, 200)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void testLookupUsernameLink(\n      final boolean stayUnauthenticated,\n      final boolean passRateLimiting,\n      final boolean validUuidInput,\n      final boolean locateLinkByUuid,\n      final int expectedStatus) {\n\n    if (passRateLimiting) {\n      MockUtils.updateRateLimiterResponseToAllow(\n          rateLimiters, RateLimiters.For.USERNAME_LINK_LOOKUP_PER_IP, \"127.0.0.1\");\n    } else {\n      MockUtils.updateRateLimiterResponseToFail(\n          rateLimiters, RateLimiters.For.USERNAME_LINK_LOOKUP_PER_IP, \"127.0.0.1\", Duration.ofMinutes(10));\n    }\n\n    when(accountsManager.getByUsernameLinkHandle(any())).thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    final String uuid = validUuidInput ? UUID.randomUUID().toString() : \"invalid-uuid\";\n\n    if (validUuidInput && locateLinkByUuid) {\n      final Account account = mock(Account.class);\n      when(account.getEncryptedUsername()).thenReturn(Optional.of(TestRandomUtil.nextBytes(16)));\n      when(accountsManager.getByUsernameLinkHandle(UUID.fromString(uuid))).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n    }\n\n    final Invocation.Builder builder = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_link/\" + uuid)\n        .request();\n    if (!stayUnauthenticated) {\n      builder.header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n    }\n    final Response get = builder.get();\n\n    assertEquals(expectedStatus, get.getStatus());\n  }\n\n  @Test\n  void testReserveUsernameHash() throws UsernameHashNotAvailableException {\n    when(accountsManager.reserveUsernameHash(any(), any()))\n        .thenReturn(new AccountsManager.UsernameReservation(null, USERNAME_HASH_1));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/reserve\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ReserveUsernameHashRequest(List.of(USERNAME_HASH_1, USERNAME_HASH_2))))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.readEntity(ReserveUsernameHashResponse.class))\n          .satisfies(r -> assertThat(r.usernameHash()).hasSize(32));\n    }\n  }\n\n  @Test\n  void testReserveUsernameHashUnavailable() throws UsernameHashNotAvailableException {\n    when(accountsManager.reserveUsernameHash(any(), anyList()))\n        .thenThrow(new UsernameHashNotAvailableException());\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/reserve\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ReserveUsernameHashRequest(List.of(USERNAME_HASH_1, USERNAME_HASH_2))))) {\n\n      assertThat(response.getStatus()).isEqualTo(409);\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testReserveUsernameHashListSizeInvalid(List<byte[]> usernameHashes) {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/reserve\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ReserveUsernameHashRequest(usernameHashes)))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  static Stream<Arguments> testReserveUsernameHashListSizeInvalid() {\n    return Stream.of(\n        Arguments.of(Collections.nCopies(21, USERNAME_HASH_1)),\n        Arguments.of(Collections.emptyList())\n    );\n  }\n\n  @Test\n  void testReserveUsernameHashInvalidHashSize() {\n    List<byte[]> usernameHashes = List.of(new byte[31]);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/reserve\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ReserveUsernameHashRequest(usernameHashes)))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testReserveUsernameHashNullList() {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v1/accounts/username_hash/reserve\")\n            .request()\n            .header(HttpHeaders.AUTHORIZATION,\n                AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.json(new ReserveUsernameHashRequest(null)))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testReserveUsernameHashInvalidBase64UrlEncoding() {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v1/accounts/username_hash/reserve\")\n            .request()\n            .header(HttpHeaders.AUTHORIZATION,\n                AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.json(\n                // Has '+' and '='characters which are invalid in base64url\n                \"\"\"\n                  {\n                    \"usernameHashes\": [\"jh1jJ50oGn9wUXAFNtDus6AJgWOQ6XbZzF+wCv7OOQs=\"]\n                  }\n                \"\"\"))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testConfirmUsernameHash()\n      throws BaseUsernameException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    Account account = mock(Account.class);\n    final UUID uuid = UUID.randomUUID();\n    when(account.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH_1));\n    when(account.getUsernameLinkHandle()).thenReturn(uuid);\n    when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1), eq(ENCRYPTED_USERNAME_1)))\n        .thenReturn(account);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF, ENCRYPTED_USERNAME_1)))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n\n      final UsernameHashResponse respEntity = response.readEntity(UsernameHashResponse.class);\n      assertArrayEquals(respEntity.usernameHash(), USERNAME_HASH_1);\n      assertEquals(respEntity.usernameLinkHandle(), uuid);\n      verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1);\n    }\n  }\n\n  @Test\n  void testConfirmUsernameHashNullProof() {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v1/accounts/username_hash/confirm\")\n            .request()\n            .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, null, ENCRYPTED_USERNAME_1)))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testConfirmUsernameHashOld()\n      throws BaseUsernameException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    Account account = mock(Account.class);\n    when(account.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH_1));\n    when(account.getUsernameLinkHandle()).thenReturn(null);\n    when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1), eq(null)))\n        .thenReturn(account);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF, null)))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n\n      final UsernameHashResponse respEntity = response.readEntity(UsernameHashResponse.class);\n      assertArrayEquals(respEntity.usernameHash(), USERNAME_HASH_1);\n      assertNull(respEntity.usernameLinkHandle());\n      verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1);\n    }\n  }\n\n  @Test\n  void testConfirmUnreservedUsernameHash()\n      throws BaseUsernameException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1), any()))\n        .thenThrow(new UsernameReservationNotFoundException());\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF, ENCRYPTED_USERNAME_1)))) {\n\n      assertThat(response.getStatus()).isEqualTo(409);\n      verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1);\n    }\n  }\n\n  @Test\n  void testConfirmLapsedUsernameHash()\n      throws BaseUsernameException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    when(accountsManager.confirmReservedUsernameHash(any(), eq(USERNAME_HASH_1), any()))\n        .thenThrow(new UsernameHashNotAvailableException());\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF, ENCRYPTED_USERNAME_1)))) {\n\n      assertThat(response.getStatus()).isEqualTo(410);\n      verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1);\n    }\n  }\n\n  @Test\n  void testConfirmUsernameHashInvalidBase64UrlEncoding() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(\n            // Has '+' and '='characters which are invalid in base64url\n            \"\"\"\n              {\n                \"usernameHash\": \"jh1jJ50oGn9wUXAFNtDus6AJgWOQ6XbZzF+wCv7OOQs=\",\n                \"zkProof\": \"iYXE0QPK60PS3lGa-xdNv0GlXA3B03xQLzltSf-2xmscyS_8fjy5H9ymfaEr62PcVY7tsWhWjOOvcCnhmP_HS=\"\n              }\n            \"\"\"))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n      verifyNoInteractions(usernameZkProofVerifier);\n    }\n  }\n\n  @Test\n  void testConfirmUsernameHashInvalidHashSize() {\n    byte[] usernameHash = new byte[31];\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ConfirmUsernameHashRequest(usernameHash, ZK_PROOF, ENCRYPTED_USERNAME_1)))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n      verifyNoInteractions(usernameZkProofVerifier);\n    }\n  }\n\n  @Test\n  void testCommitUsernameHashWithInvalidProof() throws BaseUsernameException {\n    doThrow(new BaseUsernameException(\"invalid username\")).when(usernameZkProofVerifier).verifyProof(eq(ZK_PROOF), eq(USERNAME_HASH_1));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/confirm\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new ConfirmUsernameHashRequest(USERNAME_HASH_1, ZK_PROOF, ENCRYPTED_USERNAME_1)))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n      verify(usernameZkProofVerifier).verifyProof(ZK_PROOF, USERNAME_HASH_1);\n    }\n  }\n\n  @Test\n  void testDeleteUsername() {\n    when(accountsManager.clearUsernameHash(any()))\n        .thenAnswer(invocation -> invocation.getArgument(0));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      verify(accountsManager).clearUsernameHash(AuthHelper.VALID_ACCOUNT);\n    }\n  }\n\n  @Test\n  void testDeleteUsernameBadAuth() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(401);\n    }\n  }\n\n  @Test\n  void testSetAccountAttributesNoDiscoverabilityChange() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/attributes/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new AccountAttributes(false, 2222, 3333, null, null, true, null)))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n    }\n  }\n\n  @Test\n  void testSetAccountAttributesEnableDiscovery() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/attributes/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.UNDISCOVERABLE_UUID, AuthHelper.UNDISCOVERABLE_PASSWORD))\n        .put(Entity.json(new AccountAttributes(false, 2222, 3333, null, null, true, null)))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n    }\n  }\n\n  @Test\n  void testAccountsAttributesUpdateRecoveryPassword() {\n    final byte[] recoveryPassword = TestRandomUtil.nextBytes(32);\n\n    when(registrationRecoveryPasswordsManager.store(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(true));\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/attributes/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.UNDISCOVERABLE_UUID, AuthHelper.UNDISCOVERABLE_PASSWORD))\n        .put(Entity.json(new AccountAttributes(false, 2222, 3333, null, null, true, null)\n            .withRecoveryPassword(recoveryPassword)))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      verify(registrationRecoveryPasswordsManager).store(AuthHelper.UNDISCOVERABLE_PNI, recoveryPassword);\n    }\n  }\n\n  @Test\n  void testSetAccountAttributesDisableDiscovery() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/attributes/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new AccountAttributes(false, 2222, 3333, null, null, false, null)))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n    }\n  }\n\n  @Test\n  void testSetAccountAttributesBadUnidentifiedKeyLength() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/attributes/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new AccountAttributes(false, 2222, 3333, null, null, false, null)\n            .withUnidentifiedAccessKey(new byte[7])))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testDeleteAccount() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/me\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      verify(accountsManager).delete(AuthHelper.VALID_ACCOUNT, AccountsManager.DeletionReason.USER_REQUEST);\n    }\n  }\n\n  @Test\n  void testDeleteAccountException() {\n    doThrow(new RuntimeException(\"OH NO\"))\n        .when(accountsManager).delete(any(), any());\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/me\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(500);\n      verify(accountsManager).delete(AuthHelper.VALID_ACCOUNT, AccountsManager.DeletionReason.USER_REQUEST);\n    }\n  }\n\n  @Test\n  void testAccountExists() {\n    final Account account = mock(Account.class);\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final UUID phoneNumberIdentifier = UUID.randomUUID();\n\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(accountIdentifier))).thenReturn(Optional.of(account));\n    when(accountsManager.getByServiceIdentifier(new PniServiceIdentifier(phoneNumberIdentifier))).thenReturn(Optional.of(account));\n\n    when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(mock(RateLimiter.class));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/account/%s\", accountIdentifier))\n        .request()\n        .head()) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n    }\n\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/account/PNI:%s\", phoneNumberIdentifier))\n        .request()\n        .head()) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n    }\n\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/account/%s\", UUID.randomUUID()))\n        .request()\n        .head()) {\n\n      assertThat(response.getStatus()).isEqualTo(404);\n    }\n  }\n\n  @Test\n  void testAccountExistsRateLimited() {\n    final Duration expectedRetryAfter = Duration.ofSeconds(13);\n    final Account account = mock(Account.class);\n    final UUID accountIdentifier = UUID.randomUUID();\n    when(accountsManager.getByAccountIdentifier(accountIdentifier)).thenReturn(Optional.of(account));\n\n    MockUtils.updateRateLimiterResponseToFail(\n        rateLimiters, RateLimiters.For.CHECK_ACCOUNT_EXISTENCE, \"127.0.0.1\", expectedRetryAfter);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/account/%s\", accountIdentifier))\n        .request()\n        .head()) {\n\n      assertThat(response.getStatus()).isEqualTo(429);\n      assertThat(response.getHeaderString(\"Retry-After\")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds()));\n    }\n  }\n\n  @Test\n  void testAccountExistsAuthenticated() {\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/account/%s\", UUID.randomUUID()))\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .head()) {\n\n      assertThat(response.getStatus()).isEqualTo(400);\n    }\n  }\n\n  @Test\n  void testLookupUsername() {\n    final Account account = mock(Account.class);\n    final UUID uuid = UUID.randomUUID();\n    when(account.getUuid()).thenReturn(uuid);\n\n    when(accountsManager.getByUsernameHash(any())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n    Response response = resources.getJerseyTest()\n        .target(String.format(\"v1/accounts/username_hash/%s\", BASE_64_URL_USERNAME_HASH_1))\n        .request()\n        .get();\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.readEntity(AccountIdentifierResponse.class).uuid().uuid()).isEqualTo(uuid);\n  }\n\n  @Test\n  void testLookupUsernameDoesNotExist() {\n    when(accountsManager.getByUsernameHash(any())).thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n    assertThat(resources.getJerseyTest()\n        .target(String.format(\"v1/accounts/username_hash/%s\", BASE_64_URL_USERNAME_HASH_1))\n        .request()\n        .get().getStatus()).isEqualTo(404);\n  }\n\n  @Test\n  void testLookupUsernameRateLimited() {\n    final Duration expectedRetryAfter = Duration.ofSeconds(13);\n    MockUtils.updateRateLimiterResponseToFail(\n        rateLimiters, RateLimiters.For.USERNAME_LOOKUP, \"127.0.0.1\", expectedRetryAfter);\n    final Response response = resources.getJerseyTest()\n        .target(String.format(\"v1/accounts/username_hash/%s\", BASE_64_URL_USERNAME_HASH_1))\n        .request()\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(429);\n    assertThat(response.getHeaderString(\"Retry-After\")).isEqualTo(String.valueOf(expectedRetryAfter.toSeconds()));\n  }\n\n  @Test\n  void testLookupUsernameAuthenticated() {\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/username_hash/%s\", BASE_64_URL_USERNAME_HASH_1))\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertThat(response.getStatus()).isEqualTo(400);\n    }\n  }\n\n  @Test\n  void testLookupUsernameInvalidFormat() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_hash/Not valid base64\")\n        .request()\n        .get()) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/accounts/username_hash/%s\", TOO_SHORT_BASE_64_URL_USERNAME_HASH))\n        .request()\n        .get()) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = { true, false })\n  void testPutUsernameLink(boolean keepLink) {\n    when(rateLimiters.forDescriptor(eq(RateLimiters.For.USERNAME_LINK_OPERATION))).thenReturn(mock(RateLimiter.class));\n\n    final UUID oldLinkHandle = UUID.randomUUID();\n    when(AuthHelper.VALID_ACCOUNT.getUsernameLinkHandle()).thenReturn(oldLinkHandle);\n\n    final byte[] encryptedUsername = \"some encrypted goop\".getBytes();\n    final UsernameLinkHandle newHandle = resources.getJerseyTest()\n        .target(\"/v1/accounts/username_link\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(new EncryptedUsername(encryptedUsername, keepLink)), UsernameLinkHandle.class);\n\n    assertThat(newHandle.usernameLinkHandle().equals(oldLinkHandle)).isEqualTo(keepLink);\n    verify(AuthHelper.VALID_ACCOUNT).setUsernameLinkDetails(eq(newHandle.usernameLinkHandle()), eq(encryptedUsername));\n  }\n\n  @Test\n  void testSetDeviceName() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/name/\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, Device.PRIMARY_ID, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n        .put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      verify(accountsManager).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), eq(Device.PRIMARY_ID), any());\n    }\n  }\n\n  @Test\n  void testSetLinkedDeviceNameFromPrimary() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/name/\")\n        .queryParam(\"deviceId\", AuthHelper.VALID_DEVICE_3_LINKED_ID)\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, Device.PRIMARY_ID, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n        .put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      verify(accountsManager).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), eq(AuthHelper.VALID_DEVICE_3_LINKED_ID), any());\n    }\n  }\n\n  @Test\n  void testSetLinkedDeviceNameFromLinked() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/name/\")\n        .queryParam(\"deviceId\", AuthHelper.VALID_DEVICE_3_LINKED_ID)\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_DEVICE_3_LINKED_ID, AuthHelper.VALID_PASSWORD_3_LINKED))\n        .put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      verify(accountsManager).updateDevice(eq(AuthHelper.VALID_ACCOUNT_3), eq(AuthHelper.VALID_DEVICE_3_LINKED_ID), any());\n    }\n  }\n\n  @Test\n  void testSetDeviceNameDeviceNotFound() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/name/\")\n        .queryParam(\"deviceId\", Device.MAXIMUM_DEVICE_ID)\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n        .put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {\n\n      assertThat(response.getStatus()).isEqualTo(404);\n      verify(accountsManager, never()).updateDevice(any(), anyByte(), any());\n    }\n  }\n\n  @Test\n  void testSetPrimaryDeviceNameFromLinked() {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/accounts/name/\")\n        .queryParam(\"deviceId\", Device.PRIMARY_ID)\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_DEVICE_3_LINKED_ID, AuthHelper.VALID_PASSWORD_3_LINKED))\n        .put(Entity.json(new DeviceName(TestRandomUtil.nextBytes(64))))) {\n\n      assertThat(response.getStatus()).isEqualTo(403);\n      verify(accountsManager, never()).updateDevice(any(), anyByte(), any());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountControllerV2Test.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.apache.http.HttpStatus;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.stubbing.Answer;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockError;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.entities.AccountDataReportResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.ChangeNumberRequest;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.PhoneNumberDiscoverabilityRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ChangeNumberManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass AccountControllerV2Test {\n\n  private static final long SESSION_EXPIRATION_SECONDS = Duration.ofMinutes(10).toSeconds();\n\n  private static final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n  private static final IdentityKey IDENTITY_KEY = new IdentityKey(IDENTITY_KEY_PAIR.getPublicKey());\n\n  private static final String NEW_NUMBER = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n      PhoneNumberUtil.PhoneNumberFormat.E164);\n\n  private final AccountsManager accountsManager = mock(AccountsManager.class);\n  private final ChangeNumberManager changeNumberManager = mock(ChangeNumberManager.class);\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class);\n  private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class);\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock(\n      RegistrationRecoveryPasswordsManager.class);\n  private final RegistrationLockVerificationManager registrationLockVerificationManager = mock(\n      RegistrationLockVerificationManager.class);\n  private final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private final RateLimiter registrationLimiter = mock(RateLimiter.class);\n  private final RegistrationRecoveryChecker registrationRecoveryChecker = mock(RegistrationRecoveryChecker.class);\n\n  private final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .addProvider(new ImpossiblePhoneNumberExceptionMapper())\n      .addProvider(new NonNormalizedPhoneNumberExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(\n          new AccountControllerV2(accountsManager, changeNumberManager,\n              new PhoneVerificationTokenManager(phoneNumberIdentifiers, registrationServiceClient,\n                  registrationRecoveryPasswordsManager, registrationRecoveryChecker),\n              registrationLockVerificationManager, rateLimiters))\n      .build();\n\n  @Nested\n  class ChangeNumber {\n\n    @BeforeEach\n    void setUp() throws Exception {\n      when(rateLimiters.getRegistrationLimiter()).thenReturn(registrationLimiter);\n\n      when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n\n      when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any(), any(), any())).thenAnswer(\n          (Answer<Account>) invocation -> {\n            final Account account = invocation.getArgument(0);\n            final String number = invocation.getArgument(1);\n            final IdentityKey pniIdentityKey = invocation.getArgument(2);\n\n            final UUID uuid = account.getUuid();\n            final List<Device> devices = account.getDevices();\n\n            final Account updatedAccount = mock(Account.class);\n            when(updatedAccount.getUuid()).thenReturn(uuid);\n            when(updatedAccount.getNumber()).thenReturn(number);\n            when(updatedAccount.getIdentityKey(IdentityType.PNI)).thenReturn(pniIdentityKey);\n            if (number.equals(account.getNumber())) {\n              when(updatedAccount.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI);\n            } else {\n              when(updatedAccount.getPhoneNumberIdentifier()).thenReturn(UUID.randomUUID());\n            }\n            when(updatedAccount.getDevices()).thenReturn(devices);\n\n            for (byte i = 1; i <= 3; i++) {\n              final Optional<Device> d = account.getDevice(i);\n              when(updatedAccount.getDevice(i)).thenReturn(d);\n            }\n\n            return updatedAccount;\n          });\n    }\n\n    @Test\n    void changeNumberSuccess() throws Exception {\n\n      when(registrationServiceClient.getSession(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(\n              Optional.of(new RegistrationServiceSession(new byte[16], NEW_NUMBER, true, null, null, null,\n                  SESSION_EXPIRATION_SECONDS))));\n\n      final AccountIdentityResponse accountIdentityResponse =\n          resources.getJerseyTest()\n              .target(\"/v2/accounts/number\")\n              .request()\n              .header(HttpHeaders.AUTHORIZATION,\n                  AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n              .put(Entity.entity(\n                  new ChangeNumberRequest(encodeSessionId(\"session\"), null, NEW_NUMBER, \"123\", IDENTITY_KEY,\n                      Collections.emptyList(),\n                      Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, IDENTITY_KEY_PAIR)),\n                      Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, IDENTITY_KEY_PAIR)),\n                      Map.of(Device.PRIMARY_ID, 17)),\n                  MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class);\n\n      verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(NEW_NUMBER), any(), any(), any(),\n          any(), any(), any());\n\n      assertEquals(AuthHelper.VALID_UUID, accountIdentityResponse.uuid());\n      assertEquals(NEW_NUMBER, accountIdentityResponse.number());\n      assertNotEquals(AuthHelper.VALID_PNI, accountIdentityResponse.pni());\n    }\n\n    @Test\n    void changeNumberSameNumber() throws Exception {\n      final AccountIdentityResponse accountIdentityResponse =\n          resources.getJerseyTest()\n              .target(\"/v2/accounts/number\")\n              .request()\n              .header(HttpHeaders.AUTHORIZATION,\n                  AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n              .put(Entity.entity(\n                  new ChangeNumberRequest(encodeSessionId(\"session\"), null, AuthHelper.VALID_NUMBER, null, IDENTITY_KEY,\n                      Collections.emptyList(),\n                      Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, IDENTITY_KEY_PAIR)),\n                      Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, IDENTITY_KEY_PAIR)),\n                      Map.of(Device.PRIMARY_ID, 17)),\n                  MediaType.APPLICATION_JSON_TYPE), AccountIdentityResponse.class);\n\n      verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(AuthHelper.VALID_NUMBER), any(), any(), any(),\n          any(), any(), any());\n\n      assertEquals(AuthHelper.VALID_UUID, accountIdentityResponse.uuid());\n      assertEquals(AuthHelper.VALID_NUMBER, accountIdentityResponse.number());\n      assertEquals(AuthHelper.VALID_PNI, accountIdentityResponse.pni());\n    }\n\n    @Test\n    void changeNumberNonNormalizedNumber() {\n      try (Response response = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .put(Entity.entity(\n              // +4407700900111 is a valid number but not normalized - it has an optional '0' after the country code\n              new ChangeNumberRequest(encodeSessionId(\"session\"), null, \"+4407700900111\", null,\n                  new IdentityKey(ECKeyPair.generate().getPublicKey()),\n                  Collections.emptyList(),\n                  Collections.emptyMap(), null, Collections.emptyMap()),\n              MediaType.APPLICATION_JSON_TYPE))) {\n        assertEquals(422, response.getStatus());\n      }\n    }\n\n    @Test\n    void unprocessableRequestJson() {\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(unprocessableJson()))) {\n        assertEquals(400, response.getStatus());\n      }\n    }\n\n    @Test\n    void missingBasicAuthorization() {\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request();\n      try (Response response = request.put(Entity.json(requestJson(\"sessionId\", NEW_NUMBER)))) {\n        assertEquals(401, response.getStatus());\n      }\n    }\n\n    @Test\n    void invalidBasicAuthorization() {\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION, \"Basic but-invalid\");\n      try (Response response = request.put(Entity.json(invalidRequestJson()))) {\n        assertEquals(401, response.getStatus());\n      }\n    }\n\n    @Test\n    void invalidRequestBody() {\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(invalidRequestJson()))) {\n        assertEquals(422, response.getStatus());\n      }\n    }\n\n    @ParameterizedTest\n    @MethodSource\n    void invalidRegistrationId(final Integer pniRegistrationId, final int expectedStatusCode) {\n      when(registrationServiceClient.getSession(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(\n              Optional.of(new RegistrationServiceSession(new byte[16], NEW_NUMBER, true, null, null, null,\n                  SESSION_EXPIRATION_SECONDS))));\n      final ChangeNumberRequest changeNumberRequest = new ChangeNumberRequest(encodeSessionId(\"session\"), null, NEW_NUMBER, \"123\", IDENTITY_KEY,\n          Collections.emptyList(),\n          Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, IDENTITY_KEY_PAIR)),\n          Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, IDENTITY_KEY_PAIR)),\n          Map.of(Device.PRIMARY_ID, pniRegistrationId));\n\n      try (final Response response = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .put(Entity.entity(changeNumberRequest, MediaType.APPLICATION_JSON_TYPE))) {\n\n        assertEquals(expectedStatusCode, response.getStatus());\n      }\n    }\n\n    private static Stream<Arguments> invalidRegistrationId() {\n      return Stream.of(\n          Arguments.of(0x3FFF, 200),\n          Arguments.of(0, 422),\n          Arguments.of(-1, 422),\n          Arguments.of(0x3FFF + 1, 422),\n          Arguments.of(Integer.MAX_VALUE, 422)\n      );\n    }\n\n    @Test\n    void rateLimitedNumber() throws Exception {\n      doThrow(new RateLimitExceededException(null))\n          .when(registrationLimiter).validate(anyString());\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(requestJson(\"sessionId\", NEW_NUMBER)))) {\n        assertEquals(429, response.getStatus());\n      }\n    }\n\n    @Test\n    void registrationServiceTimeout() {\n      when(registrationServiceClient.getSession(any(), any()))\n          .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(requestJson(\"sessionId\", NEW_NUMBER)))) {\n        assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());\n      }\n    }\n\n    @ParameterizedTest\n    @MethodSource\n    void registrationServiceSessionCheck(@Nullable final RegistrationServiceSession session, final int expectedStatus,\n        final String message) {\n      when(registrationServiceClient.getSession(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session)));\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(requestJson(\"sessionId\", NEW_NUMBER)))) {\n        assertEquals(expectedStatus, response.getStatus(), message);\n      }\n    }\n\n    static Stream<Arguments> registrationServiceSessionCheck() {\n      return Stream.of(\n          Arguments.of(null, 401, \"session not found\"),\n          Arguments.of(new RegistrationServiceSession(new byte[16], \"+18005551234\", false, null, null, null,\n                  SESSION_EXPIRATION_SECONDS), 400,\n              \"session number mismatch\"),\n          Arguments.of(\n              new RegistrationServiceSession(new byte[16], NEW_NUMBER, false, null, null, null,\n                  SESSION_EXPIRATION_SECONDS),\n              401,\n              \"session not verified\")\n      );\n    }\n\n    @ParameterizedTest\n    @EnumSource(RegistrationLockError.class)\n    void registrationLock(final RegistrationLockError error) throws Exception {\n      when(registrationServiceClient.getSession(any(), any()))\n          .thenReturn(\n              CompletableFuture.completedFuture(\n                  Optional.of(new RegistrationServiceSession(new byte[16], NEW_NUMBER, true, null, null, null,\n                      SESSION_EXPIRATION_SECONDS))));\n\n      when(accountsManager.getByE164(any())).thenReturn(Optional.of(mock(Account.class)));\n\n      final Exception e = switch (error) {\n        case MISMATCH -> new WebApplicationException(error.getExpectedStatus());\n        case RATE_LIMITED -> new RateLimitExceededException(null);\n      };\n      doThrow(e)\n          .when(registrationLockVerificationManager).verifyRegistrationLock(any(), any(), any(), any(), any());\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(requestJson(\"sessionId\", NEW_NUMBER)))) {\n        assertEquals(error.getExpectedStatus(), response.getStatus());\n      }\n    }\n\n    @Test\n    void recoveryPasswordManagerVerificationTrue() throws Exception {\n      when(phoneNumberIdentifiers.getPhoneNumberIdentifier(any()))\n          .thenReturn(CompletableFuture.completedFuture(UUID.randomUUID()));\n      when(registrationRecoveryPasswordsManager.verify(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(true));\n      when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any()))\n          .thenReturn(true);\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      final byte[] recoveryPassword = new byte[32];\n      try (Response response = request.put(Entity.json(requestJsonRecoveryPassword(recoveryPassword, NEW_NUMBER)))) {\n        assertEquals(200, response.getStatus());\n\n        final AccountIdentityResponse accountIdentityResponse = response.readEntity(AccountIdentityResponse.class);\n\n        verify(changeNumberManager).changeNumber(eq(AuthHelper.VALID_ACCOUNT), eq(NEW_NUMBER), any(), any(), any(),\n            any(), any(), any());\n\n        assertEquals(AuthHelper.VALID_UUID, accountIdentityResponse.uuid());\n        assertEquals(NEW_NUMBER, accountIdentityResponse.number());\n        assertNotEquals(AuthHelper.VALID_PNI, accountIdentityResponse.pni());\n      }\n    }\n\n    @Test\n    void recoveryPasswordManagerVerificationFalse() {\n      when(registrationRecoveryPasswordsManager.verify(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(false));\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      try (Response response = request.put(Entity.json(requestJsonRecoveryPassword(new byte[32], NEW_NUMBER)))) {\n        assertEquals(403, response.getStatus());\n      }\n    }\n\n    @Test\n    void registrationRecoveryCheckerAllowsAttempt() {\n      when(phoneNumberIdentifiers.getPhoneNumberIdentifier(any()))\n          .thenReturn(CompletableFuture.completedFuture(UUID.randomUUID()));\n      when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any())).thenReturn(true);\n      when(registrationRecoveryPasswordsManager.verify(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(true));\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      final byte[] recoveryPassword = new byte[32];\n      try (Response response = request.put(Entity.json(requestJsonRecoveryPassword(recoveryPassword, NEW_NUMBER)))) {\n        assertEquals(200, response.getStatus());\n      }\n    }\n\n    @Test\n    void registrationRecoveryCheckerDisallowsAttempt() {\n      when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any())).thenReturn(false);\n      when(registrationRecoveryPasswordsManager.verify(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(true));\n\n      final Invocation.Builder request = resources.getJerseyTest()\n          .target(\"/v2/accounts/number\")\n          .request()\n          .header(HttpHeaders.AUTHORIZATION,\n              AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n      final byte[] recoveryPassword = new byte[32];\n      try (Response response = request.put(Entity.json(requestJsonRecoveryPassword(recoveryPassword, NEW_NUMBER)))) {\n        assertEquals(403, response.getStatus());\n      }\n    }\n\n    @Test\n    void deviceMessageTooLarge() throws Exception {\n\n      when(registrationServiceClient.getSession(any(), any()))\n          .thenReturn(CompletableFuture.completedFuture(\n              Optional.of(new RegistrationServiceSession(new byte[16], NEW_NUMBER, true, null, null, null,\n                  SESSION_EXPIRATION_SECONDS))));\n\n      reset(changeNumberManager);\n      when(changeNumberManager.changeNumber(any(), any(), any(), any(), any(), any(), any(), any()))\n          .thenThrow(MessageTooLargeException.class);\n\n      try (final Response response = resources.getJerseyTest()\n              .target(\"/v2/accounts/number\")\n              .request()\n              .header(HttpHeaders.AUTHORIZATION,\n                  AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n              .put(Entity.entity(\n                  new ChangeNumberRequest(encodeSessionId(\"session\"), null, NEW_NUMBER, \"123\", IDENTITY_KEY,\n                      Collections.emptyList(),\n                      Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, IDENTITY_KEY_PAIR)),\n                      Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, IDENTITY_KEY_PAIR)),\n                      Map.of(Device.PRIMARY_ID, 17)),\n                  MediaType.APPLICATION_JSON_TYPE))) {\n\n        assertEquals(413, response.getStatus());\n      }\n    }\n\n    /**\n     * Valid request JSON with the given Recovery Password\n     */\n    private static String requestJsonRecoveryPassword(final byte[] recoveryPassword, final String newNumber) {\n      return requestJson(\"\", recoveryPassword, newNumber, 123);\n    }\n\n    /**\n     * Valid request JSON with the give session ID and recovery password\n     */\n    private static String requestJson(final String sessionId,\n        final byte[] recoveryPassword,\n        final String newNumber,\n        final Integer pniRegistrationId) {\n\n      final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(1, IDENTITY_KEY_PAIR);\n      final KEMSignedPreKey pniLastResortPreKey = KeysHelper.signedKEMPreKey(2, IDENTITY_KEY_PAIR);\n\n      return String.format(\"\"\"\n          {\n            \"sessionId\": \"%s\",\n            \"recoveryPassword\": \"%s\",\n            \"number\": \"%s\",\n            \"reglock\": \"1234\",\n            \"pniIdentityKey\": \"%s\",\n            \"deviceMessages\": [],\n            \"devicePniSignedPrekeys\": {\"1\": {\"keyId\": %d, \"publicKey\": \"%s\", \"signature\": \"%s\"}},\n            \"devicePniPqLastResortPrekeys\": {\"1\": {\"keyId\": %d, \"publicKey\": \"%s\", \"signature\": \"%s\"}},\n            \"pniRegistrationIds\": {\"1\": %d}\n          }\n          \"\"\", encodeSessionId(sessionId),\n          encodeRecoveryPassword(recoveryPassword),\n          newNumber,\n          Base64.getEncoder().encodeToString(IDENTITY_KEY.serialize()),\n          pniSignedPreKey.keyId(), Base64.getEncoder().encodeToString(pniSignedPreKey.serializedPublicKey()), Base64.getEncoder().encodeToString(pniSignedPreKey.signature()),\n          pniLastResortPreKey.keyId(), Base64.getEncoder().encodeToString(pniLastResortPreKey.serializedPublicKey()), Base64.getEncoder().encodeToString(pniLastResortPreKey.signature()),\n          pniRegistrationId);\n    }\n\n    /**\n     * Valid request JSON with the give session ID\n     */\n    private static String requestJson(final String sessionId, final String newNumber) {\n      return requestJson(sessionId, new byte[0], newNumber, 123);\n    }\n\n    /**\n     * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.ChangeNumberRequest}, but that\n     * fails validation\n     */\n    private static String invalidRequestJson() {\n      return \"\"\"\n          {\n            \"sessionId\": null\n          }\n          \"\"\";\n    }\n\n    /**\n     * Request JSON that cannot be marshalled into\n     * {@link org.whispersystems.textsecuregcm.entities.ChangeNumberRequest}\n     */\n    private static String unprocessableJson() {\n      return \"\"\"\n          {\n            \"sessionId\": []\n          }\n          \"\"\";\n    }\n\n    private static String encodeSessionId(final String sessionId) {\n      return Base64.getUrlEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8));\n    }\n\n    private static String encodeRecoveryPassword(final byte[] recoveryPassword) {\n      return Base64.getEncoder().encodeToString(recoveryPassword);\n    }\n  }\n\n  @Nested\n  class PhoneNumberDiscoverability {\n\n    @BeforeEach\n    void setup() {\n      AccountsHelper.setupMockUpdate(accountsManager);\n      when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n    }\n\n    @Test\n    void testSetPhoneNumberDiscoverability() {\n      Response response = resources.getJerseyTest()\n          .target(\"/v2/accounts/phone_number_discoverability\")\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .put(Entity.json(new PhoneNumberDiscoverabilityRequest(true)));\n\n      assertThat(response.getStatus()).isEqualTo(204);\n\n      ArgumentCaptor<Boolean> discoverabilityCapture = ArgumentCaptor.forClass(Boolean.class);\n      verify(AuthHelper.VALID_ACCOUNT).setDiscoverableByPhoneNumber(discoverabilityCapture.capture());\n      assertThat(discoverabilityCapture.getValue()).isTrue();\n    }\n\n    @Test\n    void testSetNullPhoneNumberDiscoverability() {\n      Response response = resources.getJerseyTest()\n          .target(\"/v2/accounts/phone_number_discoverability\")\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .put(Entity.json(\n              \"\"\"\n                  {\n                    \"discoverableByPhoneNumber\": null\n                  }\n                  \"\"\"));\n\n      assertThat(response.getStatus()).isEqualTo(422);\n      verify(AuthHelper.VALID_ACCOUNT, never()).setDiscoverableByPhoneNumber(anyBoolean());\n    }\n\n    @ParameterizedTest\n    @MethodSource\n    void testGetAccountDataReport(final Account account, final String expectedTextAfterHeader) throws Exception {\n      when(AuthHelper.ACCOUNTS_MANAGER.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n      when(accountsManager.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n\n      final Response response = resources.getJerseyTest()\n          .target(\"/v2/accounts/data_report\")\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(account.getUuid(), \"password\"))\n          .get();\n\n      assertEquals(200, response.getStatus());\n\n      final String stringResponse = response.readEntity(String.class);\n\n      final AccountDataReportResponse structuredResponse = SystemMapper.jsonMapper()\n          .readValue(stringResponse, AccountDataReportResponse.class);\n\n      assertEquals(account.getNumber(), structuredResponse.data().account().phoneNumber());\n      assertEquals(account.isDiscoverableByPhoneNumber(),\n          structuredResponse.data().account().findAccountByPhoneNumber());\n      assertEquals(account.isUnrestrictedUnidentifiedAccess(),\n          structuredResponse.data().account().allowSealedSenderFromAnyone());\n\n      final Set<Byte> deviceIds = account.getDevices().stream().map(Device::getId).collect(Collectors.toSet());\n\n      // all devices should be present\n      structuredResponse.data().devices().forEach(deviceDataReport -> {\n        assertTrue(deviceIds.remove(deviceDataReport.id()));\n        assertEquals(account.getDevice(deviceDataReport.id()).orElseThrow().getUserAgent(),\n            deviceDataReport.userAgent());\n      });\n      assertTrue(deviceIds.isEmpty());\n\n      final String actualText = (String) SystemMapper.jsonMapper().readValue(stringResponse, Map.class).get(\"text\");\n      final int headerEnd = actualText.indexOf(\"# Account\");\n      assertEquals(expectedTextAfterHeader, actualText.substring(headerEnd));\n\n      final String actualHeader = actualText.substring(0, headerEnd);\n      assertTrue(actualHeader.matches(\n          \"Report ID: [a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}\\nReport timestamp: \\\\d{4}-\\\\d{2}-\\\\d{2}T\\\\d{2}:\\\\d{2}:\\\\d{2}Z\\n\\n\"));\n    }\n\n    static Stream<Arguments> testGetAccountDataReport() {\n      final String exampleNumber1 = toE164(PhoneNumberUtil.getInstance().getExampleNumber(\"ES\"));\n      final String account2PhoneNumber = toE164(PhoneNumberUtil.getInstance().getExampleNumber(\"AU\"));\n      final String account3PhoneNumber = toE164(PhoneNumberUtil.getInstance().getExampleNumber(\"IN\"));\n\n      final Instant account1Device1Created = Instant.ofEpochSecond(1669323142); // 2022-11-24T20:52:22Z\n      final Instant account1Device2Created = Instant.ofEpochSecond(1679155122); // 2023-03-18T15:58:42Z\n      final Instant account1Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis());\n      final Instant account1Device2LastSeen = Instant.ofEpochSecond(1678838400); // 2023-03-15T00:00:00Z\n\n      final Instant account2Device1Created = Instant.ofEpochSecond(1659123001); // 2022-07-29T19:30:01Z\n      final Instant account2Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis());\n      final Instant badgeAExpiration = Instant.now().plus(Duration.ofDays(21)).truncatedTo(ChronoUnit.SECONDS);\n\n      final Instant account3Device1Created = Instant.ofEpochSecond(1639923487); // 2021-12-19T14:18:07Z\n      final Instant account3Device1LastSeen = Instant.ofEpochMilli(Util.todayInMillis());\n      final Instant badgeBExpiration = Instant.now().plus(Duration.ofDays(21)).truncatedTo(ChronoUnit.SECONDS);\n      final Instant badgeCExpiration = Instant.now().plus(Duration.ofDays(24)).truncatedTo(ChronoUnit.SECONDS);\n\n      return Stream.of(\n          Arguments.of(\n              buildTestAccountForDataReport(UUID.randomUUID(), exampleNumber1,\n                  true, true,\n                  Collections.emptyList(),\n                  List.of(new DeviceData(Device.PRIMARY_ID, account1Device1LastSeen, account1Device1Created, null),\n                      new DeviceData((byte) 2, account1Device2LastSeen, account1Device2Created, \"OWP\"))),\n              String.format(\"\"\"\n                      # Account\n                      Phone number: %s\n                      Allow sealed sender from anyone: true\n                      Find account by phone number: true\n                      Badges: None\n                                        \n                      # Devices\n                      - ID: 1\n                        Created: 2022-11-24T20:52:22Z\n                        Last seen: %s\n                        User-agent: null\n                      - ID: 2\n                        Created: 2023-03-18T15:58:42Z\n                        Last seen: 2023-03-15T00:00:00Z\n                        User-agent: OWP\n                      \"\"\",\n                  exampleNumber1,\n                  account1Device1LastSeen)\n          ),\n          Arguments.of(\n              buildTestAccountForDataReport(UUID.randomUUID(), account2PhoneNumber,\n                  false, true,\n                  List.of(new AccountBadge(\"badge_a\", badgeAExpiration, true)),\n                  List.of(new DeviceData(Device.PRIMARY_ID, account2Device1LastSeen, account2Device1Created, \"OWI\"))),\n              String.format(\"\"\"\n                      # Account\n                      Phone number: %s\n                      Allow sealed sender from anyone: false\n                      Find account by phone number: true\n                      Badges:\n                      - ID: badge_a\n                        Expiration: %s\n                        Visible: true\n                                        \n                      # Devices\n                      - ID: 1\n                        Created: 2022-07-29T19:30:01Z\n                        Last seen: %s\n                        User-agent: OWI\n                      \"\"\", account2PhoneNumber,\n                  badgeAExpiration,\n                  account2Device1LastSeen)\n          ),\n          Arguments.of(\n              buildTestAccountForDataReport(UUID.randomUUID(), account3PhoneNumber,\n                  true, false,\n                  List.of(\n                      new AccountBadge(\"badge_b\", badgeBExpiration, true),\n                      new AccountBadge(\"badge_c\", badgeCExpiration, false)),\n                  List.of(new DeviceData(Device.PRIMARY_ID, account3Device1LastSeen, account3Device1Created, \"OWA\"))),\n              String.format(\"\"\"\n                      # Account\n                      Phone number: %s\n                      Allow sealed sender from anyone: true\n                      Find account by phone number: false\n                      Badges:\n                      - ID: badge_b\n                        Expiration: %s\n                        Visible: true\n                      - ID: badge_c\n                        Expiration: %s\n                        Visible: false\n                                        \n                      # Devices\n                      - ID: 1\n                        Created: 2021-12-19T14:18:07Z\n                        Last seen: %s\n                        User-agent: OWA\n                      \"\"\", account3PhoneNumber,\n                  badgeBExpiration,\n                  badgeCExpiration,\n                  account3Device1LastSeen)\n          )\n      );\n    }\n\n    /**\n     * Creates an {@link Account} with data sufficient for\n     * {@link AccountControllerV2#getAccountDataReport(AuthenticatedDevice)}.\n     * <p>\n     * Note: All devices will have a {@link SaltedTokenHash} for \"password\"\n     */\n    static Account buildTestAccountForDataReport(final UUID aci, final String number,\n        final boolean unrestrictedUnidentifiedAccess, final boolean discoverableByPhoneNumber,\n        List<AccountBadge> badges, List<DeviceData> devices) {\n\n      final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n      final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n      final Account account = new Account();\n      account.setUuid(aci);\n      account.setNumber(number, UUID.randomUUID());\n      account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess);\n      account.setDiscoverableByPhoneNumber(discoverableByPhoneNumber);\n      account.setBadges(Clock.systemUTC(), new ArrayList<>(badges));\n      account.setIdentityKey(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n      account.setPhoneNumberIdentityKey(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n      assert !devices.isEmpty();\n\n      final SaltedTokenHash passwordTokenHash = SaltedTokenHash.generateFor(\"password\");\n\n      devices.forEach(deviceData -> {\n        final Device device = new Device();\n        device.setId(deviceData.id);\n        device.setAuthTokenHash(passwordTokenHash);\n        device.setFetchesMessages(true);\n        device.setLastSeen(deviceData.lastSeen().toEpochMilli());\n        device.setCreated(deviceData.created().toEpochMilli());\n        device.setUserAgent(deviceData.userAgent());\n        account.addDevice(device);\n      });\n\n      return account;\n    }\n\n    private record DeviceData(byte id, Instant lastSeen, Instant created, @Nullable String userAgent) {\n\n    }\n\n    private static String toE164(Phonenumber.PhoneNumber phoneNumber) {\n      return PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/AccountIdentityResponseBuilderTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Instant;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.entities.Entitlements;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass AccountIdentityResponseBuilderTest {\n\n  @Test\n  void expiredBackupEntitlement() {\n    final Instant expiration = Instant.ofEpochSecond(101);\n    final Account account = mock(Account.class);\n    when(account.getBackupVoucher()).thenReturn(new Account.BackupVoucher(6, expiration));\n\n    Entitlements.BackupEntitlement backup = new AccountIdentityResponseBuilder(account)\n        .clock(TestClock.pinned(Instant.ofEpochSecond(101)))\n        .build().entitlements().backup();\n    assertThat(backup).isNull();\n\n    backup = new AccountIdentityResponseBuilder(account)\n        .clock(TestClock.pinned(Instant.ofEpochSecond(100)))\n        .build().entitlements().backup();\n    assertThat(backup).isNotNull();\n    assertThat(backup.expiration()).isEqualTo(expiration);\n    assertThat(backup.backupLevel()).isEqualTo(6);\n  }\n\n  @Test\n  void expiredBadgeEntitlement() {\n    final Account account = mock(Account.class);\n    when(account.getBadges()).thenReturn(List.of(\n        new AccountBadge(\"badge1\", Instant.ofEpochSecond(10), false),\n        new AccountBadge(\"badge2\", Instant.ofEpochSecond(11), true)));\n\n    // all should be expired\n    assertThat(new AccountIdentityResponseBuilder(account)\n        .clock(TestClock.pinned(Instant.ofEpochSecond(11)))\n        .build().entitlements().badges()).isEmpty();\n\n    // first badge should be expired\n    assertThat(new AccountIdentityResponseBuilder(account).clock(TestClock.pinned(Instant.ofEpochSecond(10))).build()\n        .entitlements()\n        .badges()\n        .stream().map(Entitlements.BadgeEntitlement::id).toList())\n        .containsExactly(\"badge2\");\n\n    // no badges should be expired\n    assertThat(new AccountIdentityResponseBuilder(account).clock(TestClock.pinned(Instant.ofEpochSecond(9))).build()\n        .entitlements()\n        .badges()\n        .stream().map(Entitlements.BadgeEntitlement::id).toList())\n        .containsExactly(\"badge1\", \"badge2\");\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/ArchiveControllerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.client.WebTarget;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredential;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;\nimport org.whispersystems.textsecuregcm.backup.BackupException;\nimport org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;\nimport org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.BackupNotFoundException;\nimport org.whispersystems.textsecuregcm.backup.BackupPermissionException;\nimport org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;\nimport org.whispersystems.textsecuregcm.backup.CopyResult;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachment;\nimport org.whispersystems.textsecuregcm.mappers.BackupExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.EnumMapUtil;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.publisher.Flux;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class ArchiveControllerTest {\n\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private static final BackupAuthManager backupAuthManager = mock(BackupAuthManager.class);\n  private static final BackupManager backupManager = mock(BackupManager.class);\n  private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(Clock.systemUTC());\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new CompletionExceptionMapper())\n      .addResource(new GrpcStatusRuntimeExceptionMapper())\n      .addResource(new BackupExceptionMapper())\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new ArchiveController(accountsManager, backupAuthManager, backupManager, new BackupMetrics()))\n      .build();\n\n  private final UUID aci = UUID.randomUUID();\n  private final byte[] messagesBackupKey = TestRandomUtil.nextBytes(32);\n  private final byte[] mediaBackupKey = TestRandomUtil.nextBytes(32);\n\n  @BeforeEach\n  public void setUp() {\n    reset(backupAuthManager);\n    reset(backupManager);\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID))\n        .thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n  }\n\n  @ParameterizedTest\n  @CsvSource(textBlock = \"\"\"\n      GET,    v1/archives/auth/read,\n      GET,    v1/archives/auth/svrb,\n      GET,    v1/archives/,\n      GET,    v1/archives/upload/form,\n      GET,    v1/archives/media/upload/form,\n      POST,   v1/archives/,\n      PUT,    v1/archives/keys, '{\"backupIdPublicKey\": \"aaaaa\"}'\n      DELETE, v1/archives,\n      PUT,    v1/archives/media, '{\n        \"sourceAttachment\": {\"cdn\": 3, \"key\": \"abc\"},\n        \"objectLength\": 10,\n        \"mediaId\": \"aaaaaaaaaaaaaaaaaaaa\",\n        \"hmacKey\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n        \"encryptionKey\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n        \"iv\": \"aaaaaaaaaaaaaaaaaaaaaa\"\n      }'\n      PUT,    v1/archives/media/batch, '{\"items\": [{\n        \"sourceAttachment\": {\"cdn\": 3, \"key\": \"abc\"},\n        \"objectLength\": 10,\n        \"mediaId\": \"aaaaaaaaaaaaaaaaaaaa\",\n        \"hmacKey\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n        \"encryptionKey\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n        \"iv\": \"aaaaaaaaaaaaaaaaaaaaaa\"\n      }]}'\n      \"\"\")\n  public void anonymousAuthOnly(final String method, final String path, final String body)\n      throws VerificationFailedException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(path)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\",\n            Base64.getEncoder().encodeToString(\"abc\".getBytes(StandardCharsets.UTF_8)));\n\n    final Response response;\n    if (body != null) {\n      response = request.method(method, Entity.entity(body, MediaType.APPLICATION_JSON_TYPE));\n    } else {\n      response = request.method(method);\n    }\n    assertThat(response.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());\n  }\n\n  @Test\n  public void setBackupId() throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/backupid\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new ArchiveController.SetBackupIdRequest(\n                backupAuthTestUtil.getRequest(messagesBackupKey, aci),\n                backupAuthTestUtil.getRequest(mediaBackupKey, aci)),\n            MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(204);\n\n    verify(backupAuthManager).commitBackupId(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE,\n        Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),\n        Optional.of(backupAuthTestUtil.getRequest(mediaBackupKey, aci)));\n  }\n\n  @Test\n  public void setBackupIdPartial()\n      throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/backupid\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(\"\"\"\n            {\"messagesBackupAuthCredentialRequest\": \"%s\"}\n            \"\"\".formatted(Base64.getEncoder().encodeToString(backupAuthTestUtil.getRequest(messagesBackupKey, aci).serialize()))));\n\n    assertThat(response.getStatus()).isEqualTo(204);\n\n    verify(backupAuthManager).commitBackupId(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE,\n        Optional.of(backupAuthTestUtil.getRequest(messagesBackupKey, aci)),\n        Optional.empty());\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"true, 0\",\n      \"false, 1\",\n      \"false, 12345\"\n  })\n  public void backupIdLimits(boolean hasPermits, long waitSeconds) {\n    when(backupAuthManager.checkBackupIdRotationLimit(any()))\n        .thenReturn(new BackupAuthManager.BackupIdRotationLimit(hasPermits, Duration.ofSeconds(waitSeconds)));\n\n    final ArchiveController.BackupIdLimitResponse response = resources.getJerseyTest()\n        .target(\"v1/archives/backupid/limits\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(ArchiveController.BackupIdLimitResponse.class);\n\n    assertThat(response.hasPermitsRemaining()).isEqualTo(hasPermits);\n    assertThat(response.retryAfterSeconds()).isEqualTo(waitSeconds);\n  }\n\n  @Test\n  public void redeemReceipt() throws InvalidInputException, VerificationFailedException {\n    final ServerSecretParams params = ServerSecretParams.generate();\n    final ServerZkReceiptOperations serverOps = new ServerZkReceiptOperations(params);\n    final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(params.getPublicParams());\n    final ReceiptCredentialRequestContext rcrc = clientOps\n        .createReceiptCredentialRequestContext(new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE)));\n    final ReceiptCredentialResponse rcr = serverOps.issueReceiptCredential(rcrc.getRequest(), 0L, 3L);\n    final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, rcr);\n    final ReceiptCredentialPresentation presentation = clientOps.createReceiptCredentialPresentation(receiptCredential);\n\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/redeem-receipt\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(\"\"\"\n            {\"receiptCredentialPresentation\": \"%s\"}\n            \"\"\".formatted(Base64.getEncoder().encodeToString(presentation.serialize()))));\n    assertThat(response.getStatus()).isEqualTo(204);\n  }\n\n\n  @Test\n  public void setBadPublicKey() throws VerificationFailedException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/keys\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.entity(\"\"\"\n            {\"backupIdPublicKey\": \"aaaaa\"}\n            \"\"\", MediaType.APPLICATION_JSON_TYPE));\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  public void setMissingPublicKey() throws VerificationFailedException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/keys\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.entity(\"{}\", MediaType.APPLICATION_JSON_TYPE));\n    assertThat(response.getStatus()).isEqualTo(422);\n  }\n\n  @Test\n  public void setPublicKey() throws VerificationFailedException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/keys\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.entity(\n            new ArchiveController.SetPublicKeyRequest(ECKeyPair.generate().getPublicKey()),\n            MediaType.APPLICATION_JSON_TYPE));\n    assertThat(response.getStatus()).isEqualTo(204);\n  }\n\n\n  @ParameterizedTest\n  @CsvSource(textBlock = \"\"\"\n      '{\"messagesBackupAuthCredentialRequest\": \"aaa\", \"mediaBackupAuthCredentialRequest\": \"aaa\"}', 400\n      '{\"messagesBackupAuthCredentialRequest\": \"\", \"mediaBackupAuthCredentialRequest\": \"\"}', 400\n      \"\"\")\n  public void setBackupIdInvalid(final String requestBody, final int expectedStatus) {\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/backupid\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(requestBody, MediaType.APPLICATION_JSON_TYPE));\n    assertThat(response.getStatus()).isEqualTo(expectedStatus);\n  }\n\n  public static Stream<Arguments> setBackupIdException() {\n    return Stream.of(\n        Arguments.of(new RateLimitExceededException(null), 429),\n        Arguments.of(new BackupInvalidArgumentException(\"test\"), 400),\n        Arguments.of(new BackupPermissionException(\"test\"), 403)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void setBackupIdException(final Exception ex, final int expectedStatus)\n      throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    doThrow(ex).when(backupAuthManager).commitBackupId(any(), any(), any(), any());\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/backupid\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new ArchiveController.SetBackupIdRequest(\n                backupAuthTestUtil.getRequest(messagesBackupKey, aci),\n                backupAuthTestUtil.getRequest(mediaBackupKey, aci)),\n            MediaType.APPLICATION_JSON_TYPE));\n    assertThat(response.getStatus()).isEqualTo(expectedStatus);\n  }\n\n  @Test\n  public void getCredentials() throws BackupNotFoundException {\n    final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    final Instant end = start.plus(Duration.ofDays(1));\n    final RedemptionRange expectedRange = RedemptionRange.inclusive(Clock.systemUTC(), start, end);\n\n    final Map<BackupCredentialType, List<BackupAuthManager.Credential>> expectedCredentialsByType =\n        EnumMapUtil.toEnumMap(BackupCredentialType.class, credentialType -> backupAuthTestUtil.getCredentials(\n            BackupLevel.PAID, backupAuthTestUtil.getRequest(messagesBackupKey, aci), credentialType, start, end));\n\n    when(backupAuthManager.getBackupAuthCredentials(any(), eq(expectedRange)))\n        .thenReturn(expectedCredentialsByType);\n\n    final ArchiveController.BackupAuthCredentialsResponse credentialResponse = resources.getJerseyTest()\n        .target(\"v1/archives/auth\")\n        .queryParam(\"redemptionStartSeconds\", start.getEpochSecond())\n        .queryParam(\"redemptionEndSeconds\", end.getEpochSecond())\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(ArchiveController.BackupAuthCredentialsResponse.class);\n\n    expectedCredentialsByType.forEach((libsignalCredentialType, expectedCredentials) -> {\n      final ArchiveController.BackupAuthCredentialsResponse.CredentialType credentialType =\n          ArchiveController.BackupAuthCredentialsResponse.CredentialType.fromLibsignalType(libsignalCredentialType);\n      assertThat(credentialResponse.credentials().get(credentialType)).size().isEqualTo(expectedCredentials.size());\n      assertThat(credentialResponse.credentials().get(credentialType).getFirst().redemptionTime())\n          .isEqualTo(start.getEpochSecond());\n\n      for (int i = 0; i < expectedCredentials.size(); i++) {\n        assertThat(credentialResponse.credentials().get(credentialType).get(i).redemptionTime())\n            .isEqualTo(expectedCredentials.get(i).redemptionTime().getEpochSecond());\n\n        assertThat(credentialResponse.credentials().get(credentialType).get(i).credential())\n            .isEqualTo(expectedCredentials.get(i).credential().serialize());\n      }\n    });\n  }\n\n  public enum BadCredentialsType {MISSING_START, MISSING_END, MISSING_BOTH}\n\n  @ParameterizedTest\n  @EnumSource\n  public void getCredentialsBadInput(final BadCredentialsType badCredentialsType) {\n    WebTarget builder = resources.getJerseyTest()\n        .target(\"v1/archives/auth\");\n\n    final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    final Instant end = start.plus(Duration.ofDays(1));\n    if (badCredentialsType != BadCredentialsType.MISSING_BOTH\n        && badCredentialsType != BadCredentialsType.MISSING_START) {\n      builder = builder.queryParam(\"redemptionStartSeconds\", start.getEpochSecond());\n    }\n    if (badCredentialsType != BadCredentialsType.MISSING_BOTH && badCredentialsType != BadCredentialsType.MISSING_END) {\n      builder = builder.queryParam(\"redemptionEndSeconds\", end.getEpochSecond());\n    }\n    final Response response = builder\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .method(\"GET\");\n    assertThat(response.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());\n  }\n\n  @Test\n  public void getBackupInfo() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    when(backupManager.backupInfo(any()))\n        .thenReturn(new BackupManager.BackupInfo(1, \"myBackupDir\", \"myMediaDir\", \"filename\", Optional.empty()));\n    final ArchiveController.BackupInfoResponse response = resources.getJerseyTest()\n        .target(\"v1/archives\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get(ArchiveController.BackupInfoResponse.class);\n    assertThat(response.backupDir()).isEqualTo(\"myBackupDir\");\n    assertThat(response.backupName()).isEqualTo(\"filename\");\n    assertThat(response.cdn()).isEqualTo(1);\n    assertThat(response.usedSpace()).isEqualTo(0L);\n  }\n\n  @Test\n  public void putMediaBatchSuccess() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    final byte[][] mediaIds = new byte[][]{TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};\n    when(backupManager.copyToBackup(any()))\n        .thenReturn(Flux.just(\n            new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[0], 1),\n            new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[1], 1)));\n\n    final Response r = resources.getJerseyTest()\n        .target(\"v1/archives/media/batch\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.json(new ArchiveController.CopyMediaBatchRequest(List.of(\n            new ArchiveController.CopyMediaRequest(\n                new RemoteAttachment(3, \"abc\"),\n                100,\n                mediaIds[0],\n                TestRandomUtil.nextBytes(32),\n                TestRandomUtil.nextBytes(32)),\n\n            new ArchiveController.CopyMediaRequest(\n                new RemoteAttachment(3, \"def\"),\n                200,\n                mediaIds[1],\n                TestRandomUtil.nextBytes(32),\n                TestRandomUtil.nextBytes(32))\n        ))));\n    assertThat(r.getStatus()).isEqualTo(207);\n    final ArchiveController.CopyMediaBatchResponse copyResponse = r.readEntity(\n        ArchiveController.CopyMediaBatchResponse.class);\n    assertThat(copyResponse.responses()).hasSize(2);\n    for (int i = 0; i < 2; i++) {\n      final ArchiveController.CopyMediaBatchResponse.Entry response = copyResponse.responses().get(i);\n      assertThat(response.cdn()).isEqualTo(1);\n      assertThat(response.mediaId()).isEqualTo(mediaIds[i]);\n      assertThat(response.status()).isEqualTo(200);\n    }\n  }\n\n  @Test\n  public void putMediaBatchPartialFailure() throws VerificationFailedException, BackupException {\n\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n\n    final byte[][] mediaIds = IntStream.range(0, 4).mapToObj(i -> TestRandomUtil.nextBytes(15)).toArray(byte[][]::new);\n    when(backupManager.copyToBackup(any()))\n        .thenReturn(Flux.just(\n            new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[0], 1),\n            new CopyResult(CopyResult.Outcome.SOURCE_NOT_FOUND, mediaIds[1], null),\n            new CopyResult(CopyResult.Outcome.SOURCE_WRONG_LENGTH, mediaIds[2], null),\n            new CopyResult(CopyResult.Outcome.OUT_OF_QUOTA, mediaIds[3], null)));\n\n    final List<ArchiveController.CopyMediaRequest> copyRequests = Arrays.stream(mediaIds)\n        .map(mediaId -> new ArchiveController.CopyMediaRequest(\n            new RemoteAttachment(3, \"abc\"),\n            100,\n            mediaId,\n            TestRandomUtil.nextBytes(32),\n            TestRandomUtil.nextBytes(32))\n        ).toList();\n\n    Response r = resources.getJerseyTest()\n        .target(\"v1/archives/media/batch\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.json(new ArchiveController.CopyMediaBatchRequest(copyRequests)));\n    assertThat(r.getStatus()).isEqualTo(207);\n    final ArchiveController.CopyMediaBatchResponse copyResponse = r.readEntity(\n        ArchiveController.CopyMediaBatchResponse.class);\n\n    assertThat(copyResponse.responses()).hasSize(4);\n\n    final ArchiveController.CopyMediaBatchResponse.Entry r1 = copyResponse.responses().getFirst();\n    assertThat(r1.cdn()).isEqualTo(1);\n    assertThat(r1.mediaId()).isEqualTo(mediaIds[0]);\n    assertThat(r1.status()).isEqualTo(200);\n\n    final ArchiveController.CopyMediaBatchResponse.Entry r2 = copyResponse.responses().get(1);\n    assertThat(r2.mediaId()).isEqualTo(mediaIds[1]);\n    assertThat(r2.status()).isEqualTo(410);\n    assertThat(r2.failureReason()).isNotBlank();\n\n    final ArchiveController.CopyMediaBatchResponse.Entry r3 = copyResponse.responses().get(2);\n    assertThat(r3.mediaId()).isEqualTo(mediaIds[2]);\n    assertThat(r3.status()).isEqualTo(400);\n    assertThat(r3.failureReason()).isNotBlank();\n\n    final ArchiveController.CopyMediaBatchResponse.Entry r4 = copyResponse.responses().get(3);\n    assertThat(r4.mediaId()).isEqualTo(mediaIds[3]);\n    assertThat(r4.status()).isEqualTo(413);\n    assertThat(r4.failureReason()).isNotBlank();\n  }\n\n\n  @Test\n  public void copyMediaWithNegativeLength() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    final byte[][] mediaIds = new byte[][]{TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};\n    final Response r = resources.getJerseyTest()\n        .target(\"v1/archives/media/batch\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.json(new ArchiveController.CopyMediaBatchRequest(List.of(\n            new ArchiveController.CopyMediaRequest(\n                new RemoteAttachment(3, \"abc\"),\n                1,\n                mediaIds[0],\n                TestRandomUtil.nextBytes(32),\n                TestRandomUtil.nextBytes(32)),\n\n            new ArchiveController.CopyMediaRequest(\n                new RemoteAttachment(3, \"def\"),\n                -1,\n                mediaIds[1],\n                TestRandomUtil.nextBytes(32),\n                TestRandomUtil.nextBytes(32))\n        ))));\n    assertThat(r.getStatus()).isEqualTo(422);\n  }\n\n  @CartesianTest\n  public void list(\n      @CartesianTest.Values(booleans = {true, false}) final boolean cursorProvided,\n      @CartesianTest.Values(booleans = {true, false}) final boolean cursorReturned)\n      throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n\n    final byte[] mediaId = TestRandomUtil.nextBytes(15);\n    final Optional<String> expectedCursor = cursorProvided ? Optional.of(\"myCursor\") : Optional.empty();\n    final Optional<String> returnedCursor = cursorReturned ? Optional.of(\"newCursor\") : Optional.empty();\n\n    when(backupManager.list(any(), eq(expectedCursor), eq(17)))\n        .thenReturn(new BackupManager.ListMediaResult(\n            List.of(new BackupManager.StorageDescriptorWithLength(1, mediaId, 100)),\n            returnedCursor\n        ));\n\n    WebTarget target = resources.getJerseyTest()\n        .target(\"v1/archives/media/\")\n        .queryParam(\"limit\", 17);\n    if (cursorProvided) {\n      target = target.queryParam(\"cursor\", \"myCursor\");\n    }\n    final ArchiveController.ListResponse response = target\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get(ArchiveController.ListResponse.class);\n\n    assertThat(response.storedMediaObjects()).hasSize(1);\n    assertThat(response.storedMediaObjects().getFirst().objectLength()).isEqualTo(100);\n    assertThat(response.storedMediaObjects().getFirst().mediaId()).isEqualTo(mediaId);\n    assertThat(response.cursor()).isEqualTo(returnedCursor.orElse(null));\n  }\n\n  @Test\n  public void delete() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(BackupLevel.PAID,\n        messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n\n    final ArchiveController.DeleteMedia deleteRequest = new ArchiveController.DeleteMedia(\n        IntStream\n            .range(0, 100)\n            .mapToObj(i -> new ArchiveController.DeleteMedia.MediaToDelete(3, TestRandomUtil.nextBytes(15)))\n            .toList());\n\n    when(backupManager.deleteMedia(any(), any()))\n        .thenReturn(Flux.fromStream(deleteRequest.mediaToDelete().stream()\n            .map(m -> new BackupManager.StorageDescriptor(m.cdn(), m.mediaId()))));\n\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/media/delete\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .post(Entity.json(deleteRequest));\n    assertThat(response.getStatus()).isEqualTo(204);\n  }\n\n\n  static Stream<Arguments> messagesUploadForm() {\n    return Stream.of(\n        Arguments.of(Optional.empty(), true),\n        Arguments.of(Optional.of(BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE), true),\n        Arguments.of(Optional.of(BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE + 1), false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void messagesUploadForm(Optional<Long> uploadLength, boolean expectSuccess) throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation =\n        backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    when(backupManager.createMessageBackupUploadDescriptor(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"abc\", Map.of(\"k\", \"v\"), \"example.org\"));\n\n    final WebTarget builder = resources.getJerseyTest().target(\"v1/archives/upload/form\");\n    final Response response = uploadLength\n        .map(length -> builder.queryParam(\"uploadLength\", length))\n        .orElse(builder)\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get();\n    if (expectSuccess) {\n      assertThat(response.getStatus()).isEqualTo(200);\n      ArchiveController.UploadDescriptorResponse desc = response.readEntity(ArchiveController.UploadDescriptorResponse.class);\n      assertThat(desc.cdn()).isEqualTo(3);\n      assertThat(desc.key()).isEqualTo(\"abc\");\n      assertThat(desc.headers()).containsExactlyEntriesOf(Map.of(\"k\", \"v\"));\n      assertThat(desc.signedUploadLocation()).isEqualTo(\"example.org\");\n    } else {\n      assertThat(response.getStatus()).isEqualTo(413);\n    }\n  }\n\n  @Test\n  public void mediaUploadForm() throws VerificationFailedException, BackupException, RateLimitExceededException {\n    final BackupAuthCredentialPresentation presentation =\n        backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"abc\", Map.of(\"k\", \"v\"), \"example.org\"));\n    final ArchiveController.UploadDescriptorResponse desc = resources.getJerseyTest()\n        .target(\"v1/archives/media/upload/form\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get(ArchiveController.UploadDescriptorResponse.class);\n    assertThat(desc.cdn()).isEqualTo(3);\n    assertThat(desc.key()).isEqualTo(\"abc\");\n    assertThat(desc.headers()).containsExactlyEntriesOf(Map.of(\"k\", \"v\"));\n    assertThat(desc.signedUploadLocation()).isEqualTo(\"example.org\");\n\n    // rate limit\n    when(backupManager.createTemporaryAttachmentUploadDescriptor(any())).thenThrow(new RateLimitExceededException(null));\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/archives/media/upload/form\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get();\n    assertThat(response.getStatus()).isEqualTo(429);\n  }\n\n  @Test\n  public void readAuth() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation =\n        backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    when(backupManager.generateReadAuth(any(), eq(3))).thenReturn(Map.of(\"key\", \"value\"));\n    final ArchiveController.ReadAuthResponse response = resources.getJerseyTest()\n        .target(\"v1/archives/auth/read\")\n        .queryParam(\"cdn\", 3)\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get(ArchiveController.ReadAuthResponse.class);\n    assertThat(response.headers()).containsExactlyEntriesOf(Map.of(\"key\", \"value\"));\n  }\n\n\n  @Test\n  public void svrbAuth() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation =\n        backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    final ExternalServiceCredentials credentials = new ExternalServiceCredentials(\"username\", \"password\");\n    when(backupManager.generateSvrbAuth(any())).thenReturn(credentials);\n    final ExternalServiceCredentials response = resources.getJerseyTest()\n        .target(\"v1/archives/auth/svrb\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get(ExternalServiceCredentials.class);\n    assertThat(response).isEqualTo(credentials);\n  }\n\n  @Test\n  public void readAuthInvalidParam() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation =\n        backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    Response response = resources.getJerseyTest()\n        .target(\"v1/archives/auth/read\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get();\n    assertThat(response.getStatus()).isEqualTo(400);\n\n    response = resources.getJerseyTest()\n        .target(\"v1/archives/auth/read\")\n        .queryParam(\"abc\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .get();\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  public void deleteEntireBackup() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation =\n        backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    Response response = resources.getJerseyTest()\n        .target(\"v1/archives/\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .delete();\n    assertThat(response.getStatus()).isEqualTo(204);\n  }\n\n  @Test\n  public void invalidSourceAttachmentKey() throws VerificationFailedException, BackupException {\n    final BackupAuthCredentialPresentation presentation = backupAuthTestUtil.getPresentation(\n        BackupLevel.PAID, messagesBackupKey, aci);\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    final Response r = resources.getJerseyTest()\n        .target(\"v1/archives/media\")\n        .request()\n        .header(\"X-Signal-ZK-Auth\", Base64.getEncoder().encodeToString(presentation.serialize()))\n        .header(\"X-Signal-ZK-Auth-Signature\", \"aaa\")\n        .put(Entity.json(new ArchiveController.CopyMediaRequest(\n            new RemoteAttachment(3, \"invalid/urlBase64\"),\n            100,\n            TestRandomUtil.nextBytes(15),\n            TestRandomUtil.nextBytes(32),\n            TestRandomUtil.nextBytes(32))));\n    assertThat(r.getStatus()).isEqualTo(422);\n  }\n\n  private static AuthenticatedBackupUser backupUser(final byte[] backupId, final BackupCredentialType credentialType, final BackupLevel backupLevel) {\n    return new AuthenticatedBackupUser(backupId, credentialType, backupLevel, \"myBackupDir\", \"myMediaDir\", null);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/AttachmentControllerV4Test.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport java.io.IOException;\nimport java.net.MalformedURLException;\nimport java.net.URI;\nimport java.net.URL;\nimport java.net.URLDecoder;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.KeyPair;\nimport java.security.KeyPairGenerator;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.Map;\nimport org.assertj.core.api.Assertions;\nimport org.assertj.core.api.InstanceOfAssertFactories;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusConfiguration;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.entities.AttachmentDescriptorV3;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentUtil;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass AttachmentControllerV4Test {\n\n  private static final RateLimiter RATE_LIMITER = mock(RateLimiter.class);\n\n  private static final RateLimiters RATE_LIMITERS = MockUtils.buildMock(RateLimiters.class, rateLimiters ->\n      when(rateLimiters.getAttachmentLimiter()).thenReturn(RATE_LIMITER));\n\n\n  private static final String CDN3_ENABLED_CREDS = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD);\n  private static final String CDN3_DISABLED_CREDS = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO);\n  private static final ExperimentEnrollmentManager EXPERIMENT_MANAGER = MockUtils.buildMock(ExperimentEnrollmentManager.class, mgr -> {\n    when(mgr.isEnrolled(AuthHelper.VALID_UUID, AttachmentUtil.CDN3_EXPERIMENT_NAME)).thenReturn(true);\n    when(mgr.isEnrolled(AuthHelper.VALID_UUID_TWO, AttachmentUtil.CDN3_EXPERIMENT_NAME)).thenReturn(false);\n  });\n\n  private static final byte[] TUS_SECRET = TestRandomUtil.nextBytes(32);\n  private static final String TUS_URL = \"https://example.com/uploads\";\n\n  public static final String RSA_PRIVATE_KEY_PEM;\n\n  static {\n    try {\n      final KeyPairGenerator  keyPairGenerator = KeyPairGenerator.getInstance(\"RSA\");\n      keyPairGenerator.initialize(1024);\n      final KeyPair           keyPair          = keyPairGenerator.generateKeyPair();\n\n      RSA_PRIVATE_KEY_PEM = \"-----BEGIN PRIVATE KEY-----\\n\" +\n          Base64.getMimeEncoder().encodeToString(keyPair.getPrivate().getEncoded()) + \"\\n\" +\n          \"-----END PRIVATE KEY-----\";\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n\n  private static final ResourceExtension resources;\n\n  static {\n    try {\n      final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(\"some-cdn.signal.org\",\n          \"signal@example.com\", 1000, \"/attach-here\", RSA_PRIVATE_KEY_PEM);\n      resources = ResourceExtension.builder()\n          .addProvider(AuthHelper.getAuthFilter())\n          .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n          .setMapper(SystemMapper.jsonMapper())\n          .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n          .addProvider(new AttachmentControllerV4(RATE_LIMITERS,\n              gcsAttachmentGenerator,\n              new TusAttachmentGenerator(new TusConfiguration(new SecretBytes(TUS_SECRET), TUS_URL)),\n              EXPERIMENT_MANAGER))\n          .build();\n    } catch (IOException | InvalidKeyException | InvalidKeySpecException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  @Test\n  void testV4TusForm() {\n    AttachmentDescriptorV3 descriptor = resources.getJerseyTest()\n        .target(\"/v4/attachments/form/upload\")\n        .request()\n        .header(\"Authorization\", CDN3_ENABLED_CREDS)\n        .get(AttachmentDescriptorV3.class);\n    assertThat(descriptor.cdn()).isEqualTo(3);\n    assertThat(descriptor.key()).isNotBlank();\n    assertThat(descriptor.signedUploadLocation()).isEqualTo(TUS_URL + \"/\" + \"attachments\");\n    final String filenameb64 = descriptor.headers().get(\"Upload-Metadata\").split(\" \")[1];\n    final String filename = new String(Base64.getDecoder().decode(filenameb64));\n    assertThat(descriptor.key()).isEqualTo(filename);\n  }\n\n  @Test\n  void testV4GcsForm() throws MalformedURLException {\n    AttachmentDescriptorV3 descriptor = resources.getJerseyTest()\n        .target(\"/v4/attachments/form/upload\")\n        .request()\n        .header(\"Authorization\", CDN3_DISABLED_CREDS)\n        .get(AttachmentDescriptorV3.class);\n    assertThat(descriptor.cdn()).isEqualTo(2);\n    assertValidCdn2Response(descriptor);\n  }\n\n  private static void assertValidCdn2Response(final AttachmentDescriptorV3 descriptor) throws MalformedURLException {\n    assertThat(descriptor.key()).isNotBlank();\n    assertThat(descriptor.cdn()).isEqualTo(2);\n    assertThat(descriptor.headers()).hasSize(3);\n    assertThat(descriptor.headers()).extractingByKey(\"host\").isEqualTo(\"some-cdn.signal.org\");\n    assertThat(descriptor.headers()).extractingByKey(\"x-goog-resumable\").isEqualTo(\"start\");\n    assertThat(descriptor.headers()).extractingByKey(\"x-goog-content-length-range\").isEqualTo(\"1,1000\");\n    assertThat(descriptor.signedUploadLocation()).isNotEmpty();\n    assertThat(descriptor.signedUploadLocation()).contains(\"X-Goog-Signature\");\n    //noinspection ResultOfMethodCallIgnored\n    assertThatNoException().isThrownBy(() -> URI.create(descriptor.signedUploadLocation()));\n\n    final URL signedUploadLocation = URI.create(descriptor.signedUploadLocation()).toURL();\n    assertThat(signedUploadLocation.getHost()).isEqualTo(\"some-cdn.signal.org\");\n    assertThat(signedUploadLocation.getPath()).startsWith(\"/attach-here/\");\n    final Map<String, String> queryParamMap = new HashMap<>();\n    final String[] queryTerms = signedUploadLocation.getQuery().split(\"&\");\n    for (final String queryTerm : queryTerms) {\n      final String[] keyValueArray = queryTerm.split(\"=\", 2);\n      queryParamMap.put(\n          URLDecoder.decode(keyValueArray[0], StandardCharsets.UTF_8),\n          URLDecoder.decode(keyValueArray[1], StandardCharsets.UTF_8));\n    }\n\n    assertThat(queryParamMap).extractingByKey(\"X-Goog-Algorithm\").isEqualTo(\"GOOG4-RSA-SHA256\");\n    assertThat(queryParamMap).extractingByKey(\"X-Goog-Expires\").isEqualTo(\"90000\");\n    assertThat(queryParamMap).extractingByKey(\"X-Goog-SignedHeaders\").isEqualTo(\"host;x-goog-content-length-range;x-goog-resumable\");\n    assertThat(queryParamMap).extractingByKey(\"X-Goog-Date\", Assertions.as(InstanceOfAssertFactories.STRING)).isNotEmpty();\n\n    final String credential = queryParamMap.get(\"X-Goog-Credential\");\n    String[] credentialParts = credential.split(\"/\");\n    assertThat(credentialParts).hasSize(5);\n    assertThat(credentialParts[0]).isEqualTo(\"signal@example.com\");\n    assertThat(credentialParts[2]).isEqualTo(\"auto\");\n    assertThat(credentialParts[3]).isEqualTo(\"storage\");\n    assertThat(credentialParts[4]).isEqualTo(\"goog4_request\");\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallLinkControllerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.Response;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.signal.libsignal.protocol.util.Hex;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.calllinks.CreateCallLinkCredentialRequestContext;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.GetCreateCallLinkCredentialsRequest;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class CallLinkControllerTest {\n  private static final GenericServerSecretParams genericServerSecretParams = GenericServerSecretParams.generate();\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter createCallLinkLimiter = mock(RateLimiter.class);\n  private static final byte[] roomId = Hex.fromStringCondensedAssert(\"c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7\");\n  private static final CreateCallLinkCredentialRequestContext createCallLinkRequestContext = CreateCallLinkCredentialRequestContext.forRoom(roomId);\n  private static final byte[] createCallLinkRequestSerialized = createCallLinkRequestContext.getRequest().serialize();\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new CallLinkController(rateLimiters, genericServerSecretParams))\n      .build();\n\n  @BeforeEach\n  void setup() {\n    when(rateLimiters.getCreateCallLinkLimiter()).thenReturn(createCallLinkLimiter);\n  }\n\n  @Test\n  void testGetCreateAuth() {\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(new GetCreateCallLinkCredentialsRequest(createCallLinkRequestSerialized)))) {\n      assertThat(response.getStatus()).isEqualTo(200);\n    }\n  }\n\n  @Test\n  void testGetCreateAuthInvalidInput() {\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(new GetCreateCallLinkCredentialsRequest(new byte[10])))) {\n      assertThat(response.getStatus()).isEqualTo(400);\n    }\n  }\n\n  @Test\n  void testGetCreateAuthInvalidAuth() {\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.INVALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(new GetCreateCallLinkCredentialsRequest(createCallLinkRequestSerialized)))) {\n      assertThat(response.getStatus()).isEqualTo(401);\n    }\n  }\n\n  @Test\n  void testGetCreateAuthInvalidRequest() {\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(\"\"))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testGetCreateAuthInvalidInputEmptyRequestBody() {\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(\"{}\"))) {\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testGetCreateAuthInvalidInputEmptyField() {\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(\"{\\\"createCallLinkCredentialRequest\\\": \\\"\\\"}\"))) {\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testGetCreateAuthRatelimited() throws RateLimitExceededException{\n    doThrow(new RateLimitExceededException(null))\n        .when(createCallLinkLimiter).validate(AuthHelper.VALID_UUID);\n\n    try (Response response = resources.getJerseyTest()\n        .target(\"/v1/call-link/create-auth\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.json(new GetCreateCallLinkCredentialsRequest(createCallLinkRequestSerialized)))) {\n\n      assertThat(response.getStatus()).isEqualTo(429);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallQualitySurveyControllerTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.CallQualityInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\nimport java.util.List;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass CallQualitySurveyControllerTest {\n\n  private static final CallQualitySurveyManager CALL_QUALITY_SURVEY_MANAGER = mock(CallQualitySurveyManager.class);\n\n  private static final String USER_AGENT = \"Signal-iOS/7.78.0.1041 iOS/18.3.2 libsignal/0.80.3\";\n  private static final String REMOTE_ADDRESS = \"127.0.0.1\";\n\n  private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .addProvider(new TestRemoteAddressFilterProvider(REMOTE_ADDRESS))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new CallQualitySurveyController(CALL_QUALITY_SURVEY_MANAGER))\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    reset(CALL_QUALITY_SURVEY_MANAGER);\n  }\n\n  @Test\n  void submitCallQualitySurvey() throws CallQualityInvalidArgumentsException {\n    final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance();\n\n    try (final Response response = RESOURCE_EXTENSION.getJerseyTest()\n        .target(\"/v1/call_quality_survey\")\n        .request()\n        .header(\"User-Agent\", USER_AGENT)\n        .put(Entity.entity(request.toByteArray(), MediaType.APPLICATION_OCTET_STREAM_TYPE))) {\n\n      assertEquals(204, response.getStatus());\n      verify(CALL_QUALITY_SURVEY_MANAGER).submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT);\n    }\n  }\n\n  @Test\n  void submitCallQualitySurveyAuthenticated() throws CallQualityInvalidArgumentsException {\n    final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance();\n\n    try (final Response response = RESOURCE_EXTENSION.getJerseyTest()\n        .target(\"/v1/call_quality_survey\")\n        .request()\n        .header(\"User-Agent\", USER_AGENT)\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(request.toByteArray(), MediaType.APPLICATION_OCTET_STREAM_TYPE))) {\n\n      assertEquals(403, response.getStatus());\n      verify(CALL_QUALITY_SURVEY_MANAGER, never()).submitCallQualitySurvey(any(), any(), any());\n    }\n  }\n\n  @Test\n  void submitCallQualitySurveyInvalidArgument() throws CallQualityInvalidArgumentsException {\n    final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance();\n\n    doThrow(new CallQualityInvalidArgumentsException(\"test\"))\n        .when(CALL_QUALITY_SURVEY_MANAGER).submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT);\n\n    try (final Response response = RESOURCE_EXTENSION.getJerseyTest()\n        .target(\"/v1/call_quality_survey\")\n        .request()\n        .header(\"User-Agent\", USER_AGENT)\n        .put(Entity.entity(request.toByteArray(), MediaType.APPLICATION_OCTET_STREAM_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/CallRoutingControllerV2Test.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.util.List;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.CloudflareTurnCredentialsManager;\nimport org.whispersystems.textsecuregcm.auth.TurnToken;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass CallRoutingControllerV2Test {\n\n  private static final String GET_CALL_RELAYS_PATH = \"v2/calling/relays\";\n  private static final String REMOTE_ADDRESS = \"123.123.123.1\";\n  private static final TurnToken CLOUDFLARE_TURN_TOKEN = new TurnToken(\n      \"ABC\",\n      \"XYZ\",\n      43_200,\n      List.of(\"turn:cloudflare.example.com:3478?transport=udp\"),\n      null,\n      \"cf.example.com\");\n\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter getCallEndpointLimiter = mock(RateLimiter.class);\n  private static final CloudflareTurnCredentialsManager cloudflareTurnCredentialsManager =\n      mock(CloudflareTurnCredentialsManager.class);\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .addProvider(new TestRemoteAddressFilterProvider(REMOTE_ADDRESS))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new CallRoutingControllerV2(rateLimiters, cloudflareTurnCredentialsManager))\n      .build();\n\n  @BeforeEach\n  void setup() {\n    when(rateLimiters.getCallEndpointLimiter()).thenReturn(getCallEndpointLimiter);\n  }\n\n  @AfterEach\n  void tearDown() {\n    reset(rateLimiters, getCallEndpointLimiter);\n  }\n\n  @Test\n  void testGetRelaysBothRouting() throws IOException {\n    when(cloudflareTurnCredentialsManager.retrieveFromCloudflare()).thenReturn(CLOUDFLARE_TURN_TOKEN);\n\n    try (final Response rawResponse = resources.getJerseyTest()\n        .target(GET_CALL_RELAYS_PATH)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertThat(rawResponse.getStatus()).isEqualTo(200);\n\n      assertThat(rawResponse.readEntity(GetCallingRelaysResponse.class).relays())\n          .isEqualTo(List.of(CLOUDFLARE_TURN_TOKEN));\n    }\n  }\n\n  @Test\n  void testGetRelaysRateLimited() throws RateLimitExceededException {\n    doThrow(new RateLimitExceededException(null))\n        .when(getCallEndpointLimiter).validate(AuthHelper.VALID_UUID);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(GET_CALL_RELAYS_PATH)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertThat(response.getStatus()).isEqualTo(429);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/CertificateControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Base64;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Optional;\nimport org.apache.commons.lang3.StringUtils;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.protocol.ecc.ECPrivateKey;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.zkgroup.GenericServerSecretParams;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;\nimport org.signal.libsignal.zkgroup.auth.ClientZkAuthOperations;\nimport org.signal.libsignal.zkgroup.auth.ServerZkAuthOperations;\nimport org.signal.libsignal.zkgroup.calllinks.CallLinkAuthCredentialResponse;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.CertificateGenerator;\nimport org.whispersystems.textsecuregcm.entities.DeliveryCertificate;\nimport org.whispersystems.textsecuregcm.entities.GroupCredentials;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.SenderCertificate;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.ServerCertificate;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass CertificateControllerTest {\n\n  private static final ECPublicKey CA_PUBLIC_KEY;\n  private static final byte[] SIGNING_CERTIFICATE_DATA;\n  private static final CertificateGenerator CERTIFICATE_GENERATOR;\n  private static final ServerCertificate.Certificate SIGNING_CERTIFICATE;\n\n  private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();\n\n  private static final GenericServerSecretParams genericServerSecretParams = GenericServerSecretParams.generate();\n\n  private static final ServerZkAuthOperations SERVER_ZK_AUTH_OPERATIONS;\n  private static final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());\n\n  private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class);\n\n  static {\n    try {\n      CA_PUBLIC_KEY = new ECPrivateKey(Base64.getDecoder().decode(\"EO3Mnf0kfVlVnwSaqPoQnAxhnnGL1JTdXqktCKEe9Eo=\"))\n          .publicKey();\n      SIGNING_CERTIFICATE_DATA = Base64.getDecoder().decode(\n          \"CiUIDBIhBbTz4h1My+tt+vw+TVscgUe/DeHS0W02tPWAWbTO2xc3EkD+go4bJnU0AcnFfbOLKoiBfCzouZtDYMOVi69rE7r4U9cXREEqOkUmU2WJBjykAxWPCcSTmVTYHDw7hkSp/puG\");\n      final ECPrivateKey signingKey = new ECPrivateKey(Base64.getDecoder().decode(\"ABOxG29xrfq4E7IrW11Eg7+HBbtba9iiS0500YoBjn4=\"));\n\n      CERTIFICATE_GENERATOR = new CertificateGenerator(SIGNING_CERTIFICATE_DATA, signingKey, 1, false);\n      SIGNING_CERTIFICATE = ServerCertificate.Certificate.parseFrom(\n          ServerCertificate.parseFrom(SIGNING_CERTIFICATE_DATA).getCertificate());\n      SERVER_ZK_AUTH_OPERATIONS = new ServerZkAuthOperations(SERVER_SECRET_PARAMS);\n    } catch (IOException | InvalidKeyException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new CertificateController(ACCOUNTS_MANAGER, CERTIFICATE_GENERATOR, SERVER_ZK_AUTH_OPERATIONS, genericServerSecretParams, clock))\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    when(ACCOUNTS_MANAGER.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n  }\n\n  @Test\n  void testSigningCertificate() throws Exception {\n    final ServerCertificate fullCertificate = ServerCertificate.parseFrom(SIGNING_CERTIFICATE_DATA);\n    assertTrue(CA_PUBLIC_KEY.verifySignature(fullCertificate.getCertificate().toByteArray(),\n        fullCertificate.getSignature().toByteArray()));\n  }\n\n  @Test\n  void testValidCertificate() throws Exception {\n    DeliveryCertificate certificateObject = resources.getJerseyTest()\n        .target(\"/v1/certificate/delivery\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(DeliveryCertificate.class);\n\n    SenderCertificate certificateHolder = SenderCertificate.parseFrom(certificateObject.getCertificate());\n    SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom(\n        certificateHolder.getCertificate());\n\n    assertEquals(SIGNING_CERTIFICATE.getId(), certificate.getSignerId());\n    ECPublicKey serverPublicKey = new ECPublicKey(SIGNING_CERTIFICATE.getKey().toByteArray());\n\n    assertTrue(serverPublicKey.verifySignature(\n        certificateHolder.getCertificate().toByteArray(), certificateHolder.getSignature().toByteArray()));\n\n    assertEquals(AuthHelper.VALID_NUMBER, certificate.getSenderE164());\n    assertEquals(1L, certificate.getSenderDevice());\n    assertTrue(certificate.hasSenderUuid());\n    assertEquals(UUIDUtil.toByteString(AuthHelper.VALID_UUID), certificate.getSenderUuid());\n    assertArrayEquals(certificate.getIdentityKey().toByteArray(), AuthHelper.VALID_IDENTITY.serialize());\n  }\n\n  @Test\n  void testValidCertificateWithUuid() throws Exception {\n    DeliveryCertificate certificateObject = resources.getJerseyTest()\n        .target(\"/v1/certificate/delivery\")\n        .queryParam(\"includeUuid\", \"true\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(DeliveryCertificate.class);\n\n    SenderCertificate certificateHolder = SenderCertificate.parseFrom(certificateObject.getCertificate());\n    SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom(\n        certificateHolder.getCertificate());\n\n    assertEquals(SIGNING_CERTIFICATE.getId(), certificate.getSignerId());\n    ECPublicKey serverPublicKey = new ECPublicKey(SIGNING_CERTIFICATE.getKey().toByteArray());\n\n    assertTrue(serverPublicKey.verifySignature(certificateHolder.getCertificate().toByteArray(),\n        certificateHolder.getSignature().toByteArray()));\n\n    assertEquals(AuthHelper.VALID_NUMBER, certificate.getSenderE164());\n    assertEquals(1L, certificate.getSenderDevice());\n    assertEquals(certificate.getSenderUuid(), UUIDUtil.toByteString(AuthHelper.VALID_UUID));\n    assertArrayEquals(certificate.getIdentityKey().toByteArray(), AuthHelper.VALID_IDENTITY.serialize());\n  }\n\n  @Test\n  void testValidCertificateWithUuidNoE164() throws Exception {\n    DeliveryCertificate certificateObject = resources.getJerseyTest()\n        .target(\"/v1/certificate/delivery\")\n        .queryParam(\"includeUuid\", \"true\")\n        .queryParam(\"includeE164\", \"false\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(DeliveryCertificate.class);\n\n    SenderCertificate certificateHolder = SenderCertificate.parseFrom(certificateObject.getCertificate());\n    SenderCertificate.Certificate certificate = SenderCertificate.Certificate.parseFrom(\n        certificateHolder.getCertificate());\n\n    assertEquals(SIGNING_CERTIFICATE.getId(), certificate.getSignerId());\n    ECPublicKey serverPublicKey = new ECPublicKey(SIGNING_CERTIFICATE.getKey().toByteArray());\n\n    assertTrue(serverPublicKey.verifySignature(certificateHolder.getCertificate().toByteArray(),\n        certificateHolder.getSignature().toByteArray()));\n\n    assertTrue(StringUtils.isBlank(certificate.getSenderE164()));\n    assertEquals(1L, certificate.getSenderDevice());\n    assertEquals(certificate.getSenderUuid(), UUIDUtil.toByteString(AuthHelper.VALID_UUID));\n    assertArrayEquals(certificate.getIdentityKey().toByteArray(), AuthHelper.VALID_IDENTITY.serialize());\n  }\n\n  @Test\n  void testBadAuthentication() {\n    Response response = resources.getJerseyTest()\n        .target(\"/v1/certificate/delivery\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))\n        .get();\n\n    assertEquals(401, response.getStatus());\n  }\n\n\n  @Test\n  void testNoAuthentication() {\n    Response response = resources.getJerseyTest()\n        .target(\"/v1/certificate/delivery\")\n        .request()\n        .get();\n\n    assertEquals(401, response.getStatus());\n  }\n\n\n  @Test\n  void testUnidentifiedAuthentication() {\n    Response response = resources.getJerseyTest()\n        .target(\"/v1/certificate/delivery\")\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"1234\".getBytes()))\n        .get();\n\n    assertEquals(401, response.getStatus());\n  }\n\n  @Test\n  void testGetSingleGroupCredentialWithPniAsServiceId() {\n    final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);\n\n    final GroupCredentials credentials = resources.getJerseyTest()\n        .target(\"/v1/certificate/auth/group\")\n        .queryParam(\"redemptionStartSeconds\", startOfDay.getEpochSecond())\n        .queryParam(\"redemptionEndSeconds\", startOfDay.getEpochSecond())\n        .queryParam(\"pniAsServiceId\", true)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(GroupCredentials.class);\n\n    assertEquals(1, credentials.credentials().size());\n    assertEquals(1, credentials.callLinkAuthCredentials().size());\n\n    assertEquals(AuthHelper.VALID_PNI, credentials.pni());\n    assertEquals(startOfDay.getEpochSecond(), credentials.credentials().getFirst().redemptionTime());\n    assertEquals(startOfDay.getEpochSecond(), credentials.callLinkAuthCredentials().getFirst().redemptionTime());\n\n    final ClientZkAuthOperations clientZkAuthOperations =\n        new ClientZkAuthOperations(SERVER_SECRET_PARAMS.getPublicParams());\n\n    assertDoesNotThrow(() -> {\n      clientZkAuthOperations.receiveAuthCredentialWithPniAsServiceId(\n          new ServiceId.Aci(AuthHelper.VALID_UUID),\n          new ServiceId.Pni(AuthHelper.VALID_PNI),\n          (int) startOfDay.getEpochSecond(),\n          new AuthCredentialWithPniResponse(credentials.credentials().getFirst().credential()));\n    });\n\n    assertDoesNotThrow(() -> {\n      new CallLinkAuthCredentialResponse(credentials.callLinkAuthCredentials().getFirst().credential())\n          .receive(new ServiceId.Aci(AuthHelper.VALID_UUID), startOfDay, genericServerSecretParams.getPublicParams());\n    });\n  }\n\n  @Test\n  void testGetSingleGroupCredential() {\n    final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);\n\n    final GroupCredentials credentials = resources.getJerseyTest()\n        .target(\"/v1/certificate/auth/group\")\n        .queryParam(\"redemptionStartSeconds\", startOfDay.getEpochSecond())\n        .queryParam(\"redemptionEndSeconds\", startOfDay.getEpochSecond())\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(GroupCredentials.class);\n\n    assertEquals(1, credentials.credentials().size());\n    assertEquals(1, credentials.callLinkAuthCredentials().size());\n\n    assertEquals(AuthHelper.VALID_PNI, credentials.pni());\n    assertEquals(startOfDay.getEpochSecond(), credentials.credentials().getFirst().redemptionTime());\n    assertEquals(startOfDay.getEpochSecond(), credentials.callLinkAuthCredentials().getFirst().redemptionTime());\n\n    final ClientZkAuthOperations clientZkAuthOperations =\n        new ClientZkAuthOperations(SERVER_SECRET_PARAMS.getPublicParams());\n\n    assertDoesNotThrow(() -> {\n      clientZkAuthOperations.receiveAuthCredentialWithPniAsServiceId(\n          new ServiceId.Aci(AuthHelper.VALID_UUID),\n          new ServiceId.Pni(AuthHelper.VALID_PNI),\n          (int) startOfDay.getEpochSecond(),\n          new AuthCredentialWithPniResponse(credentials.credentials().getFirst().credential()));\n    });\n\n    assertDoesNotThrow(() -> {\n      new CallLinkAuthCredentialResponse(credentials.callLinkAuthCredentials().getFirst().credential())\n          .receive(new ServiceId.Aci(AuthHelper.VALID_UUID), startOfDay, genericServerSecretParams.getPublicParams());\n    });\n  }\n\n  @Test\n  void testGetWeekLongGroupCredentials() {\n    final Instant startOfDay = clock.instant().truncatedTo(ChronoUnit.DAYS);\n\n    final GroupCredentials credentials = resources.getJerseyTest()\n        .target(\"/v1/certificate/auth/group\")\n        .queryParam(\"redemptionStartSeconds\", startOfDay.getEpochSecond())\n        .queryParam(\"redemptionEndSeconds\", startOfDay.plus(Duration.ofDays(7)).getEpochSecond())\n        .queryParam(\"pniAsServiceId\", true)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(GroupCredentials.class);\n\n    assertEquals(AuthHelper.VALID_PNI, credentials.pni());\n    assertEquals(8, credentials.credentials().size());\n    assertEquals(8, credentials.callLinkAuthCredentials().size());\n\n    final ClientZkAuthOperations clientZkAuthOperations =\n        new ClientZkAuthOperations(SERVER_SECRET_PARAMS.getPublicParams());\n\n    for (int i = 0; i < 8; i++) {\n      final Instant redemptionTime = startOfDay.plus(Duration.ofDays(i));\n      assertEquals(redemptionTime.getEpochSecond(), credentials.credentials().get(i).redemptionTime());\n      assertEquals(redemptionTime.getEpochSecond(), credentials.callLinkAuthCredentials().get(i).redemptionTime());\n\n      final int index = i;\n\n      assertDoesNotThrow(() -> {\n        clientZkAuthOperations.receiveAuthCredentialWithPniAsServiceId(\n            new ServiceId.Aci(AuthHelper.VALID_UUID),\n            new ServiceId.Pni(AuthHelper.VALID_PNI),\n            redemptionTime.getEpochSecond(),\n            new AuthCredentialWithPniResponse(credentials.credentials().get(index).credential()));\n      });\n\n      assertDoesNotThrow(() -> {\n        new CallLinkAuthCredentialResponse(credentials.callLinkAuthCredentials().get(index).credential())\n            .receive(new ServiceId.Aci(AuthHelper.VALID_UUID), redemptionTime, genericServerSecretParams.getPublicParams());\n      });\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testBadRedemptionTimes(final Instant redemptionStart, final Instant redemptionEnd) {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/certificate/auth/group\")\n        .queryParam(\"redemptionStartSeconds\", redemptionStart.getEpochSecond())\n        .queryParam(\"redemptionEndSeconds\", redemptionEnd.getEpochSecond())\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get();\n\n    assertEquals(400, response.getStatus());\n  }\n\n  private static Collection<Arguments> testBadRedemptionTimes() {\n    return List.of(\n        Arguments.argumentSet(\"Start is after end\", clock.instant().plus(Duration.ofDays(1)), clock.instant()),\n        Arguments.argumentSet(\"Start is in the past\", clock.instant().minus(Duration.ofDays(1)), clock.instant()),\n        Arguments.argumentSet(\"End is too far in the future\", clock.instant(),\n            clock.instant().plus(CertificateController.MAX_REDEMPTION_DURATION).plus(Duration.ofDays(1))),\n        Arguments.argumentSet(\"Start is not at a day boundary\", clock.instant().plusSeconds(17),\n            clock.instant().plus(Duration.ofDays(1))),\n        Arguments.argumentSet(\"End is not at a day boundary\", clock.instant(), clock.instant().plusSeconds(17))\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/ChallengeControllerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.Optional;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.limits.RateLimitChallengeManager;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.push.NotPushRegisteredException;\nimport org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker;\nimport org.whispersystems.textsecuregcm.spam.ChallengeConstraintChecker.ChallengeConstraints;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass ChallengeControllerTest {\n\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private static final RateLimitChallengeManager rateLimitChallengeManager = mock(RateLimitChallengeManager.class);\n  private static final ChallengeConstraintChecker challengeConstraintChecker = mock(ChallengeConstraintChecker.class);\n\n  private static final ChallengeController challengeController =\n      new ChallengeController(accountsManager, rateLimitChallengeManager, challengeConstraintChecker);\n\n  private static final ResourceExtension EXTENSION = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new TestRemoteAddressFilterProvider(\"127.0.0.1\"))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new RateLimitExceededExceptionMapper())\n      .addResource(challengeController)\n      .build();\n\n  @BeforeEach\n  void setup() {\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_TWO));\n\n    when(challengeConstraintChecker.challengeConstraints(any(), any()))\n        .thenReturn(new ChallengeConstraints(true, Optional.empty()));\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(rateLimitChallengeManager, challengeConstraintChecker);\n  }\n\n  @Test\n  void testHandlePushChallenge() throws RateLimitExceededException {\n    final String pushChallengeJson = \"\"\"\n        {\n          \"type\": \"rateLimitPushChallenge\",\n          \"challenge\": \"Hello I am a push challenge token\"\n        }\n        \"\"\";\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(pushChallengeJson));\n\n    assertEquals(200, response.getStatus());\n    verify(rateLimitChallengeManager).answerPushChallenge(AuthHelper.VALID_ACCOUNT, \"Hello I am a push challenge token\");\n  }\n\n  @Test\n  void testHandlePushChallengeRateLimited() throws RateLimitExceededException {\n    final String pushChallengeJson = \"\"\"\n        {\n          \"type\": \"rateLimitPushChallenge\",\n          \"challenge\": \"Hello I am a push challenge token\"\n        }\n        \"\"\";\n\n    final Duration retryAfter = Duration.ofMinutes(17);\n    doThrow(new RateLimitExceededException(retryAfter)).when(rateLimitChallengeManager)\n        .answerPushChallenge(any(), any());\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(pushChallengeJson));\n\n    assertEquals(429, response.getStatus());\n    assertEquals(String.valueOf(retryAfter.toSeconds()), response.getHeaderString(\"Retry-After\"));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = { true, false } )\n  void testHandleCaptcha(boolean hasThreshold) throws RateLimitExceededException, IOException {\n    final String captchaChallengeJson = \"\"\"\n        {\n          \"type\": \"captcha\",\n          \"token\": \"A server-generated token\",\n          \"captcha\": \"The value of the solved captcha token\"\n        }\n        \"\"\";\n\n    when(rateLimitChallengeManager.answerCaptchaChallenge(any(), any(), any(), any(), any()))\n        .thenReturn(true);\n\n\n    if (hasThreshold) {\n      when(challengeConstraintChecker.challengeConstraints(any(), any()))\n          .thenReturn(new ChallengeConstraints(true, Optional.of(0.5F)));\n    }\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(captchaChallengeJson));\n\n    assertEquals(200, response.getStatus());\n\n    verify(rateLimitChallengeManager).answerCaptchaChallenge(eq(AuthHelper.VALID_ACCOUNT),\n        eq(\"The value of the solved captcha token\"), eq(\"127.0.0.1\"), anyString(),\n        eq(hasThreshold ? Optional.of(0.5f) : Optional.empty()));\n  }\n\n  @Test\n  void testHandleInvalidCaptcha() throws RateLimitExceededException, IOException {\n    final String captchaChallengeJson = \"\"\"\n        {\n          \"type\": \"captcha\",\n          \"token\": \"A server-generated token\",\n          \"captcha\": \"The value of the solved captcha token\"\n        }\n        \"\"\";\n    when(rateLimitChallengeManager.answerCaptchaChallenge(eq(AuthHelper.VALID_ACCOUNT),\n        eq(\"The value of the solved captcha token\"), eq(\"127.0.0.1\"), anyString(), any()))\n        .thenReturn(false);\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(captchaChallengeJson));\n\n    assertEquals(428, response.getStatus());\n  }\n\n  @Test\n  void testHandleCaptchaRateLimited() throws RateLimitExceededException, IOException {\n    final String captchaChallengeJson = \"\"\"\n        {\n          \"type\": \"captcha\",\n          \"token\": \"A server-generated token\",\n          \"captcha\": \"The value of the solved captcha token\"\n        }\n        \"\"\";\n\n    final Duration retryAfter = Duration.ofMinutes(17);\n    doThrow(new RateLimitExceededException(retryAfter)).when(rateLimitChallengeManager)\n        .answerCaptchaChallenge(any(), any(), any(), any(), any());\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(captchaChallengeJson));\n\n    assertEquals(429, response.getStatus());\n    assertEquals(String.valueOf(retryAfter.toSeconds()), response.getHeaderString(\"Retry-After\"));\n  }\n\n  @Test\n  void testHandleUnrecognizedAnswer() {\n    final String unrecognizedJson = \"\"\"\n        {\n          \"type\": \"unrecognized\"\n        }\n        \"\"\";\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"10.0.0.1\")\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(unrecognizedJson));\n\n    assertEquals(400, response.getStatus());\n\n    verifyNoInteractions(rateLimitChallengeManager);\n  }\n\n  @Test\n  void testRequestPushChallenge() throws NotPushRegisteredException {\n    {\n      final Response response = EXTENSION.target(\"/v1/challenge/push\")\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .post(Entity.text(\"\"));\n\n      assertEquals(200, response.getStatus());\n    }\n\n    {\n      doThrow(NotPushRegisteredException.class).when(rateLimitChallengeManager).sendPushChallenge(AuthHelper.VALID_ACCOUNT_TWO);\n\n      final Response response = EXTENSION.target(\"/v1/challenge/push\")\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n          .post(Entity.text(\"\"));\n\n      assertEquals(404, response.getStatus());\n    }\n  }\n\n  @Test\n  void testRequestPushChallengeNotPermitted() {\n    when(challengeConstraintChecker.challengeConstraints(any(), any()))\n        .thenReturn(new ChallengeConstraints(false, Optional.empty()));\n\n    final Response response = EXTENSION.target(\"/v1/challenge/push\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.text(\"\"));\n    assertEquals(429, response.getStatus());\n    verifyNoInteractions(rateLimitChallengeManager);\n  }\n\n  @Test\n  void testAnswerPushChallengeNotPermitted() {\n    when(challengeConstraintChecker.challengeConstraints(any(), any()))\n        .thenReturn(new ChallengeConstraints(false, Optional.empty()));\n\n    final String pushChallengeJson = \"\"\"\n        {\n          \"type\": \"rateLimitPushChallenge\",\n          \"challenge\": \"Hello I am a push challenge token\"\n        }\n        \"\"\";\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(pushChallengeJson));\n\n    assertEquals(429, response.getStatus());\n    verifyNoInteractions(rateLimitChallengeManager);\n  }\n\n  @Test\n  void testValidationError() {\n    final String unrecognizedJson = \"\"\"\n        {\n          \"type\": \"rateLimitPushChallenge\"\n        }\n        \"\"\";\n\n    final Response response = EXTENSION.target(\"/v1/challenge\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.json(unrecognizedJson));\n\n    assertEquals(422, response.getStatus());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceCheckControllerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.WebTarget;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.GrpcStatusRuntimeExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceCheckManager;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.ChallengeNotFoundException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckKeyIdNotFoundException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.DeviceCheckVerificationFailedException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.DuplicatePublicKeyException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.RequestReuseException;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.TooManyKeysException;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass DeviceCheckControllerTest {\n\n  private final static Duration REDEMPTION_DURATION = Duration.ofDays(5);\n  private final static long REDEMPTION_LEVEL = 201L;\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private final static BackupAuthManager backupAuthManager = mock(BackupAuthManager.class);\n  private final static AppleDeviceCheckManager appleDeviceCheckManager = mock(AppleDeviceCheckManager.class);\n  private final static RateLimiters rateLimiters = mock(RateLimiters.class);\n  private final static Clock clock = TestClock.pinned(Instant.EPOCH);\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new CompletionExceptionMapper())\n      .addResource(new GrpcStatusRuntimeExceptionMapper())\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new DeviceCheckController(clock, accountsManager, backupAuthManager, appleDeviceCheckManager, rateLimiters,\n          REDEMPTION_LEVEL, REDEMPTION_DURATION))\n      .build();\n\n  @BeforeEach\n  public void setUp() {\n    reset(backupAuthManager);\n    reset(appleDeviceCheckManager);\n    reset(rateLimiters);\n    when(rateLimiters.forDescriptor(any())).thenReturn(mock(RateLimiter.class));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n  }\n\n  @ParameterizedTest\n  @EnumSource(AppleDeviceCheckManager.ChallengeType.class)\n  public void createChallenge(AppleDeviceCheckManager.ChallengeType challengeType) throws RateLimitExceededException {\n    when(appleDeviceCheckManager.createChallenge(eq(challengeType), any()))\n        .thenReturn(\"TestChallenge\");\n\n    WebTarget target = resources.getJerseyTest()\n        .target(\"v1/devicecheck/%s\".formatted(switch (challengeType) {\n          case ATTEST -> \"attest\";\n          case ASSERT_BACKUP_REDEMPTION -> \"assert\";\n        }));\n    if (challengeType == AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION) {\n      target = target.queryParam(\"action\", \"backup\");\n    }\n    final DeviceCheckController.ChallengeResponse challenge = target\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(DeviceCheckController.ChallengeResponse.class);\n\n    assertThat(challenge.challenge()).isEqualTo(\"TestChallenge\");\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  public void createChallengeRateLimited(boolean create) throws RateLimitExceededException {\n    final RateLimiter rateLimiter = mock(RateLimiter.class);\n    when(rateLimiters.forDescriptor(RateLimiters.For.DEVICE_CHECK_CHALLENGE)).thenReturn(rateLimiter);\n    doThrow(new RateLimitExceededException(Duration.ofSeconds(1L))).when(rateLimiter).validate(any(UUID.class));\n\n    final String path = \"v1/devicecheck/%s\".formatted(create ? \"assert\" : \"attest\");\n\n    final Response response = resources.getJerseyTest()\n        .target(path)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get();\n    assertThat(response.getStatus()).isEqualTo(429);\n  }\n\n  @Test\n  public void failedAttestValidation()\n      throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {\n    final String errorMessage = \"a test error message\";\n    final byte[] keyId = TestRandomUtil.nextBytes(16);\n    final byte[] attestation = TestRandomUtil.nextBytes(32);\n\n    doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager)\n        .registerAttestation(any(), eq(keyId), eq(attestation));\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/devicecheck/attest\")\n        .queryParam(\"keyId\", Base64.getUrlEncoder().encodeToString(keyId))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM));\n\n    assertThat(response.getStatus()).isEqualTo(401);\n    assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE);\n    assertThat(response.readEntity(Map.class).get(\"message\")).isEqualTo(errorMessage);\n  }\n\n  @Test\n  public void failedAssertValidation()\n      throws DeviceCheckVerificationFailedException, ChallengeNotFoundException,  DeviceCheckKeyIdNotFoundException, RequestReuseException {\n    final String errorMessage = \"a test error message\";\n    final byte[] keyId = TestRandomUtil.nextBytes(16);\n    final byte[] assertion = TestRandomUtil.nextBytes(32);\n    final String challenge = \"embeddedChallenge\";\n    final String request = \"\"\"\n        {\"action\": \"backup\", \"challenge\": \"embeddedChallenge\"}\n        \"\"\";\n\n    doThrow(new DeviceCheckVerificationFailedException(errorMessage)).when(appleDeviceCheckManager)\n        .validateAssert(any(), eq(keyId), eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION), eq(challenge), eq(request.getBytes()), eq(assertion));\n\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/devicecheck/assert\")\n        .queryParam(\"keyId\", Base64.getUrlEncoder().encodeToString(keyId))\n        .queryParam(\"request\", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8)))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM));\n\n    assertThat(response.getStatus()).isEqualTo(401);\n    assertThat(response.getMediaType()).isEqualTo(MediaType.APPLICATION_JSON_TYPE);\n    assertThat(response.readEntity(Map.class).get(\"message\")).isEqualTo(errorMessage);\n  }\n\n  @Test\n  public void registerKey()\n      throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {\n    final byte[] keyId = TestRandomUtil.nextBytes(16);\n    final byte[] attestation = TestRandomUtil.nextBytes(32);\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/devicecheck/attest\")\n        .queryParam(\"keyId\", Base64.getUrlEncoder().encodeToString(keyId))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(attestation, MediaType.APPLICATION_OCTET_STREAM));\n    assertThat(response.getStatus()).isEqualTo(204);\n    verify(appleDeviceCheckManager, times(1))\n        .registerAttestation(any(), eq(keyId), eq(attestation));\n  }\n\n  @Test\n  public void checkAssertion()\n      throws DeviceCheckKeyIdNotFoundException, DeviceCheckVerificationFailedException, ChallengeNotFoundException, RequestReuseException {\n    final byte[] keyId = TestRandomUtil.nextBytes(16);\n    final byte[] assertion = TestRandomUtil.nextBytes(32);\n    final String challenge = \"embeddedChallenge\";\n    final String request = \"\"\"\n        {\"action\": \"backup\", \"challenge\": \"embeddedChallenge\"}\n        \"\"\";\n\n    final Response response = resources.getJerseyTest()\n        .target(\"v1/devicecheck/assert\")\n        .queryParam(\"keyId\", Base64.getUrlEncoder().encodeToString(keyId))\n        .queryParam(\"request\", Base64.getUrlEncoder().encodeToString(request.getBytes(StandardCharsets.UTF_8)))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.entity(assertion, MediaType.APPLICATION_OCTET_STREAM));\n    assertThat(response.getStatus()).isEqualTo(204);\n    verify(appleDeviceCheckManager, times(1)).validateAssert(\n        any(),\n        eq(keyId),\n        eq(AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION),\n        eq(challenge),\n        eq(request.getBytes(StandardCharsets.UTF_8)),\n        eq(assertion));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/DeviceControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.Mockito.anyString;\nimport static org.mockito.Mockito.clearInvocations;\nimport static org.mockito.Mockito.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Base64;\nimport java.util.EnumSet;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.NullSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.ApnRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;\nimport org.whispersystems.textsecuregcm.entities.DeviceInfo;\nimport org.whispersystems.textsecuregcm.entities.DeviceInfoList;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.GcmRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.LinkDeviceRequest;\nimport org.whispersystems.textsecuregcm.entities.LinkDeviceResponse;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachment;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachmentError;\nimport org.whispersystems.textsecuregcm.entities.RestoreAccountRequest;\nimport org.whispersystems.textsecuregcm.entities.TransferArchiveUploadedRequest;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.DeviceSpec;\nimport org.whispersystems.textsecuregcm.storage.LinkDeviceTokenAlreadyUsedException;\nimport org.whispersystems.textsecuregcm.storage.PersistentTimer;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.tests.util.MockRedisFuture;\nimport org.whispersystems.textsecuregcm.util.LinkDeviceToken;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass DeviceControllerTest {\n\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private static final PersistentTimer persistentTimer = mock(PersistentTimer.class);\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter rateLimiter = mock(RateLimiter.class);\n  @SuppressWarnings(\"unchecked\")\n  private static final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);\n  @SuppressWarnings(\"unchecked\")\n  private static final RedisAdvancedClusterAsyncCommands<String, String> asyncCommands = mock(RedisAdvancedClusterAsyncCommands.class);\n  private static final Account account = mock(Account.class);\n  private static final Account maxedAccount = mock(Account.class);\n  private static final Device primaryDevice = mock(Device.class);\n  private static final Map<String, Integer> deviceConfiguration = new HashMap<>();\n  private static final TestClock testClock = TestClock.now();\n\n  private static final byte NEXT_DEVICE_ID = 42;\n\n  private static final DeviceController deviceController = new DeviceController(\n      accountsManager,\n      rateLimiters,\n      persistentTimer,\n      deviceConfiguration);\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addProvider(new DeviceLimitExceededExceptionMapper())\n      .addResource(deviceController)\n      .build();\n\n  @BeforeEach\n  void setup() {\n    when(rateLimiters.getAllocateDeviceLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getVerifyDeviceLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getWaitForLinkedDeviceLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getUploadTransferArchiveLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getWaitForTransferArchiveLimiter()).thenReturn(rateLimiter);\n\n    when(primaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n\n    when(account.getNextDeviceId()).thenReturn(NEXT_DEVICE_ID);\n    when(account.getNumber()).thenReturn(AuthHelper.VALID_NUMBER);\n    when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID);\n    when(account.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI);\n    when(account.getPrimaryDevice()).thenReturn(primaryDevice);\n    when(account.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(primaryDevice));\n    when(account.getDevices()).thenReturn(List.of(primaryDevice));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n    when(accountsManager.getByAccountIdentifierAsync(AuthHelper.VALID_UUID))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(account));\n    when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(maxedAccount));\n\n    when(persistentTimer.start(anyString(), anyString()))\n        .thenReturn(CompletableFuture.completedFuture(mock(PersistentTimer.Sample.class)));\n\n    AccountsHelper.setupMockUpdate(accountsManager);\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(\n        accountsManager,\n        rateLimiters,\n        rateLimiter,\n        commands,\n        asyncCommands,\n        account,\n        maxedAccount,\n        primaryDevice\n    );\n\n    testClock.unpin();\n  }\n\n  @Test\n  void getDevices() {\n    final byte deviceId = Device.PRIMARY_ID;\n    final byte[] deviceName = \"refreshed-device-name\".getBytes(StandardCharsets.UTF_8);\n    final long deviceCreated = System.currentTimeMillis();\n    final long deviceLastSeen = deviceCreated + 1;\n    final int registrationId = 2;\n    final byte[] createdAtCiphertext = \"timestamp ciphertext\".getBytes(StandardCharsets.UTF_8);\n\n    final Device refreshedDevice = mock(Device.class);\n    when(refreshedDevice.getId()).thenReturn(deviceId);\n    when(refreshedDevice.getName()).thenReturn(deviceName);\n    when(refreshedDevice.getLastSeen()).thenReturn(deviceLastSeen);\n    when(refreshedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n    when(refreshedDevice.getCreatedAtCiphertext()).thenReturn(createdAtCiphertext);\n\n    final Account refreshedAccount = mock(Account.class);\n    when(refreshedAccount.getDevices()).thenReturn(List.of(refreshedDevice));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(refreshedAccount));\n\n    final DeviceInfoList deviceInfoList = resources.getJerseyTest()\n        .target(\"/v1/devices\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(DeviceInfoList.class);\n\n    assertEquals(1, deviceInfoList.devices().size());\n    assertEquals(deviceId, deviceInfoList.devices().getFirst().id());\n    assertArrayEquals(deviceName, deviceInfoList.devices().getFirst().name());\n    assertEquals(deviceLastSeen, deviceInfoList.devices().getFirst().lastSeen());\n    assertEquals(registrationId, deviceInfoList.devices().getFirst().registrationId());\n    assertArrayEquals(createdAtCiphertext, deviceInfoList.devices().getFirst().createdAtCiphertext());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  void linkDeviceAtomic(final boolean fetchesMessages,\n                        final Optional<ApnRegistrationId> apnRegistrationId,\n                        final Optional<GcmRegistrationId> gcmRegistrationId,\n                        final Optional<String> expectedApnsToken,\n                        final Optional<String> expectedGcmToken) throws LinkDeviceTokenAlreadyUsedException {\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));\n\n    when(accountsManager.addDevice(any(), any(), any())).thenAnswer(invocation -> {\n      final Account a = invocation.getArgument(0);\n      final DeviceSpec deviceSpec = invocation.getArgument(1);\n\n      return new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock, aciIdentityKey));\n    });\n\n    when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));\n\n    final AccountAttributes accountAttributes = new AccountAttributes(fetchesMessages, 1234, 5678, null,\n        null, true, Set.of());\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        accountAttributes,\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, apnRegistrationId, gcmRegistrationId));\n\n    final LinkDeviceResponse response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE), LinkDeviceResponse.class);\n\n    assertThat(response.deviceId()).isEqualTo(NEXT_DEVICE_ID);\n\n    final ArgumentCaptor<DeviceSpec> deviceSpecCaptor = ArgumentCaptor.forClass(DeviceSpec.class);\n    verify(accountsManager).addDevice(eq(account), deviceSpecCaptor.capture(), any());\n\n    final Device device = deviceSpecCaptor.getValue().toDevice(NEXT_DEVICE_ID, testClock, aciIdentityKey);\n\n    assertEquals(fetchesMessages, device.getFetchesMessages());\n\n    expectedApnsToken.ifPresentOrElse(expectedToken -> assertEquals(expectedToken, device.getApnId()),\n        () -> assertNull(device.getApnId()));\n\n    expectedGcmToken.ifPresentOrElse(expectedToken -> assertEquals(expectedToken, device.getGcmId()),\n        () -> assertNull(device.getGcmId()));\n  }\n\n  private static Stream<Arguments> linkDeviceAtomic() {\n    final String apnsToken = \"apns-token\";\n    final String gcmToken = \"gcm-token\";\n\n    return Stream.of(\n        Arguments.of(true, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),\n        Arguments.of(false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),\n        Arguments.of(false, Optional.of(new ApnRegistrationId(apnsToken)), Optional.empty(), Optional.of(apnsToken), Optional.empty()),\n        Arguments.of(false, Optional.empty(), Optional.of(new GcmRegistrationId(gcmToken)), Optional.empty(), Optional.of(gcmToken))\n    );\n  }\n\n  @CartesianTest\n  void deviceDowngrade(@CartesianTest.Enum final DeviceCapability capability,\n      @CartesianTest.Values(booleans = {true, false}) final boolean accountHasCapability,\n      @CartesianTest.Values(booleans = {true, false}) final boolean requestHasCapability)\n      throws LinkDeviceTokenAlreadyUsedException {\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n    when(accountsManager.addDevice(any(), any(), any()))\n        .thenReturn(new Pair<>(mock(Account.class), mock(Device.class)));\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(primaryDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n    when(account.hasCapability(capability)).thenReturn(accountHasCapability);\n\n    when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));\n\n    when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));\n\n    final Set<DeviceCapability> requestCapabilities = EnumSet.allOf(DeviceCapability.class);\n\n    if (!requestHasCapability) {\n      requestCapabilities.remove(capability);\n    }\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        new AccountAttributes(false, 1234, 5678, null, null, true, requestCapabilities),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId(\"gcm-id\"))));\n\n    final int expectedStatus =\n        capability.getAccountCapabilityMode() != DeviceCapability.AccountCapabilityMode.ALWAYS_CAPABLE\n            && capability.preventDowngrade() && accountHasCapability && !requestHasCapability ? 409 : 200;\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(expectedStatus, response.getStatus());\n    }\n  }\n\n  @Test\n  void linkDeviceAtomicBadCredentials() {\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(primaryDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        new AccountAttributes(false, 1234, 5678, null, null, true, null),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId(\"gcm-id\"))));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", \"This is not a valid authorization header\")\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus());\n    }\n  }\n\n  @Test\n  void linkDeviceAtomicReusedToken() throws LinkDeviceTokenAlreadyUsedException {\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));\n\n    when(accountsManager.addDevice(any(), any(), any()))\n        .thenThrow(new LinkDeviceTokenAlreadyUsedException());\n\n    when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));\n\n    final AccountAttributes accountAttributes = new AccountAttributes(true, 1234, 5678, null,\n        null, true, Set.of());\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        accountAttributes,\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(403, response.getStatus());\n    }\n  }\n\n  @Test\n  void linkDeviceAtomicWithVerificationTokenUsed() {\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    when(commands.get(anyString())).thenReturn(\"\");\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n            new AccountAttributes(false, 1234, 5678, null, null, true, null),\n            new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId(\"gcm-id\"))));\n\n    try (final Response response = resources.getJerseyTest()\n            .target(\"/v1/devices/link\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n            .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  void linkDeviceAtomicConflictingChannel(final boolean fetchesMessages,\n                                          final Optional<ApnRegistrationId> apnRegistrationId,\n                                          final Optional<GcmRegistrationId> gcmRegistrationId) {\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n    when(accountsManager.generateLinkDeviceToken(any())).thenReturn(\"test\");\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final LinkDeviceToken deviceCode = resources.getJerseyTest()\n        .target(\"/v1/devices/provisioning/code\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(LinkDeviceToken.class);\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.token(),\n        new AccountAttributes(fetchesMessages, 1234, 5678, null, null, true, null),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, apnRegistrationId, gcmRegistrationId));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  private static Stream<Arguments> linkDeviceAtomicConflictingChannel() {\n    return Stream.of(\n        Arguments.of(true, Optional.of(new ApnRegistrationId(\"apns-token\")), Optional.of(new GcmRegistrationId(\"gcm-token\"))),\n        Arguments.of(true, Optional.empty(), Optional.of(new GcmRegistrationId(\"gcm-token\"))),\n        Arguments.of(true, Optional.of(new ApnRegistrationId(\"apns-token\")), Optional.empty()),\n        Arguments.of(false, Optional.of(new ApnRegistrationId(\"apns-token\")), Optional.of(new GcmRegistrationId(\"gcm-token\")))\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void linkDeviceAtomicMissingProperty(final IdentityKey aciIdentityKey,\n                                       final IdentityKey pniIdentityKey,\n                                       final ECSignedPreKey aciSignedPreKey,\n                                       final ECSignedPreKey pniSignedPreKey,\n                                       final KEMSignedPreKey aciPqLastResortPreKey,\n                                       final KEMSignedPreKey pniPqLastResortPreKey) {\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n    when(accountsManager.generateLinkDeviceToken(any())).thenReturn(\"test\");\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final LinkDeviceToken deviceCode = resources.getJerseyTest()\n        .target(\"/v1/devices/provisioning/code\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(LinkDeviceToken.class);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(pniIdentityKey);\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(deviceCode.token(),\n        new AccountAttributes(true, 1234, 5678, null, null, true, null),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  private static Stream<Arguments> linkDeviceAtomicMissingProperty() {\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n    final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n\n    return Stream.of(\n        Arguments.of(aciIdentityKey, pniIdentityKey, null, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey),\n        Arguments.of(aciIdentityKey, pniIdentityKey, aciSignedPreKey, null, aciPqLastResortPreKey, pniPqLastResortPreKey),\n        Arguments.of(aciIdentityKey, pniIdentityKey, aciSignedPreKey, pniSignedPreKey, null, pniPqLastResortPreKey),\n        Arguments.of(aciIdentityKey, pniIdentityKey, aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, null)\n    );\n  }\n\n  @Test\n  void linkDeviceAtomicMissingCapabilities() {\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        new AccountAttributes(true, 1234, 5678, null, null, true, null),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void linkDeviceAtomicInvalidSignature(final IdentityKey aciIdentityKey,\n                                        final IdentityKey pniIdentityKey,\n                                        final ECSignedPreKey aciSignedPreKey,\n                                        final ECSignedPreKey pniSignedPreKey,\n                                        final KEMSignedPreKey aciPqLastResortPreKey,\n                                        final KEMSignedPreKey pniPqLastResortPreKey) {\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(pniIdentityKey);\n\n    when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        new AccountAttributes(true, 1234, 5678, null, null, true, null),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  private static Stream<Arguments> linkDeviceAtomicInvalidSignature() {\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n    final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n\n    return Stream.of(\n        Arguments.of(aciIdentityKey, pniIdentityKey, ecSignedPreKeyWithBadSignature(aciSignedPreKey), pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey),\n        Arguments.of(aciIdentityKey, pniIdentityKey, aciSignedPreKey, ecSignedPreKeyWithBadSignature(pniSignedPreKey), aciPqLastResortPreKey, pniPqLastResortPreKey),\n        Arguments.of(aciIdentityKey, pniIdentityKey, aciSignedPreKey, pniSignedPreKey, kemSignedPreKeyWithBadSignature(aciPqLastResortPreKey), pniPqLastResortPreKey),\n        Arguments.of(aciIdentityKey, pniIdentityKey, aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, kemSignedPreKeyWithBadSignature(pniPqLastResortPreKey))\n    );\n  }\n\n  @Test\n  void linkDeviceAtomicExcessiveDeviceName() {\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        new AccountAttributes(false, 1234, 5678, TestRandomUtil.nextBytes(512), null, true, null),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId(\"gcm-id\"))));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void linkDeviceRegistrationId(final int registrationId, final int pniRegistrationId, final int expectedStatusCode)\n      throws LinkDeviceTokenAlreadyUsedException {\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n    final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(aciIdentityKey);\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    when(accountsManager.addDevice(any(), any(), any())).thenAnswer(invocation -> {\n      final Account a = invocation.getArgument(0);\n      final DeviceSpec deviceSpec = invocation.getArgument(1);\n\n      return new Pair<>(a, deviceSpec.toDevice(NEXT_DEVICE_ID, testClock, aciIdentityKey));\n    });\n\n    when(accountsManager.checkDeviceLinkingToken(anyString())).thenReturn(Optional.of(AuthHelper.VALID_UUID));\n\n    when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(null));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        new AccountAttributes(false, registrationId, pniRegistrationId, null, null, true, Set.of()),\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.of(new ApnRegistrationId(\"apn\")), Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n      assertEquals(expectedStatusCode, response.getStatus());\n    }\n  }\n\n  private static Stream<Arguments> linkDeviceRegistrationId() {\n    return Stream.of(\n        Arguments.of(0x3FFF, 0x3FFF, 200),\n        Arguments.of(0, 0x3FFF, 422),\n        Arguments.of(-1, 0x3FFF, 422),\n        Arguments.of(0x3FFF + 1, 0x3FFF, 422),\n        Arguments.of(Integer.MAX_VALUE, 0x3FFF, 422),\n        Arguments.of(0x3FFF, 0, 422),\n        Arguments.of(0x3FFF, -1, 422),\n        Arguments.of(0x3FFF, 0x3FFF + 1, 422),\n        Arguments.of(0x3FFF, Integer.MAX_VALUE, 422)\n    );\n  }\n\n  @Test\n  void linkDeviceNullAccountAttributes() {\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n\n    final Device existingDevice = mock(Device.class);\n    when(existingDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevices()).thenReturn(List.of(existingDevice));\n\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n    pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n    aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n    pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n\n    when(account.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(aciIdentityKeyPair.getPublicKey()));\n    when(account.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(pniIdentityKeyPair.getPublicKey()));\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(\"link-device-token\",\n        null,\n        new DeviceActivationRequest(aciSignedPreKey, pniSignedPreKey, aciPqLastResortPreKey, pniPqLastResortPreKey, Optional.empty(), Optional.of(new GcmRegistrationId(\"gcm-id\"))));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  private static ECSignedPreKey ecSignedPreKeyWithBadSignature(final ECSignedPreKey signedPreKey) {\n    return new ECSignedPreKey(signedPreKey.keyId(),\n        signedPreKey.publicKey(),\n        \"incorrect-signature\".getBytes(StandardCharsets.UTF_8));\n  }\n\n  private static KEMSignedPreKey kemSignedPreKeyWithBadSignature(final KEMSignedPreKey signedPreKey) {\n    return new KEMSignedPreKey(signedPreKey.keyId(),\n        signedPreKey.publicKey(),\n        \"incorrect-signature\".getBytes(StandardCharsets.UTF_8));\n  }\n\n  @Test\n  void maxDevicesTest() throws LinkDeviceTokenAlreadyUsedException {\n    final List<Device> devices = IntStream.range(0, DeviceController.MAX_DEVICES + 1)\n        .mapToObj(i -> mock(Device.class))\n        .toList();\n\n    when(account.getDevices()).thenReturn(devices);\n\n    Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/provisioning/code\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get();\n\n    assertEquals(411, response.getStatus());\n    verify(accountsManager, never()).addDevice(any(), any(), any());\n  }\n\n  @Test\n  void putCapabilitiesSuccessTest() {\n    try (final Response response = resources\n        .getJerseyTest()\n        .target(\"/v1/devices/capabilities\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/5.42.8675309 Android/30\")\n        .put(Entity.json(\"{\\\"storage\\\": true, \\\"notARealDeviceCapability\\\": true}\"))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      assertThat(response.hasEntity()).isFalse();\n      verify(primaryDevice).setCapabilities(Set.of(DeviceCapability.STORAGE));\n    }\n  }\n\n  @Test\n  void putCapabilitiesFailureTest() {\n    try (final Response response = resources\n        .getJerseyTest()\n        .target(\"/v1/devices/capabilities\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/5.42.8675309 Android/30\")\n        .put(Entity.json(\"\"))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void removeDevice() {\n\n    // this is a static mock, so it might have previous invocations\n    clearInvocations(account);\n\n    final byte deviceId = 2;\n\n    when(accountsManager.removeDevice(account, deviceId))\n        .thenReturn(account);\n\n    try (final Response response = resources\n        .getJerseyTest()\n        .target(\"/v1/devices/\" + deviceId)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/5.42.8675309 Android/30\")\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      assertThat(response.hasEntity()).isFalse();\n\n      verify(accountsManager).removeDevice(account, deviceId);\n    }\n  }\n\n  @Test\n  void unlinkPrimaryDevice() {\n    // this is a static mock, so it might have previous invocations\n    clearInvocations(account);\n\n    try (final Response response = resources\n        .getJerseyTest()\n        .target(\"/v1/devices/\" + Device.PRIMARY_ID)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/5.42.8675309 Android/30\")\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(403);\n\n      verify(accountsManager, never()).removeDevice(any(), anyByte());\n    }\n  }\n\n  @Test\n  void removeDeviceBySelf() {\n    final byte deviceId = 2;\n\n    when(accountsManager.removeDevice(AuthHelper.VALID_ACCOUNT_3, deviceId))\n        .thenReturn(account);\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_3))\n        .thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_3));\n\n    try (final Response response = resources\n        .getJerseyTest()\n        .target(\"/v1/devices/\" + deviceId)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, deviceId, AuthHelper.VALID_PASSWORD_3_LINKED))\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/5.42.8675309 Android/30\")\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n      assertThat(response.hasEntity()).isFalse();\n\n      verify(accountsManager).removeDevice(AuthHelper.VALID_ACCOUNT_3, deviceId);\n    }\n  }\n\n  @Test\n  void removeDeviceByOther() {\n    final byte deviceId = 2;\n    final byte otherDeviceId = 3;\n\n    try (final Response response = resources\n        .getJerseyTest()\n        .target(\"/v1/devices/\" + otherDeviceId)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, deviceId, AuthHelper.VALID_PASSWORD_3_LINKED))\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/5.42.8675309 Android/30\")\n        .delete()) {\n\n      assertThat(response.getStatus()).isEqualTo(401);\n\n      verify(accountsManager, never()).removeDevice(any(), anyByte());\n    }\n  }\n\n  @Test\n  void waitForLinkedDevice() {\n    final DeviceInfo deviceInfo = new DeviceInfo(Device.PRIMARY_ID,\n        \"Device name ciphertext\".getBytes(StandardCharsets.UTF_8),\n        System.currentTimeMillis(),\n        1,\n        \"timestamp ciphertext\".getBytes(StandardCharsets.UTF_8));\n\n    final String tokenIdentifier = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32]);\n\n    when(accountsManager\n        .waitForNewLinkedDevice(eq(AuthHelper.VALID_UUID), eq(primaryDevice), eq(tokenIdentifier), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(deviceInfo)));\n\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID)).thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/wait_for_linked_device/\" + tokenIdentifier)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(200, response.getStatus());\n\n      final DeviceInfo retrievedDeviceInfo = response.readEntity(DeviceInfo.class);\n      assertEquals(deviceInfo.id(), retrievedDeviceInfo.id());\n      assertArrayEquals(deviceInfo.name(), retrievedDeviceInfo.name());\n      assertEquals(deviceInfo.lastSeen(), retrievedDeviceInfo.lastSeen());\n      assertEquals(deviceInfo.registrationId(), retrievedDeviceInfo.registrationId());\n      assertArrayEquals(deviceInfo.createdAtCiphertext(), retrievedDeviceInfo.createdAtCiphertext());\n    }\n  }\n\n  @Test\n  void waitForLinkedDeviceNoDeviceLinked() {\n    final String tokenIdentifier = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32]);\n\n    when(accountsManager\n        .waitForNewLinkedDevice(eq(AuthHelper.VALID_UUID), eq(primaryDevice), eq(tokenIdentifier), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID)).thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/wait_for_linked_device/\" + tokenIdentifier)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(204, response.getStatus());\n    }\n  }\n\n  @Test\n  void waitForLinkedDeviceBadTokenIdentifier() {\n    final String tokenIdentifier = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32]);\n\n    when(accountsManager\n        .waitForNewLinkedDevice(eq(AuthHelper.VALID_UUID), eq(primaryDevice), eq(tokenIdentifier), any()))\n        .thenReturn(CompletableFuture.failedFuture(new IllegalArgumentException()));\n\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID)).thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/wait_for_linked_device/\" + tokenIdentifier)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, -1, 3601})\n  void waitForLinkedDeviceBadTimeout(final int timeoutSeconds) {\n    final String tokenIdentifier = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32]);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/wait_for_linked_device/\" + tokenIdentifier)\n        .queryParam(\"timeout\", timeoutSeconds)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void waitForLinkedDeviceBadTokenIdentifierLength(final String tokenIdentifier) {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/wait_for_linked_device/\" + tokenIdentifier)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  private static List<String> waitForLinkedDeviceBadTokenIdentifierLength() {\n    return List.of(RandomStringUtils.secure().nextAlphanumeric(DeviceController.MIN_TOKEN_IDENTIFIER_LENGTH - 1),\n        RandomStringUtils.secure().nextAlphanumeric(DeviceController.MAX_TOKEN_IDENTIFIER_LENGTH + 1));\n  }\n\n  @Test\n  void waitForLinkedDeviceRateLimited() {\n    final String tokenIdentifier = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[32]);\n\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID))\n        .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/wait_for_linked_device/\" + tokenIdentifier)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(429, response.getStatus());\n    }\n  }\n\n  @Test\n  void recordTransferArchiveUploaded() {\n    final byte deviceId = Device.PRIMARY_ID + 1;\n    final int registrationId = 123;\n    final RemoteAttachment transferArchive =\n        new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"test\".getBytes(StandardCharsets.UTF_8)));\n\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID)).thenReturn(CompletableFuture.completedFuture(null));\n    when(accountsManager.recordTransferArchiveUpload(account, deviceId, registrationId, transferArchive))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new TransferArchiveUploadedRequest(deviceId, registrationId, transferArchive),\n            MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(204, response.getStatus());\n\n      verify(accountsManager)\n          .recordTransferArchiveUpload(account, deviceId, registrationId, transferArchive);\n    }\n  }\n\n  @Test\n  void recordTransferArchiveFailed() {\n    final byte deviceId = Device.PRIMARY_ID + 1;\n    final int registrationId = 123;\n    final Instant deviceCreated = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n    final RemoteAttachmentError transferFailure = new RemoteAttachmentError(RemoteAttachmentError.ErrorType.CONTINUE_WITHOUT_UPLOAD);\n\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID)).thenReturn(CompletableFuture.completedFuture(null));\n    when(accountsManager.recordTransferArchiveUpload(account, deviceId, registrationId, transferFailure))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new TransferArchiveUploadedRequest(deviceId, registrationId, transferFailure),\n            MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(204, response.getStatus());\n\n      verify(accountsManager)\n          .recordTransferArchiveUpload(account, deviceId, registrationId, transferFailure);\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void recordTransferArchiveUploadedBadRequest(final TransferArchiveUploadedRequest request) {\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID)).thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n\n      verify(accountsManager, never())\n          .recordTransferArchiveUpload(any(), anyByte(), anyInt(), any());\n    }\n  }\n\n  @SuppressWarnings(\"DataFlowIssue\")\n  private static List<Arguments> recordTransferArchiveUploadedBadRequest() {\n    final RemoteAttachment validTransferArchive =\n        new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"archive\".getBytes(StandardCharsets.UTF_8)));\n\n    return List.of(\n        Arguments.argumentSet(\"Invalid device ID\", new TransferArchiveUploadedRequest((byte) -1, 1, validTransferArchive)),\n        Arguments.argumentSet(\"Invalid registration ID - negative\",\n            new TransferArchiveUploadedRequest(Device.PRIMARY_ID, -1, validTransferArchive)),\n        Arguments.argumentSet(\"Invalid registration ID - too large\",\n            new TransferArchiveUploadedRequest(Device.PRIMARY_ID, 0x4000, validTransferArchive)),\n        Arguments.argumentSet(\"Missing CDN number\",\n            new TransferArchiveUploadedRequest(Device.PRIMARY_ID, 1,\n                new RemoteAttachment(null, Base64.getUrlEncoder().encodeToString(\"archive\".getBytes(StandardCharsets.UTF_8))))),\n        Arguments.argumentSet(\"Bad attachment key\",\n            new TransferArchiveUploadedRequest(Device.PRIMARY_ID, 1,\n                new RemoteAttachment(3, \"This is not a valid base64 string\")))\n    );\n  }\n\n  @Test\n  void recordTransferArchiveRateLimited() {\n    when(rateLimiter.validateAsync(AuthHelper.VALID_UUID))\n        .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new TransferArchiveUploadedRequest(Device.PRIMARY_ID, 1,\n            new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"test\".getBytes(StandardCharsets.UTF_8)))),\n            MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(429, response.getStatus());\n\n      verify(accountsManager, never())\n          .recordTransferArchiveUpload(any(), anyByte(), anyInt(), any());\n    }\n  }\n\n  @Test\n  void waitForTransferArchive() {\n    final RemoteAttachment transferArchive =\n        new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"test\".getBytes(StandardCharsets.UTF_8)));\n\n    when(rateLimiter.validateAsync(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n    when(accountsManager.waitForTransferArchive(eq(account), eq(primaryDevice), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(transferArchive)));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(200, response.getStatus());\n      assertEquals(transferArchive, response.readEntity(RemoteAttachment.class));\n    }\n  }\n\n  @Test\n  void waitForTransferArchiveUploadFailed() {\n    final RemoteAttachment transferArchive =\n        new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"test\".getBytes(StandardCharsets.UTF_8)));\n\n    when(rateLimiter.validateAsync(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n    when(accountsManager.waitForTransferArchive(eq(account), eq(primaryDevice), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(transferArchive)));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(200, response.getStatus());\n      assertEquals(transferArchive, response.readEntity(RemoteAttachment.class));\n    }\n  }\n\n  @Test\n  void waitForTransferArchiveNoArchiveUploaded() {\n    when(rateLimiter.validateAsync(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n    when(accountsManager.waitForTransferArchive(eq(account), eq(primaryDevice), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(204, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, -1, 3601})\n  void waitForTransferArchiveBadTimeout(final int timeoutSeconds) {\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive/\")\n        .queryParam(\"timeout\", timeoutSeconds)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  @Test\n  void waitForTransferArchiveRateLimited() {\n    when(rateLimiter.validateAsync(anyString()))\n        .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/transfer_archive/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get()) {\n\n      assertEquals(429, response.getStatus());\n    }\n  }\n\n  @Test\n  void recordRestoreAccountRequest() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n    final RestoreAccountRequest restoreAccountRequest =\n        new RestoreAccountRequest(RestoreAccountRequest.Method.LOCAL_BACKUP, null);\n\n    when(accountsManager.recordRestoreAccountRequest(token, restoreAccountRequest))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .request()\n        .put(Entity.json(restoreAccountRequest))) {\n\n      assertEquals(204, response.getStatus());\n    }\n  }\n\n  @Test\n  void recordRestoreAccountRequestBadToken() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(128);\n    final RestoreAccountRequest restoreAccountRequest =\n        new RestoreAccountRequest(RestoreAccountRequest.Method.LOCAL_BACKUP, null);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .request()\n        .put(Entity.json(restoreAccountRequest))) {\n\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  @Test\n  void recordRestoreAccountRequestInvalidRequest() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n    final RestoreAccountRequest restoreAccountRequest = new RestoreAccountRequest(null, null);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .request()\n        .put(Entity.json(restoreAccountRequest))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"0, true\",\n      \"4096, true\",\n      \"4097, false\"\n  })\n  void recordRestoreAccountRequestBootstrapLengthLimit(int bootstrapLength, boolean valid) {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    final byte[] bootstrap = TestRandomUtil.nextBytes(bootstrapLength);\n    final RestoreAccountRequest restoreAccountRequest = new RestoreAccountRequest(\n        RestoreAccountRequest.Method.DEVICE_TRANSFER, bootstrap);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .request()\n        .put(Entity.json(restoreAccountRequest))) {\n\n      assertEquals(valid ? 204 : 422, response.getStatus());\n    }\n\n  }\n\n  @Test\n  void waitForDeviceTransferRequest() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n    final RestoreAccountRequest restoreAccountRequest =\n        new RestoreAccountRequest(RestoreAccountRequest.Method.LOCAL_BACKUP, null);\n\n    when(accountsManager.waitForRestoreAccountRequest(eq(token), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(restoreAccountRequest)));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .request()\n        .get()) {\n\n      assertEquals(200, response.getStatus());\n      assertEquals(restoreAccountRequest, response.readEntity(RestoreAccountRequest.class));\n    }\n  }\n\n  @Test\n  void waitForDeviceTransferRequestNoRequestIssued() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    when(accountsManager.waitForRestoreAccountRequest(eq(token), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .request()\n        .get()) {\n\n      assertEquals(204, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, -1, 3601})\n  void waitForDeviceTransferRequestBadTimeout(final int timeoutSeconds) {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/restore_account/\" + token)\n        .queryParam(\"timeout\", timeoutSeconds)\n        .request()\n        .get()) {\n\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @NullSource\n  @ValueSource(strings = {\"\"})\n  void linkDeviceMissingVerificationCode(final String verificationCode) {\n    final AccountAttributes accountAttributes = new AccountAttributes(true, 1234, 5678, null,\n        null, true, Set.of());\n\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final LinkDeviceRequest request = new LinkDeviceRequest(verificationCode,\n        accountAttributes,\n        new DeviceActivationRequest(\n            KeysHelper.signedECPreKey(1, aciIdentityKeyPair),\n            KeysHelper.signedECPreKey(2, pniIdentityKeyPair),\n            KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair),\n            KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair),\n            Optional.empty(),\n            Optional.empty()));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/devices/link\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getProvisioningAuthHeader(AuthHelper.VALID_NUMBER, \"password1\"))\n        .put(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE))) {\n      assertEquals(422, response.getStatus());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/DirectoryControllerV2Test.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.secretBytesOf;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.UUID;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.DirectoryV2ClientConfiguration;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\nclass DirectoryControllerV2Test {\n\n  @Test\n  void testAuthToken() {\n    final ExternalServiceCredentialsGenerator credentialsGenerator = DirectoryV2Controller.credentialsGenerator(\n        new DirectoryV2ClientConfiguration(secretBytesOf(0x01), secretBytesOf(0x02)),\n        Clock.fixed(Instant.ofEpochSecond(1633738643L), ZoneId.of(\"Etc/UTC\"))\n    );\n\n    final DirectoryV2Controller controller = new DirectoryV2Controller(credentialsGenerator);\n\n    final Account account = mock(Account.class);\n    final UUID uuid = UUID.fromString(\"11111111-1111-1111-1111-111111111111\");\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n\n    final ExternalServiceCredentials credentials = controller.getAuthToken(\n        new AuthenticatedDevice(uuid, Device.PRIMARY_ID, Instant.now()));\n\n    assertEquals(\"d369bc712e2e0dd36258\", credentials.username());\n    assertEquals(\"1633738643:4433b0fab41f25f79dd4\", credentials.password());\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/DonationControllerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.ArgumentMatchers.same;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.entities.RedeemReceiptRequest;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.RedeemedReceiptsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass DonationControllerTest {\n\n  private static final long nowEpochSeconds = 1_500_000_000L;\n\n  static BadgesConfiguration getBadgesConfiguration() {\n    return new BadgesConfiguration(\n        List.of(\n            new BadgeConfiguration(\"TEST\", \"other\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))),\n            new BadgeConfiguration(\"TEST1\", \"testing\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))),\n            new BadgeConfiguration(\"TEST2\", \"testing\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))),\n            new BadgeConfiguration(\"TEST3\", \"testing\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n                List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")))),\n        List.of(\"TEST\"),\n        Map.of(1L, \"TEST1\", 2L, \"TEST2\", 3L, \"TEST3\"));\n  }\n\n  final Clock clock = TestClock.pinned(Instant.ofEpochSecond(nowEpochSeconds));\n  ServerZkReceiptOperations zkReceiptOperations;\n  RedeemedReceiptsManager redeemedReceiptsManager;\n  AccountsManager accountsManager;\n  byte[] receiptSerialBytes;\n  ReceiptSerial receiptSerial;\n  byte[] presentation;\n  DonationController.ReceiptCredentialPresentationFactory receiptCredentialPresentationFactory;\n  ReceiptCredentialPresentation receiptCredentialPresentation;\n  ResourceExtension resources;\n\n  @BeforeEach\n  void beforeEach() throws Throwable {\n    zkReceiptOperations = mock(ServerZkReceiptOperations.class);\n    redeemedReceiptsManager = mock(RedeemedReceiptsManager.class);\n    accountsManager = mock(AccountsManager.class);\n    AccountsHelper.setupMockUpdate(accountsManager);\n    receiptSerial = new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE));\n    presentation = TestRandomUtil.nextBytes(25);\n    receiptCredentialPresentationFactory = mock(DonationController.ReceiptCredentialPresentationFactory.class);\n    receiptCredentialPresentation = mock(ReceiptCredentialPresentation.class);\n\n    try {\n      when(receiptCredentialPresentationFactory.build(presentation)).thenReturn(receiptCredentialPresentation);\n    } catch (InvalidInputException e) {\n      throw new AssertionError(e);\n    }\n\n    resources = ResourceExtension.builder()\n        .addProvider(AuthHelper.getAuthFilter())\n        .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n        .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n        .addResource(new DonationController(clock, zkReceiptOperations, redeemedReceiptsManager, accountsManager,\n            getBadgesConfiguration(), receiptCredentialPresentationFactory))\n        .build();\n    resources.before();\n  }\n\n  @AfterEach\n  void afterEach() throws Throwable {\n    resources.after();\n  }\n\n  @Test\n  void testRedeemReceipt() {\n    when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);\n    final long receiptLevel = 1L;\n    when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);\n    final long receiptExpiration = nowEpochSeconds + 86400 * 30;\n    when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);\n    when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(\n        CompletableFuture.completedFuture(Boolean.TRUE));\n    when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID)))\n        .thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n\n    RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);\n    Response response = resources.getJerseyTest()\n        .target(\"/v1/donation/redeem-receipt\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(200);\n    verify(AuthHelper.VALID_ACCOUNT).addBadge(same(clock), eq(new AccountBadge(\"TEST1\", Instant.ofEpochSecond(receiptExpiration), true)));\n    verify(AuthHelper.VALID_ACCOUNT).makeBadgePrimaryIfExists(same(clock), eq(\"TEST1\"));\n  }\n\n  @Test\n  void testRedeemReceiptAlreadyRedeemedWithDifferentParameters() {\n    when(receiptCredentialPresentation.getReceiptSerial()).thenReturn(receiptSerial);\n    final long receiptLevel = 1L;\n    when(receiptCredentialPresentation.getReceiptLevel()).thenReturn(receiptLevel);\n    final long receiptExpiration = nowEpochSeconds + 86400 * 30;\n    when(receiptCredentialPresentation.getReceiptExpirationTime()).thenReturn(receiptExpiration);\n    when(redeemedReceiptsManager.put(same(receiptSerial), eq(receiptExpiration), eq(receiptLevel), eq(AuthHelper.VALID_UUID))).thenReturn(\n        CompletableFuture.completedFuture(Boolean.FALSE));\n    when(accountsManager.getByAccountIdentifier(eq(AuthHelper.VALID_UUID)))\n        .thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n\n    RedeemReceiptRequest request = new RedeemReceiptRequest(presentation, true, true);\n    Response response = resources.getJerseyTest()\n        .target(\"/v1/donation/redeem-receipt\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.entity(request, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(400);\n    assertThat(response.readEntity(String.class)).isEqualTo(\"receipt serial is already redeemed\");\n  }\n\n  @Test\n  void testRedeemReceiptBadCredentialPresentation() throws InvalidInputException {\n    when(receiptCredentialPresentationFactory.build(any())).thenThrow(new InvalidInputException());\n\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/donation/redeem-receipt\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .post(Entity.entity(new RedeemReceiptRequest(presentation, true, true), MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeyTransparencyControllerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.net.HttpHeaders;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.client.WebTarget;\nimport jakarta.ws.rs.core.Response;\nimport java.io.UncheckedIOException;\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.keytransparency.client.CondensedTreeSearchResponse;\nimport org.signal.keytransparency.client.DistinguishedResponse;\nimport org.signal.keytransparency.client.E164SearchRequest;\nimport org.signal.keytransparency.client.FullTreeHead;\nimport org.signal.keytransparency.client.MonitorResponse;\nimport org.signal.keytransparency.client.SearchProof;\nimport org.signal.keytransparency.client.SearchResponse;\nimport org.signal.keytransparency.client.UpdateValue;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencyDistinguishedKeyResponse;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorRequest;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencyMonitorResponse;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencySearchRequest;\nimport org.whispersystems.textsecuregcm.entities.KeyTransparencySearchResponse;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;\nimport org.whispersystems.textsecuregcm.limits.RateLimitByIpFilter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class KeyTransparencyControllerTest {\n\n  public static final String NUMBER = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n      PhoneNumberUtil.PhoneNumberFormat.E164);\n  public static final AciServiceIdentifier ACI = new AciServiceIdentifier(UUID.randomUUID());\n  public static final byte[] USERNAME_HASH = TestRandomUtil.nextBytes(20);\n  private static final TestRemoteAddressFilterProvider TEST_REMOTE_ADDRESS_FILTER_PROVIDER\n      = new TestRemoteAddressFilterProvider(\"127.0.0.1\");\n  public static final IdentityKey ACI_IDENTITY_KEY =  new IdentityKey(ECKeyPair.generate().getPublicKey());\n  private static final byte[] COMMITMENT_INDEX = new byte[32];\n  public static final byte[] UNIDENTIFIED_ACCESS_KEY = new byte[16];\n  private final KeyTransparencyServiceClient keyTransparencyServiceClient = mock(KeyTransparencyServiceClient.class);\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter searchRatelimiter = mock(RateLimiter.class);\n  private static final RateLimiter monitorRatelimiter = mock(RateLimiter.class);\n  private static final RateLimiter distinguishedRatelimiter = mock(RateLimiter.class);\n\n  private final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(TEST_REMOTE_ADDRESS_FILTER_PROVIDER)\n      .addProvider(new RateLimitByIpFilter(rateLimiters))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new KeyTransparencyController(keyTransparencyServiceClient))\n      .build();\n\n  @BeforeEach\n  void setup() {\n    when(rateLimiters.forDescriptor(RateLimiters.For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP)).thenReturn(\n        distinguishedRatelimiter);\n    when(rateLimiters.forDescriptor(RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP)).thenReturn(searchRatelimiter);\n    when(rateLimiters.forDescriptor(RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP)).thenReturn(monitorRatelimiter);\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(rateLimiters,\n        searchRatelimiter,\n        monitorRatelimiter);\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void searchSuccess(\n      final Optional<String> e164,\n      final Optional<byte[]> usernameHash\n  ) {\n    final CondensedTreeSearchResponse aciSearchResponse = CondensedTreeSearchResponse.newBuilder()\n        .setOpening(ByteString.copyFrom(TestRandomUtil.nextBytes(16)))\n        .setSearch(SearchProof.getDefaultInstance())\n        .setValue(UpdateValue.newBuilder()\n            .setValue(ByteString.copyFrom(TestRandomUtil.nextBytes(16)))\n            .build())\n        .build();\n\n    final SearchResponse.Builder searchResponseBuilder = SearchResponse.newBuilder()\n        .setTreeHead(FullTreeHead.getDefaultInstance())\n        .setAci(aciSearchResponse);\n\n    e164.ifPresent(ignored -> searchResponseBuilder.setE164(CondensedTreeSearchResponse.getDefaultInstance()));\n    usernameHash.ifPresent(ignored -> searchResponseBuilder.setUsernameHash(CondensedTreeSearchResponse.getDefaultInstance()));\n\n    when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), anyLong()))\n        .thenReturn(searchResponseBuilder.build());\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/search\")\n        .request();\n\n    final Optional<byte[]> unidentifiedAccessKey = e164.isPresent() ? Optional.of(UNIDENTIFIED_ACCESS_KEY) : Optional.empty();\n    final String searchJson = createRequestJson(\n        new KeyTransparencySearchRequest(ACI, e164, usernameHash, ACI_IDENTITY_KEY,\n            unidentifiedAccessKey, Optional.of(3L), 4L));\n\n    try (Response response = request.post(Entity.json(searchJson))) {\n      assertEquals(200, response.getStatus());\n\n      final KeyTransparencySearchResponse keyTransparencySearchResponse = response.readEntity(\n          KeyTransparencySearchResponse.class);\n      assertNotNull(keyTransparencySearchResponse.serializedResponse());\n      assertEquals(aciSearchResponse, SearchResponse.parseFrom(keyTransparencySearchResponse.serializedResponse()).getAci());\n\n      ArgumentCaptor<ByteString> aciArgument = ArgumentCaptor.forClass(ByteString.class);\n      ArgumentCaptor<ByteString> aciIdentityKeyArgument = ArgumentCaptor.forClass(ByteString.class);\n      ArgumentCaptor<Optional<ByteString>> usernameHashArgument = ArgumentCaptor.forClass(Optional.class);\n      ArgumentCaptor<Optional<E164SearchRequest>> e164Argument = ArgumentCaptor.forClass(Optional.class);\n\n\n      verify(keyTransparencyServiceClient).search(aciArgument.capture(), aciIdentityKeyArgument.capture(),\n          usernameHashArgument.capture(), e164Argument.capture(), eq(Optional.of(3L)), eq(4L));\n\n      assertArrayEquals(ACI.toCompactByteArray(), aciArgument.getValue().toByteArray());\n      assertArrayEquals(ACI_IDENTITY_KEY.serialize(), aciIdentityKeyArgument.getValue().toByteArray());\n\n      if (usernameHash.isPresent()) {\n        assertArrayEquals(USERNAME_HASH, usernameHashArgument.getValue().orElseThrow().toByteArray());\n      } else {\n        assertTrue(usernameHashArgument.getValue().isEmpty());\n      }\n\n      if (e164.isPresent()) {\n        final E164SearchRequest expected = E164SearchRequest.newBuilder()\n            .setE164(e164.get())\n            .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey.get()))\n            .build();\n        assertEquals(expected, e164Argument.getValue().orElseThrow());\n      } else {\n        assertTrue(e164Argument.getValue().isEmpty());\n      }\n\n    } catch (InvalidProtocolBufferException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private static Stream<Arguments> searchSuccess() {\n    return Stream.of(\n        Arguments.argumentSet(\"Search for ACI and E164\", Optional.of(NUMBER), Optional.empty()),\n        Arguments.argumentSet(\"Search for ACI and username hash\", Optional.empty(), Optional.of(USERNAME_HASH)),\n        Arguments.argumentSet(\"Search for ACI, E164, and username hash\", Optional.of(NUMBER), Optional.of(USERNAME_HASH))\n      );\n  }\n\n  @Test\n  void searchAuthenticated() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/search\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n    try (Response response = request.post(\n        Entity.json(createRequestJson(new KeyTransparencySearchRequest(ACI, Optional.empty(), Optional.empty(),\n            ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), 4L))))) {\n      assertEquals(400, response.getStatus());\n    }\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void searchGrpcErrors(final Status grpcStatus, final int httpStatus) {\n    when(keyTransparencyServiceClient.search(any(), any(), any(), any(), any(), anyLong()))\n        .thenThrow(new StatusRuntimeException(grpcStatus));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/search\")\n        .request();\n    try (Response response = request.post(\n        Entity.json(createRequestJson(new KeyTransparencySearchRequest(ACI, Optional.empty(), Optional.empty(),\n            ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), 4L))))) {\n      assertEquals(httpStatus, response.getStatus());\n      verify(keyTransparencyServiceClient, times(1)).search(any(), any(), any(), any(), any(), anyLong());\n    }\n  }\n\n  private static Stream<Arguments> searchGrpcErrors() {\n    return Stream.of(\n        Arguments.of(Status.PERMISSION_DENIED, 403),\n        Arguments.of(Status.INVALID_ARGUMENT, 422),\n        Arguments.of(Status.UNKNOWN, 500)\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void searchInvalidRequest(final AciServiceIdentifier aci,\n      final IdentityKey aciIdentityKey,\n      final Optional<String> e164,\n      final Optional<byte[]> unidentifiedAccessKey,\n      final Optional<Long> lastTreeHeadSize,\n      final long distinguishedTreeHeadSize) {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/search\")\n        .request();\n    try (Response response = request.post(Entity.json(\n        createRequestJson(new KeyTransparencySearchRequest(aci, e164, Optional.empty(),\n            aciIdentityKey, unidentifiedAccessKey, lastTreeHeadSize, distinguishedTreeHeadSize))))) {\n      assertEquals(422, response.getStatus());\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  private static Stream<Arguments> searchInvalidRequest() {\n    return Stream.of(\n        Arguments.argumentSet(\"ACI can't be null\", null, ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.empty(), 4L),\n        Arguments.argumentSet(\"ACI identity key can't be null\", ACI, null, Optional.empty(), Optional.empty(), Optional.empty(), 4L),\n        Arguments.argumentSet(\"lastNonDistinguishedTreeHeadSize must be positive\", ACI, ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.of(0L), 4L),\n        Arguments.argumentSet(\"lastDistinguishedTreeHeadSize must be positive\", ACI, ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), Optional.empty(), 0L),\n        Arguments.argumentSet(\"E164 can't be provided without an unidentified access key\", ACI, ACI_IDENTITY_KEY, Optional.of(NUMBER), Optional.empty(), Optional.empty(), 4L),\n        Arguments.argumentSet(\"An unidentified access key can't be provided without an E164\", ACI, ACI_IDENTITY_KEY, Optional.empty(), Optional.of(UNIDENTIFIED_ACCESS_KEY), Optional.empty(), 4L)\n      );\n  }\n\n  @Test\n  void searchRateLimited() {\n    MockUtils.updateRateLimiterResponseToFail(\n        rateLimiters, RateLimiters.For.KEY_TRANSPARENCY_SEARCH_PER_IP, \"127.0.0.1\", Duration.ofMinutes(10));\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/search\")\n        .request();\n    try (Response response = request.post(\n        Entity.json(createRequestJson(new KeyTransparencySearchRequest(ACI, Optional.empty(), Optional.empty(),\n            ACI_IDENTITY_KEY, Optional.empty(), Optional.empty(), 4L))))) {\n      assertEquals(429, response.getStatus());\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  @Test\n  void monitorSuccess() {\n    when(keyTransparencyServiceClient.monitor(any(), any(), any(), anyLong(), anyLong()))\n        .thenReturn(MonitorResponse.getDefaultInstance());\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/monitor\")\n        .request();\n\n    try (Response response = request.post(Entity.json(\n        createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 0, COMMITMENT_INDEX),\n                Optional.empty(), Optional.empty(), 3L, 4L))))) {\n      assertEquals(200, response.getStatus());\n\n      final KeyTransparencyMonitorResponse keyTransparencyMonitorResponse = response.readEntity(\n          KeyTransparencyMonitorResponse.class);\n      assertNotNull(keyTransparencyMonitorResponse.serializedResponse());\n\n      verify(keyTransparencyServiceClient, times(1)).monitor(\n          any(), any(), any(), eq(3L), eq(4L));\n    }\n  }\n\n  @Test\n  void monitorAuthenticated() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/monitor\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n    try (Response response = request.post(\n        Entity.json(createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 3, COMMITMENT_INDEX),\n                Optional.empty(), Optional.empty(), 3L, 4L))))) {\n      assertEquals(400, response.getStatus());\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void monitorGrpcErrors(final Status grpcStatus, final int httpStatus) {\n    when(keyTransparencyServiceClient.monitor(any(), any(), any(), anyLong(), anyLong()))\n        .thenThrow(new StatusRuntimeException(grpcStatus));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/monitor\")\n        .request();\n    try (Response response = request.post(\n        Entity.json(createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 3, COMMITMENT_INDEX),\n                Optional.empty(), Optional.empty(), 3L, 4L))))) {\n      assertEquals(httpStatus, response.getStatus());\n      verify(keyTransparencyServiceClient, times(1)).monitor(any(), any(), any(), anyLong(), anyLong());\n    }\n  }\n\n  private static Stream<Arguments> monitorGrpcErrors() {\n    return Stream.of(\n        Arguments.of(Status.NOT_FOUND, 404),\n        Arguments.of(Status.PERMISSION_DENIED, 403),\n        Arguments.of(Status.INVALID_ARGUMENT, 422),\n        Arguments.of(Status.UNKNOWN, 500)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void monitorInvalidRequest(final String requestJson) {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/monitor\")\n        .request();\n    try (Response response = request.post(Entity.json(requestJson))) {\n      assertEquals(422, response.getStatus());\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  private static Stream<Arguments> monitorInvalidRequest() {\n    return Stream.of(\n        Arguments.argumentSet(\"aci monitor cannot be null\", createRequestJson(\n            new KeyTransparencyMonitorRequest(null, Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"aci monitor fields can't be null - null value and commitment index\", createRequestJson(\n            new KeyTransparencyMonitorRequest(new KeyTransparencyMonitorRequest.AciMonitor(null, 4, null),\n                Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"aci monitor fields can't be null - null value\", createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(null, 4, COMMITMENT_INDEX),\n                Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"aci monitor fields can't be null - null commitment index\", createRequestJson(\n            new KeyTransparencyMonitorRequest(new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, null),\n                Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"aciPosition must be non-negative\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, -1, COMMITMENT_INDEX),\n            Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"aci commitment index must be the correct size - too small\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, new byte[0]),\n            Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"aci commitment index must be the correct size - too large\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 0, new byte[33]),\n            Optional.empty(), Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"username monitor fields cannot be null - null value and commitment index\", createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX), Optional.empty(),\n                Optional.of(new KeyTransparencyMonitorRequest.UsernameHashMonitor(null, 5, null)),\n                3L, 4L))),\n        Arguments.argumentSet(\"username monitor fields cannot be null - null value\", createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX), Optional.empty(),\n                Optional.of(new KeyTransparencyMonitorRequest.UsernameHashMonitor(null, 5, COMMITMENT_INDEX)),\n                3L, 4L))),\n        Arguments.argumentSet(\"username monitor fields cannot be null - null commitment index\", createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX), Optional.empty(),\n                Optional.of(new KeyTransparencyMonitorRequest.UsernameHashMonitor(USERNAME_HASH, 5, null)),\n                3L, 4L))),\n        Arguments.argumentSet(\"usernameHashPosition must be non-negative\", createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n                Optional.empty(),\n                Optional.of(new KeyTransparencyMonitorRequest.UsernameHashMonitor(USERNAME_HASH,\n                    -1, COMMITMENT_INDEX)), 3L, 4L))),\n        Arguments.argumentSet(\"username commitment index must be the correct size - too small\", createRequestJson(\n            new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, new byte[0]),\n                Optional.empty(),\n                Optional.of(new KeyTransparencyMonitorRequest.UsernameHashMonitor(USERNAME_HASH,\n                    5, new byte[0])), 3L, 4L))),\n        Arguments.argumentSet(\"username commitment index must be the correct size - too large\", createRequestJson(\n            new KeyTransparencyMonitorRequest(new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, null),\n                Optional.empty(),\n                Optional.of(new KeyTransparencyMonitorRequest.UsernameHashMonitor(USERNAME_HASH,\n                    5, new byte[33])), 3L, 4L))),\n        Arguments.argumentSet(\"e164 fields cannot be null - null value and commitment index\",\n            createRequestJson(new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n                Optional.of(new KeyTransparencyMonitorRequest.E164Monitor(null, 5, null)),\n                Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"e164 fields cannot be null - null value\",\n            createRequestJson(new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n                Optional.of(new KeyTransparencyMonitorRequest.E164Monitor(null, 5, COMMITMENT_INDEX)),\n                Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"e164 fields cannot be null - null commitment index\",\n            createRequestJson(new KeyTransparencyMonitorRequest(\n                new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n                Optional.of(new KeyTransparencyMonitorRequest.E164Monitor(NUMBER, 5, null)),\n                Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"e164Position must be non-negative\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n            Optional.of(\n                new KeyTransparencyMonitorRequest.E164Monitor(NUMBER, -1, COMMITMENT_INDEX)),\n            Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"e164 commitment index must be the correct size - too small\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n            Optional.of(\n                new KeyTransparencyMonitorRequest.E164Monitor(NUMBER, 5, new byte[0])),\n            Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"e164 commitment index must be the correct size - too large\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX),\n            Optional.of(\n                new KeyTransparencyMonitorRequest.E164Monitor(NUMBER, 5, new byte[33])),\n            Optional.empty(), 3L, 4L))),\n        Arguments.argumentSet(\"lastNonDistinguishedTreeHeadSize must be positive\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX), Optional.empty(),\n            Optional.empty(), 0L, 4L))),\n        Arguments.argumentSet(\"lastDistinguishedTreeHeadSize must be positive\", createRequestJson(new KeyTransparencyMonitorRequest(\n            new KeyTransparencyMonitorRequest.AciMonitor(ACI, 4, COMMITMENT_INDEX), Optional.empty(),\n            Optional.empty(), 3L, 0L)))\n    );\n  }\n\n  @Test\n  void monitorRateLimited() {\n    MockUtils.updateRateLimiterResponseToFail(\n        rateLimiters, RateLimiters.For.KEY_TRANSPARENCY_MONITOR_PER_IP, \"127.0.0.1\", Duration.ofMinutes(10));\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/monitor\")\n        .request();\n    try (Response response = request.post(\n        Entity.json(createRequestJson(\n            new KeyTransparencyMonitorRequest(new KeyTransparencyMonitorRequest.AciMonitor(ACI, 3, null),\n                Optional.empty(), Optional.empty(),\n                3L, 4L))))) {\n      assertEquals(429, response.getStatus());\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  @ParameterizedTest\n  @CsvSource(\", 1\")\n  void distinguishedSuccess(@Nullable Long lastTreeHeadSize) {\n    when(keyTransparencyServiceClient.getDistinguishedKey(any()))\n        .thenReturn(DistinguishedResponse.getDefaultInstance());\n\n    WebTarget webTarget = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/distinguished\");\n\n    if (lastTreeHeadSize != null) {\n      webTarget = webTarget.queryParam(\"lastTreeHeadSize\", lastTreeHeadSize);\n    }\n\n    try (Response response = webTarget.request().get()) {\n      assertEquals(200, response.getStatus());\n\n      final KeyTransparencyDistinguishedKeyResponse distinguishedKeyResponse = response.readEntity(\n          KeyTransparencyDistinguishedKeyResponse.class);\n      assertNotNull(distinguishedKeyResponse.serializedResponse());\n\n      verify(keyTransparencyServiceClient, times(1))\n          .getDistinguishedKey(eq(Optional.ofNullable(lastTreeHeadSize)));\n    }\n  }\n\n  @Test\n  void distinguishedAuthenticated() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/distinguished\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD));\n    try (Response response = request.get()) {\n      assertEquals(400, response.getStatus());\n    }\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void distinguishedGrpcErrors(final Status grpcStatus, final int httpStatus) {\n    when(keyTransparencyServiceClient.getDistinguishedKey(any()))\n        .thenThrow(new StatusRuntimeException(grpcStatus));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/distinguished\")\n        .request();\n    try (Response response = request.get()) {\n      assertEquals(httpStatus, response.getStatus());\n      verify(keyTransparencyServiceClient).getDistinguishedKey(any());\n    }\n  }\n\n  private static Stream<Arguments> distinguishedGrpcErrors() {\n    return Stream.of(\n        Arguments.of(Status.NOT_FOUND, 404),\n        Arguments.of(Status.PERMISSION_DENIED, 403),\n        Arguments.of(Status.INVALID_ARGUMENT, 422),\n        Arguments.of(Status.UNKNOWN, 500)\n    );\n  }\n\n  @Test\n  void distinguishedInvalidRequest() {\n    when(keyTransparencyServiceClient.getDistinguishedKey(any()))\n        .thenReturn(DistinguishedResponse.getDefaultInstance());\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/distinguished\")\n        .queryParam(\"lastTreeHeadSize\", -1)\n        .request();\n\n    try (Response response = request.get()) {\n      assertEquals(400, response.getStatus());\n\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  @Test\n  void distinguishedRateLimited() {\n    MockUtils.updateRateLimiterResponseToFail(\n        rateLimiters, RateLimiters.For.KEY_TRANSPARENCY_DISTINGUISHED_PER_IP, \"127.0.0.1\", Duration.ofMinutes(10)\n    );\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/key-transparency/distinguished\")\n        .request();\n    try (Response response = request.get()) {\n      assertEquals(429, response.getStatus());\n      verifyNoInteractions(keyTransparencyServiceClient);\n    }\n  }\n\n  private static String createRequestJson(final Object request) {\n    try {\n      return SystemMapper.jsonMapper().writeValueAsString(request);\n    } catch (final JsonProcessingException e) {\n      throw new UncheckedIOException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/KeysControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.clearInvocations;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.ByteBuffer;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.CheckKeysRequest;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.PreKeyCount;\nimport org.whispersystems.textsecuregcm.entities.PreKeyResponse;\nimport org.whispersystems.textsecuregcm.entities.PreKeyResponseItem;\nimport org.whispersystems.textsecuregcm.entities.SetKeysRequest;\nimport org.whispersystems.textsecuregcm.entities.SignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.ServerRejectedExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.ByteArrayAdapter;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass KeysControllerTest {\n\n  private static final String EXISTS_NUMBER = \"+14152222222\";\n  private static final UUID   EXISTS_UUID   = UUID.randomUUID();\n  private static final UUID   EXISTS_PNI    = UUID.randomUUID();\n  private static final AciServiceIdentifier EXISTS_ACI = new AciServiceIdentifier(EXISTS_UUID);\n  private static final PniServiceIdentifier EXISTS_PNI_SERVICE_ID = new PniServiceIdentifier(EXISTS_PNI);\n\n  private static final UUID   OTHER_UUID   = UUID.randomUUID();\n  private static final AciServiceIdentifier OTHER_ACI = new AciServiceIdentifier(OTHER_UUID);\n\n  private static final UUID   NOT_EXISTS_UUID   = UUID.randomUUID();\n  private static final AciServiceIdentifier NOT_EXISTS_ACI = new AciServiceIdentifier(NOT_EXISTS_UUID);\n\n  private static final byte SAMPLE_DEVICE_ID = 1;\n\n  private static final int SAMPLE_REGISTRATION_ID  =  999;\n  private static final int SAMPLE_PNI_REGISTRATION_ID = 1717;\n\n  private final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n  private final IdentityKey IDENTITY_KEY = new IdentityKey(IDENTITY_KEY_PAIR.getPublicKey());\n\n  private final ECKeyPair PNI_IDENTITY_KEY_PAIR = ECKeyPair.generate();\n  private final IdentityKey PNI_IDENTITY_KEY = new IdentityKey(PNI_IDENTITY_KEY_PAIR.getPublicKey());\n\n  private final ECPreKey SAMPLE_KEY = KeysHelper.ecPreKey(1234);\n  private final ECPreKey SAMPLE_KEY_PNI = KeysHelper.ecPreKey(7777);\n\n  private final KEMSignedPreKey SAMPLE_PQ_KEY = KeysHelper.signedKEMPreKey(2424, ECKeyPair.generate());\n  private final KEMSignedPreKey SAMPLE_PQ_KEY_PNI = KeysHelper.signedKEMPreKey(8888, ECKeyPair.generate());\n\n  private final ECSignedPreKey SAMPLE_SIGNED_KEY = KeysHelper.signedECPreKey(1111, IDENTITY_KEY_PAIR);\n  private final ECSignedPreKey SAMPLE_SIGNED_PNI_KEY = KeysHelper.signedECPreKey(5555, PNI_IDENTITY_KEY_PAIR);\n\n\n  private final static KeysManager KEYS = mock(KeysManager.class);\n  private final static AccountsManager accounts = mock(AccountsManager.class);\n  private final static Account existsAccount = mock(Account.class);\n\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter rateLimiter = mock(RateLimiter.class);\n\n  private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n\n  private static final TestClock clock = TestClock.now();\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(CompletionExceptionMapper.class)\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new ServerRejectedExceptionMapper())\n      .addResource(new KeysController(rateLimiters, KEYS, accounts, serverSecretParams, clock))\n      .addResource(new RateLimitExceededExceptionMapper())\n      .build();\n\n  private record WeaklyTypedPreKey(long keyId,\n\n                                   @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                                   @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                   byte[] publicKey) {\n  }\n\n  private record WeaklyTypedSignedPreKey(long keyId,\n\n                                         @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                                         @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                         byte[] publicKey,\n\n                                         @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                                         @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                         byte[] signature) {\n\n    static WeaklyTypedSignedPreKey fromSignedPreKey(final SignedPreKey<?> signedPreKey) {\n      return new WeaklyTypedSignedPreKey(signedPreKey.keyId(), signedPreKey.serializedPublicKey(), signedPreKey.signature());\n    }\n  }\n\n  private record WeaklyTypedPreKeyState(List<WeaklyTypedPreKey> preKeys,\n                                        WeaklyTypedSignedPreKey signedPreKey,\n                                        List<WeaklyTypedSignedPreKey> pqPreKeys,\n                                        WeaklyTypedSignedPreKey pqLastResortPreKey,\n\n                                        @JsonSerialize(using = ByteArrayAdapter.Serializing.class)\n                                        @JsonDeserialize(using = ByteArrayAdapter.Deserializing.class)\n                                        byte[] identityKey) {\n  }\n\n  private Device createSampleDevice(byte deviceId, int registrationId, int pniRegistrationId) {\n    final Device sampleDevice = mock(Device.class);\n    when(sampleDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n    when(sampleDevice.getRegistrationId(IdentityType.PNI)).thenReturn(pniRegistrationId);\n    when(sampleDevice.getId()).thenReturn(deviceId);\n\n    return sampleDevice;\n  }\n\n  @BeforeEach\n  void setup() {\n    clock.unpin();\n\n    AccountsHelper.setupMockUpdate(accounts);\n\n    final Device sampleDevice =\n        createSampleDevice(SAMPLE_DEVICE_ID, SAMPLE_REGISTRATION_ID, SAMPLE_PNI_REGISTRATION_ID);\n\n    final KeysManager.DevicePreKeys aciKeys =\n        new KeysManager.DevicePreKeys(SAMPLE_SIGNED_KEY, Optional.of(SAMPLE_KEY), SAMPLE_PQ_KEY);\n    final KeysManager.DevicePreKeys pniKeys =\n        new KeysManager.DevicePreKeys(SAMPLE_SIGNED_PNI_KEY, Optional.of(SAMPLE_KEY_PNI), SAMPLE_PQ_KEY_PNI);\n\n    when(KEYS.takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_ACI), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(aciKeys)));\n    when(KEYS.takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_PNI_SERVICE_ID), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(pniKeys)));\n\n    when(existsAccount.getUuid()).thenReturn(EXISTS_UUID);\n    when(existsAccount.isIdentifiedBy(new AciServiceIdentifier(EXISTS_UUID))).thenReturn(true);\n    when(existsAccount.getPhoneNumberIdentifier()).thenReturn(EXISTS_PNI);\n    when(existsAccount.isIdentifiedBy(new PniServiceIdentifier(EXISTS_PNI))).thenReturn(true);\n    when(existsAccount.getIdentifier(IdentityType.ACI)).thenReturn(EXISTS_UUID);\n    when(existsAccount.getIdentifier(IdentityType.PNI)).thenReturn(EXISTS_PNI);\n    when(existsAccount.getDevice(SAMPLE_DEVICE_ID)).thenReturn(Optional.of(sampleDevice));\n    when(existsAccount.getDevices()).thenReturn(List.of(sampleDevice));\n    when(existsAccount.getIdentityKey(IdentityType.ACI)).thenReturn(IDENTITY_KEY);\n    when(existsAccount.getIdentityKey(IdentityType.PNI)).thenReturn(PNI_IDENTITY_KEY);\n    when(existsAccount.getNumber()).thenReturn(EXISTS_NUMBER);\n    when(existsAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(\"1337\".getBytes()));\n\n    when(accounts.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n    when(accounts.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(accounts.getByServiceIdentifier(new AciServiceIdentifier(EXISTS_UUID))).thenReturn(Optional.of(existsAccount));\n    when(accounts.getByServiceIdentifier(new PniServiceIdentifier(EXISTS_PNI))).thenReturn(Optional.of(existsAccount));\n\n    when(accounts.getByServiceIdentifierAsync(new AciServiceIdentifier(EXISTS_UUID)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(existsAccount)));\n\n    when(accounts.getByServiceIdentifierAsync(new PniServiceIdentifier(EXISTS_PNI)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(existsAccount)));\n\n    when(accounts.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n    when(accounts.getByAccountIdentifierAsync(AuthHelper.VALID_UUID))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT)));\n\n    when(rateLimiters.getPreKeysLimiter()).thenReturn(rateLimiter);\n\n    when(KEYS.storeEcOneTimePreKeys(any(), anyByte(), any()))\n        .thenReturn(CompletableFutureTestUtil.almostCompletedFuture(null));\n    when(KEYS.storeKemOneTimePreKeys(any(), anyByte(), any()))\n        .thenReturn(CompletableFutureTestUtil.almostCompletedFuture(null));\n    when(KEYS.storePqLastResort(any(), anyByte(), any()))\n        .thenReturn(CompletableFutureTestUtil.almostCompletedFuture(null));\n    when(KEYS.storeEcSignedPreKeys(any(), anyByte(), any()))\n        .thenReturn(CompletableFutureTestUtil.almostCompletedFuture(null));\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(\n        KEYS,\n        accounts,\n        existsAccount,\n        rateLimiters,\n        rateLimiter\n    );\n\n    clearInvocations(AuthHelper.VALID_DEVICE);\n  }\n\n  @Test\n  void validKeyStatusTest() {\n    when(KEYS.getEcCount(AuthHelper.VALID_UUID, SAMPLE_DEVICE_ID)).thenReturn(CompletableFuture.completedFuture(5));\n    when(KEYS.getPqCount(AuthHelper.VALID_UUID, SAMPLE_DEVICE_ID)).thenReturn(CompletableFuture.completedFuture(5));\n    when(KEYS.getEcSignedPreKey(AuthHelper.VALID_UUID, AuthHelper.VALID_DEVICE.getId()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(KeysHelper.signedECPreKey(123, IDENTITY_KEY_PAIR))));\n\n    PreKeyCount result = resources.getJerseyTest()\n                                  .target(\"/v2/keys\")\n                                  .request()\n                                  .header(\"Authorization\",\n                                          AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                                  .get(PreKeyCount.class);\n\n    assertThat(result.getCount()).isEqualTo(5);\n    assertThat(result.getPqCount()).isEqualTo(5);\n\n    verify(KEYS).getEcCount(AuthHelper.VALID_UUID, SAMPLE_DEVICE_ID);\n    verify(KEYS).getPqCount(AuthHelper.VALID_UUID, SAMPLE_DEVICE_ID);\n  }\n\n  @Test\n  void validSingleRequestTestV2() {\n    PreKeyResponse result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(PreKeyResponse.class);\n\n    assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.ACI));\n    assertThat(result.getDevicesCount()).isEqualTo(1);\n    assertEquals(SAMPLE_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey()).isEqualTo(SAMPLE_PQ_KEY);\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getRegistrationId()).isEqualTo(SAMPLE_REGISTRATION_ID);\n    assertEquals(SAMPLE_SIGNED_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());\n\n    verify(KEYS).takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_ACI), any());\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void validSingleRequestPqTestV2() {\n    PreKeyResponse result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n        .queryParam(\"pq\", \"true\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(PreKeyResponse.class);\n\n    assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.ACI));\n    assertThat(result.getDevicesCount()).isEqualTo(1);\n    assertEquals(SAMPLE_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());\n    assertEquals(SAMPLE_PQ_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey());\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getRegistrationId()).isEqualTo(SAMPLE_REGISTRATION_ID);\n    assertEquals(SAMPLE_SIGNED_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());\n\n    verify(KEYS).takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_ACI), any());\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void validSingleRequestByPhoneNumberIdentifierTestV2() {\n    PreKeyResponse result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/PNI:%s/1\", EXISTS_PNI))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(PreKeyResponse.class);\n\n    assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.PNI));\n    assertThat(result.getDevicesCount()).isEqualTo(1);\n    assertEquals(SAMPLE_KEY_PNI, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey()).isEqualTo(SAMPLE_PQ_KEY_PNI);\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getRegistrationId()).isEqualTo(SAMPLE_PNI_REGISTRATION_ID);\n    assertEquals(SAMPLE_SIGNED_PNI_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());\n\n    verify(KEYS).takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_PNI_SERVICE_ID), any());\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void validSingleRequestPqByPhoneNumberIdentifierTestV2() {\n    PreKeyResponse result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/PNI:%s/1\", EXISTS_PNI))\n        .queryParam(\"pq\", \"true\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(PreKeyResponse.class);\n\n    assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.PNI));\n    assertThat(result.getDevicesCount()).isEqualTo(1);\n    assertEquals(SAMPLE_KEY_PNI, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey()).isEqualTo(SAMPLE_PQ_KEY_PNI);\n    assertThat(result.getDevice(SAMPLE_DEVICE_ID).getRegistrationId()).isEqualTo(SAMPLE_PNI_REGISTRATION_ID);\n    assertEquals(SAMPLE_SIGNED_PNI_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());\n\n    verify(KEYS).takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_PNI_SERVICE_ID), any());\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void testGetKeysRateLimited() throws RateLimitExceededException {\n    Duration retryAfter = Duration.ofSeconds(31);\n    doThrow(new RateLimitExceededException(retryAfter)).when(rateLimiter).validate(anyString());\n\n    Response result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/PNI:%s/*\", EXISTS_PNI))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get();\n\n    final String expectedRatelimitKey = String.format(\"%s.%s__%s.%s.%s\",\n        AuthHelper.VALID_UUID,\n        AuthHelper.VALID_DEVICE.getId(),\n        EXISTS_PNI,\n        \"*\",\n        \"*\");\n\n    assertThat(result.getStatus()).isEqualTo(429);\n    assertThat(result.getHeaderString(\"Retry-After\")).isEqualTo(String.valueOf(retryAfter.toSeconds()));\n    verify(rateLimiter).validate(expectedRatelimitKey);\n  }\n\n  @Test\n  void testUnidentifiedRequest() {\n    PreKeyResponse result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n        .queryParam(\"pq\", \"true\")\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"1337\".getBytes()))\n        .get(PreKeyResponse.class);\n\n    assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.ACI));\n    assertThat(result.getDevicesCount()).isEqualTo(1);\n    assertEquals(SAMPLE_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());\n    assertEquals(SAMPLE_PQ_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey());\n    assertEquals(SAMPLE_SIGNED_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());\n\n    verify(KEYS).takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_ACI), any());\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void testNoKeysForDevice() {\n    final List<Device> devices = List.of(\n        createSampleDevice((byte) 1, 2, 3),\n        createSampleDevice((byte) 4, 5, 6));\n    // device 1 is missing required prekeys, device 4 is missing an optional EC prekey\n    when(KEYS.takeDevicePreKeys(eq((byte) 4), eq(EXISTS_PNI_SERVICE_ID), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(\n            new KeysManager.DevicePreKeys(SAMPLE_SIGNED_PNI_KEY, Optional.empty(), SAMPLE_PQ_KEY_PNI))));\n    when(KEYS.takeDevicePreKeys(eq((byte) 1), eq(EXISTS_PNI_SERVICE_ID), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(existsAccount.getDevice((byte) 1)).thenReturn(Optional.of(devices.get(0)));\n    when(existsAccount.getDevice((byte) 4)).thenReturn(Optional.of(devices.get(1)));\n    when(existsAccount.getDevices()).thenReturn(devices);\n\n    PreKeyResponse results = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/PNI:%s/*\", EXISTS_PNI))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(PreKeyResponse.class);\n\n    // Should drop device 1 and keep device 4\n    assertThat(results.getDevicesCount()).isEqualTo(1);\n    final PreKeyResponseItem result = results.getDevice((byte) 4);\n    assertEquals(6, result.getRegistrationId());\n    assertNull(result.getPreKey());\n    assertEquals(SAMPLE_SIGNED_PNI_KEY, result.getSignedPreKey());\n    assertEquals(SAMPLE_PQ_KEY_PNI, result.getPqPreKey());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testGetKeysWithGroupSendEndorsement(\n      ServiceIdentifier target, ServiceIdentifier authorizedTarget, Duration timeLeft, boolean includeUak, int expectedResponse) throws Exception {\n\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    clock.pin(expiration.minus(timeLeft));\n\n    Invocation.Builder builder = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/%s/1\", target.toServiceIdentifierString()))\n        .queryParam(\"pq\", \"true\")\n        .request()\n        .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedTarget), expiration));\n\n    if (includeUak) {\n      builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"1337\".getBytes()));\n    }\n\n    Response response = builder.get();\n    assertThat(response.getStatus()).isEqualTo(expectedResponse);\n\n    if (expectedResponse == 200) {\n      PreKeyResponse result = response.readEntity(PreKeyResponse.class);\n\n      assertThat(result.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(IdentityType.ACI));\n      assertThat(result.getDevicesCount()).isEqualTo(1);\n      assertEquals(SAMPLE_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPreKey());\n      assertEquals(SAMPLE_PQ_KEY, result.getDevice(SAMPLE_DEVICE_ID).getPqPreKey());\n      assertEquals(SAMPLE_SIGNED_KEY, result.getDevice(SAMPLE_DEVICE_ID).getSignedPreKey());\n\n      verify(KEYS).takeDevicePreKeys(eq(SAMPLE_DEVICE_ID), eq(EXISTS_ACI), any());\n    }\n\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  private static Stream<Arguments> testGetKeysWithGroupSendEndorsement() {\n    return Stream.of(\n        // valid endorsement\n        Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(1), false, 200),\n\n        // expired endorsement, not authorized\n        Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(-1), false, 401),\n\n        // endorsement for the wrong recipient, not authorized\n        Arguments.of(EXISTS_ACI, OTHER_ACI, Duration.ofHours(1), false, 401),\n\n        // expired endorsement for the wrong recipient, not authorized\n        Arguments.of(EXISTS_ACI, OTHER_ACI, Duration.ofHours(-1), false, 401),\n\n        // valid endorsement for the right recipient but they aren't registered, not found\n        Arguments.of(NOT_EXISTS_ACI, NOT_EXISTS_ACI, Duration.ofHours(1), false, 404),\n\n        // expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found)\n        Arguments.of(NOT_EXISTS_ACI, NOT_EXISTS_ACI, Duration.ofHours(-1), false, 401),\n\n        // valid endorsement but also a UAK, bad request\n        Arguments.of(EXISTS_ACI, EXISTS_ACI, Duration.ofHours(1), true, 400));\n  }\n\n  @Test\n  void testNoDevices() {\n\n    when(existsAccount.getDevices()).thenReturn(Collections.emptyList());\n\n    Response result = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/%s/*\", EXISTS_UUID))\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"1337\".getBytes()))\n        .get();\n\n    assertThat(result).isNotNull();\n    assertThat(result.getStatus()).isEqualTo(404);\n  }\n\n  @Test\n  void testUnauthorizedUnidentifiedRequest() {\n    Response response = resources.getJerseyTest()\n                                     .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n                                     .request()\n                                     .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"9999\".getBytes()))\n                                     .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void testMalformedUnidentifiedRequest() {\n    Response response = resources.getJerseyTest()\n                                 .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n                                 .request()\n                                 .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, \"$$$$$$$$$\")\n                                 .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n    verifyNoMoreInteractions(KEYS);\n  }\n\n\n  @ParameterizedTest\n  @EnumSource\n  void validMultiRequestTestV2(IdentityType identityType) {\n    final ServiceIdentifier serviceIdentifier = switch (identityType) {\n      case ACI -> new AciServiceIdentifier(EXISTS_UUID);\n      case PNI -> new PniServiceIdentifier(EXISTS_PNI);\n    };\n\n    final List<Device> devices = new ArrayList<>();\n    final List<KeysManager.DevicePreKeys> devicePreKeys = new ArrayList<>();\n    for (int i = 0; i < 4; i++) {\n      devices.add(createSampleDevice((byte) i, i + 100, i + 200));\n\n      final ECSignedPreKey signedEcPreKey = KeysHelper.signedECPreKey(i + 300, IDENTITY_KEY_PAIR);\n      final ECPreKey ecPreKey = KeysHelper.ecPreKey(i + 400);\n      final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(i + 500, ECKeyPair.generate());\n      devicePreKeys.add(new KeysManager.DevicePreKeys(signedEcPreKey, Optional.of(ecPreKey), kemSignedPreKey));\n\n      when(existsAccount.getDevice((byte) i)).thenReturn(Optional.of(devices.getLast()));\n      when(KEYS.takeDevicePreKeys(eq((byte) i), eq(serviceIdentifier), any()))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(devicePreKeys.getLast())));\n    }\n    when(existsAccount.getDevices()).thenReturn(devices);\n\n    PreKeyResponse results = resources.getJerseyTest()\n        .target(String.format(\"/v2/keys/%s/*\", serviceIdentifier.toServiceIdentifierString()))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(PreKeyResponse.class);\n\n    assertThat(results.getDevicesCount()).isEqualTo(4);\n    assertThat(results.getIdentityKey()).isEqualTo(existsAccount.getIdentityKey(identityType));\n\n    for (int i = 0; i < 4; i++) {\n      final PreKeyResponseItem result = results.getDevice((byte) i);\n      final KeysManager.DevicePreKeys expectedPreKeys = devicePreKeys.get(i);\n      final Device expectedDevice = devices.get(i);\n\n      assertEquals(expectedDevice.getRegistrationId(identityType), result.getRegistrationId());\n      assertEquals(expectedPreKeys.ecPreKey().orElseThrow(), result.getPreKey());\n      assertEquals(expectedPreKeys.ecSignedPreKey(), result.getSignedPreKey());\n      assertEquals(expectedPreKeys.kemSignedPreKey(), result.getPqPreKey());\n\n      verify(KEYS).takeDevicePreKeys(eq((byte) i), eq(serviceIdentifier), any());\n    }\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void invalidRequestTestV2() {\n    Response response = resources.getJerseyTest()\n                                 .target(String.format(\"/v2/keys/%s\", NOT_EXISTS_UUID))\n                                 .request()\n                                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                                 .get();\n\n    assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(404);\n  }\n\n  @Test\n  void anotherInvalidRequestTestV2() {\n    when(existsAccount.getDevice((byte) 22)).thenReturn(Optional.empty());\n    Response response = resources.getJerseyTest()\n                                 .target(String.format(\"/v2/keys/%s/22\", EXISTS_UUID))\n                                 .request()\n                                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                                 .get();\n\n    assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(404);\n  }\n\n  @Test\n  void unauthorizedRequestTestV2() {\n    Response response =\n        resources.getJerseyTest()\n                 .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))\n                 .get();\n\n    assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(401);\n\n    response =\n        resources.getJerseyTest()\n                 .target(String.format(\"/v2/keys/%s/1\", EXISTS_UUID))\n                 .request()\n                 .get();\n\n    assertThat(response.getStatusInfo().getStatusCode()).isEqualTo(401);\n  }\n\n  @Test\n  void putKeysTestV2() {\n    final ECPreKey preKey = KeysHelper.ecPreKey(31337);\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(31338, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n\n    final SetKeysRequest setKeysRequest = new SetKeysRequest(List.of(preKey), signedPreKey, null, null);\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(204);\n\n    ArgumentCaptor<List<ECPreKey>> listCaptor = ArgumentCaptor.forClass(List.class);\n\n    verify(KEYS).storeEcOneTimePreKeys(eq(AuthHelper.VALID_UUID), eq(SAMPLE_DEVICE_ID), listCaptor.capture());\n\n    assertThat(listCaptor.getValue()).containsExactly(preKey);\n\n    verify(KEYS).storeEcSignedPreKeys(AuthHelper.VALID_UUID, AuthHelper.VALID_DEVICE.getId(), signedPreKey);\n  }\n\n  @Test\n  void putKeysTestV2EmptySingleUseKeysList() {\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(31338, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n\n    final SetKeysRequest setKeysRequest = new SetKeysRequest(List.of(), signedPreKey, List.of(), null);\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(204);\n\n      verify(KEYS, never()).storeEcOneTimePreKeys(any(), anyByte(), any());\n      verify(KEYS, never()).storeKemOneTimePreKeys(any(), anyByte(), any());\n      verify(KEYS).storeEcSignedPreKeys(AuthHelper.VALID_UUID, AuthHelper.VALID_DEVICE.getId(), signedPreKey);\n    }\n  }\n\n  @Test\n  void putKeysPqTestV2() {\n    final ECPreKey preKey = KeysHelper.ecPreKey(31337);\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(31338, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey pqPreKey = KeysHelper.signedKEMPreKey(31339, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey pqLastResortPreKey = KeysHelper.signedKEMPreKey(31340, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n\n    final SetKeysRequest setKeysRequest =\n        new SetKeysRequest(List.of(preKey), signedPreKey, List.of(pqPreKey), pqLastResortPreKey);\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(204);\n\n    ArgumentCaptor<List<ECPreKey>> ecCaptor = ArgumentCaptor.forClass(List.class);\n    ArgumentCaptor<List<KEMSignedPreKey>> pqCaptor = ArgumentCaptor.forClass(List.class);\n    verify(KEYS).storeEcOneTimePreKeys(eq(AuthHelper.VALID_UUID), eq(SAMPLE_DEVICE_ID), ecCaptor.capture());\n    verify(KEYS).storeKemOneTimePreKeys(eq(AuthHelper.VALID_UUID), eq(SAMPLE_DEVICE_ID), pqCaptor.capture());\n    verify(KEYS).storePqLastResort(AuthHelper.VALID_UUID, SAMPLE_DEVICE_ID, pqLastResortPreKey);\n\n    assertThat(ecCaptor.getValue()).containsExactly(preKey);\n    assertThat(pqCaptor.getValue()).containsExactly(pqPreKey);\n\n    verify(KEYS).storeEcSignedPreKeys(AuthHelper.VALID_UUID, AuthHelper.VALID_DEVICE.getId(), signedPreKey);\n  }\n\n  @Test\n  void putKeysStructurallyInvalidSignedECKey() {\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final KEMSignedPreKey wrongPreKey = KeysHelper.signedKEMPreKey(1, identityKeyPair);\n    final WeaklyTypedPreKeyState preKeyState =\n        new WeaklyTypedPreKeyState(null, WeaklyTypedSignedPreKey.fromSignedPreKey(wrongPreKey), null, null, identityKey.serialize());\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  void putKeysStructurallyInvalidUnsignedECKey() {\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final WeaklyTypedPreKey wrongPreKey = new WeaklyTypedPreKey(1, \"cluck cluck i'm a parrot\".getBytes());\n    final WeaklyTypedPreKeyState preKeyState =\n        new WeaklyTypedPreKeyState(List.of(wrongPreKey), null, null, null, identityKey.serialize());\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  void putKeysStructurallyInvalidPQOneTimeKey() {\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final WeaklyTypedSignedPreKey wrongPreKey = WeaklyTypedSignedPreKey.fromSignedPreKey(KeysHelper.signedECPreKey(1, identityKeyPair));\n    final WeaklyTypedPreKeyState preKeyState =\n        new WeaklyTypedPreKeyState(null, null, List.of(wrongPreKey), null, identityKey.serialize());\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  void putKeysStructurallyInvalidPQLastResortKey() {\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final WeaklyTypedSignedPreKey wrongPreKey = WeaklyTypedSignedPreKey.fromSignedPreKey(KeysHelper.signedECPreKey(1, identityKeyPair));\n    final WeaklyTypedPreKeyState preKeyState =\n        new WeaklyTypedPreKeyState(null, null, null, wrongPreKey, identityKey.serialize());\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(preKeyState, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  void putKeysTooManySingleUseECKeys() {\n    final List<ECPreKey> preKeys = IntStream.range(31337, 31438).mapToObj(KeysHelper::ecPreKey).toList();\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(31338, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n\n    final SetKeysRequest setKeysRequest = new SetKeysRequest(preKeys, signedPreKey, null, null);\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(422);\n\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void putKeysTooManySingleUseKEMKeys() {\n    final List<KEMSignedPreKey> pqPreKeys = IntStream.range(31337, 31438)\n        .mapToObj(id -> KeysHelper.signedKEMPreKey(id, AuthHelper.VALID_IDENTITY_KEY_PAIR))\n        .toList();\n\n    final SetKeysRequest setKeysRequest = new SetKeysRequest(null, null, pqPreKeys, null);\n\n    Response response =\n        resources.getJerseyTest()\n                 .target(\"/v2/keys\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(422);\n\n    verifyNoMoreInteractions(KEYS);\n  }\n\n  @Test\n  void putKeysByPhoneNumberIdentifierTestV2() {\n    final ECPreKey preKey = KeysHelper.ecPreKey(31337);\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(31338, AuthHelper.VALID_PNI_IDENTITY_KEY_PAIR);\n\n    final SetKeysRequest setKeysRequest = new SetKeysRequest(List.of(preKey), signedPreKey, null, null);\n\n    Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys\")\n            .queryParam(\"identity\", \"pni\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(204);\n\n    ArgumentCaptor<List<ECPreKey>> listCaptor = ArgumentCaptor.forClass(List.class);\n    verify(KEYS).storeEcOneTimePreKeys(eq(AuthHelper.VALID_PNI), eq(SAMPLE_DEVICE_ID), listCaptor.capture());\n\n    assertThat(listCaptor.getValue()).containsExactly(preKey);\n\n    verify(KEYS).storeEcSignedPreKeys(AuthHelper.VALID_PNI, AuthHelper.VALID_DEVICE.getId(), signedPreKey);\n  }\n\n  @Test\n  void putKeysByPhoneNumberIdentifierPqTestV2() {\n    final ECPreKey preKey = KeysHelper.ecPreKey(31337);\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(31338, AuthHelper.VALID_PNI_IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey pqPreKey = KeysHelper.signedKEMPreKey(31339, AuthHelper.VALID_PNI_IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey pqLastResortPreKey = KeysHelper.signedKEMPreKey(31340, AuthHelper.VALID_PNI_IDENTITY_KEY_PAIR);\n\n    final SetKeysRequest setKeysRequest =\n        new SetKeysRequest(List.of(preKey), signedPreKey, List.of(pqPreKey), pqLastResortPreKey);\n\n    Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys\")\n            .queryParam(\"identity\", \"pni\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(204);\n\n    ArgumentCaptor<List<ECPreKey>> ecCaptor = ArgumentCaptor.forClass(List.class);\n    ArgumentCaptor<List<KEMSignedPreKey>> pqCaptor = ArgumentCaptor.forClass(List.class);\n    verify(KEYS).storeEcOneTimePreKeys(eq(AuthHelper.VALID_PNI), eq(SAMPLE_DEVICE_ID), ecCaptor.capture());\n    verify(KEYS).storeKemOneTimePreKeys(eq(AuthHelper.VALID_PNI), eq(SAMPLE_DEVICE_ID), pqCaptor.capture());\n    verify(KEYS).storePqLastResort(AuthHelper.VALID_PNI, SAMPLE_DEVICE_ID, pqLastResortPreKey);\n\n    assertThat(ecCaptor.getValue()).containsExactly(preKey);\n    assertThat(pqCaptor.getValue()).containsExactly(pqPreKey);\n\n    verify(KEYS).storeEcSignedPreKeys(AuthHelper.VALID_PNI, AuthHelper.VALID_DEVICE.getId(), signedPreKey);\n  }\n\n  @Test\n  void putPrekeyWithInvalidSignature() {\n    final ECSignedPreKey badSignedPreKey = KeysHelper.signedECPreKey(1, ECKeyPair.generate());\n    final SetKeysRequest setKeysRequest = new SetKeysRequest(List.of(), badSignedPreKey, null, null);\n    Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys\")\n            .queryParam(\"identity\", \"aci\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(setKeysRequest, MediaType.APPLICATION_JSON_TYPE));\n\n    assertThat(response.getStatus()).isEqualTo(422);\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void checkKeys(\n      final IdentityKey clientIdentityKey,\n      final ECSignedPreKey clientEcSignedPreKey,\n      final Optional<ECSignedPreKey> serverEcSignedPreKey,\n      final KEMSignedPreKey clientLastResortKey,\n      final Optional<KEMSignedPreKey> serverLastResortKey,\n      final int expectedStatus) throws NoSuchAlgorithmException {\n\n    when(KEYS.getEcSignedPreKey(AuthHelper.VALID_UUID, Device.PRIMARY_ID))\n        .thenReturn(CompletableFuture.completedFuture(serverEcSignedPreKey));\n\n    when(KEYS.getLastResort(AuthHelper.VALID_UUID, Device.PRIMARY_ID))\n        .thenReturn(CompletableFuture.completedFuture(serverLastResortKey));\n\n    final CheckKeysRequest checkKeysRequest =\n        new CheckKeysRequest(IdentityType.ACI, getKeyDigest(clientIdentityKey, clientEcSignedPreKey, clientLastResortKey));\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys/check\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .post(Entity.entity(checkKeysRequest, MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(expectedStatus, response.getStatus());\n    }\n  }\n\n  private static List<Arguments> checkKeys() {\n    final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(17, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey lastResortKey = KeysHelper.signedKEMPreKey(19, AuthHelper.VALID_IDENTITY_KEY_PAIR);\n\n    return List.of(\n        // All keys match\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            ecSignedPreKey,\n            Optional.of(ecSignedPreKey),\n            lastResortKey,\n            Optional.of(lastResortKey),\n            200),\n\n        // Signed EC pre-key not found\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            ecSignedPreKey,\n            Optional.empty(),\n            lastResortKey,\n            Optional.of(lastResortKey),\n            409),\n\n        // Last-resort key not found\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            ecSignedPreKey,\n            Optional.of(ecSignedPreKey),\n            lastResortKey,\n            Optional.empty(),\n            409),\n\n        // Mismatched identity key\n        Arguments.of(\n            new IdentityKey(ECKeyPair.generate().getPublicKey()),\n            ecSignedPreKey,\n            Optional.of(ecSignedPreKey),\n            lastResortKey,\n            Optional.of(lastResortKey),\n            409),\n\n        // Mismatched EC signed pre-key ID\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            new ECSignedPreKey(ecSignedPreKey.keyId() + 1, ecSignedPreKey.publicKey(), ecSignedPreKey.signature()),\n            Optional.of(ecSignedPreKey),\n            lastResortKey,\n            Optional.of(lastResortKey),\n            409),\n\n        // Mismatched EC signed pre-key content\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            KeysHelper.signedECPreKey(ecSignedPreKey.keyId(), AuthHelper.VALID_IDENTITY_KEY_PAIR),\n            Optional.of(ecSignedPreKey),\n            lastResortKey,\n            Optional.of(lastResortKey),\n            409),\n        // Mismatched last-resort key ID\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            ecSignedPreKey,\n            Optional.of(ecSignedPreKey),\n            new KEMSignedPreKey(lastResortKey.keyId() + 1, lastResortKey.publicKey(), lastResortKey.signature()),\n            Optional.of(lastResortKey),\n            409),\n\n        // Mismatched last-resort key content\n        Arguments.of(\n            AuthHelper.VALID_IDENTITY,\n            ecSignedPreKey,\n            Optional.of(ecSignedPreKey),\n            KeysHelper.signedKEMPreKey(lastResortKey.keyId(), AuthHelper.VALID_IDENTITY_KEY_PAIR),\n            Optional.of(lastResortKey),\n            409)\n    );\n  }\n\n  private static byte[] getKeyDigest(final IdentityKey identityKey, final ECSignedPreKey ecSignedPreKey, final KEMSignedPreKey lastResortKey)\n      throws NoSuchAlgorithmException {\n\n    final MessageDigest messageDigest = MessageDigest.getInstance(\"SHA-256\");\n    messageDigest.update(identityKey.serialize());\n\n    {\n      final ByteBuffer ecSignedPreKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);\n      ecSignedPreKeyIdBuffer.putLong(ecSignedPreKey.keyId());\n      ecSignedPreKeyIdBuffer.flip();\n\n      messageDigest.update(ecSignedPreKeyIdBuffer);\n      messageDigest.update(ecSignedPreKey.serializedPublicKey());\n    }\n\n    {\n      final ByteBuffer lastResortKeyIdBuffer = ByteBuffer.allocate(Long.BYTES);\n      lastResortKeyIdBuffer.putLong(lastResortKey.keyId());\n      lastResortKeyIdBuffer.flip();\n\n      messageDigest.update(lastResortKeyIdBuffer);\n      messageDigest.update(lastResortKey.serializedPublicKey());\n    }\n\n    return messageDigest.digest();\n  }\n\n  @Test\n  void checkKeysIncorrectDigestLength() {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys/check\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .post(Entity.entity(new CheckKeysRequest(IdentityType.ACI, new byte[31]), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(\"/v2/keys/check\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .post(Entity.entity(new CheckKeysRequest(IdentityType.ACI, new byte[33]), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/MessageControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.hamcrest.CoreMatchers.equalTo;\nimport static org.hamcrest.CoreMatchers.is;\nimport static org.hamcrest.CoreMatchers.not;\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.anyBoolean;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.asJson;\nimport static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture;\n\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.LocalDate;\nimport java.time.ZoneOffset;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.hamcrest.Matcher;\nimport org.junit.jupiter.api.AfterAll;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessageList;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.entities.MismatchedDevicesResponse;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntityList;\nimport org.whispersystems.textsecuregcm.entities.SendMultiRecipientMessageResponse;\nimport org.whispersystems.textsecuregcm.entities.SpamReport;\nimport org.whispersystems.textsecuregcm.entities.StaleDevicesResponse;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.providers.MultiRecipientMessageProvider;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageManager;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.MultiRecipientMessageHelper;\nimport org.whispersystems.textsecuregcm.tests.util.TestRecipient;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.websocket.WebsocketHeaders;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass MessageControllerTest {\n\n  private static final String SINGLE_DEVICE_RECIPIENT = \"+14151111111\";\n  private static final UUID SINGLE_DEVICE_UUID = UUID.randomUUID();\n  private static final AciServiceIdentifier SINGLE_DEVICE_ACI_ID = new AciServiceIdentifier(SINGLE_DEVICE_UUID);\n  private static final UUID SINGLE_DEVICE_PNI = UUID.randomUUID();\n  private static final PniServiceIdentifier SINGLE_DEVICE_PNI_ID = new PniServiceIdentifier(SINGLE_DEVICE_PNI);\n  private static final byte SINGLE_DEVICE_ID1 = 1;\n  private static final int SINGLE_DEVICE_REG_ID1 = 111;\n  private static final int SINGLE_DEVICE_PNI_REG_ID1 = 1111;\n\n  private static final String MULTI_DEVICE_RECIPIENT = \"+14152222222\";\n  private static final UUID MULTI_DEVICE_UUID = UUID.randomUUID();\n  private static final AciServiceIdentifier MULTI_DEVICE_ACI_ID = new AciServiceIdentifier(MULTI_DEVICE_UUID);\n  private static final UUID MULTI_DEVICE_PNI = UUID.randomUUID();\n  private static final PniServiceIdentifier MULTI_DEVICE_PNI_ID = new PniServiceIdentifier(MULTI_DEVICE_PNI);\n  private static final byte MULTI_DEVICE_ID1 = 1;\n  private static final byte MULTI_DEVICE_ID2 = 2;\n  private static final byte MULTI_DEVICE_ID3 = 3;\n  private static final int MULTI_DEVICE_REG_ID1 = 222;\n  private static final int MULTI_DEVICE_REG_ID2 = 333;\n  private static final int MULTI_DEVICE_REG_ID3 = 444;\n  private static final int MULTI_DEVICE_PNI_REG_ID1 = 2222;\n  private static final int MULTI_DEVICE_PNI_REG_ID2 = 3333;\n  private static final int MULTI_DEVICE_PNI_REG_ID3 = 4444;\n\n  private static final UUID NONEXISTENT_UUID = UUID.randomUUID();\n  private static final AciServiceIdentifier NONEXISTENT_ACI_ID = new AciServiceIdentifier(NONEXISTENT_UUID);\n  private static final PniServiceIdentifier NONEXISTENT_PNI_ID = new PniServiceIdentifier(NONEXISTENT_UUID);\n\n  private static final byte[] UNIDENTIFIED_ACCESS_BYTES = \"0123456789abcdef\".getBytes();\n\n  private static final String INTERNATIONAL_RECIPIENT = \"+61123456789\";\n  private static final UUID INTERNATIONAL_UUID = UUID.randomUUID();\n\n  @SuppressWarnings(\"unchecked\")\n  private static final RedisAdvancedClusterCommands<String, String> redisCommands  = mock(RedisAdvancedClusterCommands.class);\n\n  private static final MessageSender messageSender = mock(MessageSender.class);\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private static final MessagesManager messagesManager = mock(MessagesManager.class);\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final CardinalityEstimator cardinalityEstimator = mock(CardinalityEstimator.class);\n  private static final RateLimiter rateLimiter = mock(RateLimiter.class);\n  private static final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class);\n  private static final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class);\n  private static final PushNotificationScheduler pushNotificationScheduler = mock(PushNotificationScheduler.class);\n  private static final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class);\n  private static final Scheduler messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n\n  private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n\n  private static final TestClock clock = TestClock.now();\n\n  private static final Instant START_OF_DAY = LocalDate.now(clock).atStartOfDay().toInstant(ZoneOffset.UTC);\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(RateLimitExceededExceptionMapper.class)\n      .addProvider(CompletionExceptionMapper.class)\n      .addProvider(MultiRecipientMessageProvider.class)\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(\n          new MessageController(rateLimiters, cardinalityEstimator, messageSender, accountsManager,\n              messagesManager, phoneNumberIdentifiers, pushNotificationManager, pushNotificationScheduler,\n              reportMessageManager, messageDeliveryScheduler, mock(ClientReleaseManager.class),\n              serverSecretParams, SpamChecker.noop(), new MessageMetrics(), mock(MessageDeliveryLoopMonitor.class),\n              clock))\n      .build();\n\n  @BeforeEach\n  void setup() throws MultiRecipientMismatchedDevicesException, MessageTooLargeException {\n    reset(pushNotificationScheduler);\n\n    when(messageSender.sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final List<Device> singleDeviceList = List.of(\n        generateTestDevice(SINGLE_DEVICE_ID1, SINGLE_DEVICE_REG_ID1, SINGLE_DEVICE_PNI_REG_ID1, true)\n    );\n\n    final List<Device> multiDeviceList = List.of(\n        generateTestDevice(MULTI_DEVICE_ID1, MULTI_DEVICE_REG_ID1, MULTI_DEVICE_PNI_REG_ID1, true),\n        generateTestDevice(MULTI_DEVICE_ID2, MULTI_DEVICE_REG_ID2, MULTI_DEVICE_PNI_REG_ID2, true),\n        generateTestDevice(MULTI_DEVICE_ID3, MULTI_DEVICE_REG_ID3, MULTI_DEVICE_PNI_REG_ID3, false)\n    );\n\n    Account singleDeviceAccount  = AccountsHelper.generateTestAccount(SINGLE_DEVICE_RECIPIENT, SINGLE_DEVICE_UUID, SINGLE_DEVICE_PNI, singleDeviceList, UNIDENTIFIED_ACCESS_BYTES);\n    Account multiDeviceAccount   = AccountsHelper.generateTestAccount(MULTI_DEVICE_RECIPIENT, MULTI_DEVICE_UUID, MULTI_DEVICE_PNI, multiDeviceList, UNIDENTIFIED_ACCESS_BYTES);\n    Account internationalAccount = AccountsHelper.generateTestAccount(INTERNATIONAL_RECIPIENT, INTERNATIONAL_UUID,\n        UUID.randomUUID(), singleDeviceList, UNIDENTIFIED_ACCESS_BYTES);\n\n    when(accountsManager.getByServiceIdentifier(SINGLE_DEVICE_ACI_ID)).thenReturn(Optional.of(singleDeviceAccount));\n    when(accountsManager.getByServiceIdentifier(SINGLE_DEVICE_PNI_ID)).thenReturn(Optional.of(singleDeviceAccount));\n    when(accountsManager.getByServiceIdentifier(MULTI_DEVICE_ACI_ID)).thenReturn(Optional.of(multiDeviceAccount));\n    when(accountsManager.getByServiceIdentifier(MULTI_DEVICE_PNI_ID)).thenReturn(Optional.of(multiDeviceAccount));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(INTERNATIONAL_UUID))).thenReturn(Optional.of(internationalAccount));\n    when(accountsManager.getByServiceIdentifier(NONEXISTENT_ACI_ID)).thenReturn(Optional.empty());\n    when(accountsManager.getByServiceIdentifier(NONEXISTENT_PNI_ID)).thenReturn(Optional.empty());\n\n    when(accountsManager.getByServiceIdentifierAsync(any())).thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n    when(accountsManager.getByServiceIdentifierAsync(SINGLE_DEVICE_ACI_ID)).thenReturn(CompletableFuture.completedFuture(Optional.of(singleDeviceAccount)));\n    when(accountsManager.getByServiceIdentifierAsync(SINGLE_DEVICE_PNI_ID)).thenReturn(CompletableFuture.completedFuture(Optional.of(singleDeviceAccount)));\n    when(accountsManager.getByServiceIdentifierAsync(MULTI_DEVICE_ACI_ID)).thenReturn(CompletableFuture.completedFuture(Optional.of(multiDeviceAccount)));\n    when(accountsManager.getByServiceIdentifierAsync(MULTI_DEVICE_PNI_ID)).thenReturn(CompletableFuture.completedFuture(Optional.of(multiDeviceAccount)));\n    when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(INTERNATIONAL_UUID))).thenReturn(CompletableFuture.completedFuture(Optional.of(internationalAccount)));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT));\n    when(accountsManager.getByAccountIdentifierAsync(AuthHelper.VALID_UUID))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT)));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_3)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_3));\n    when(accountsManager.getByAccountIdentifierAsync(AuthHelper.VALID_UUID_3))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(AuthHelper.VALID_ACCOUNT_3)));\n\n    when(rateLimiters.getMessagesLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getStoriesLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getInboundMessageBytes()).thenReturn(rateLimiter);\n\n    when(rateLimiter.validateAsync(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n    clock.unpin();\n  }\n\n  private static Device generateTestDevice(final byte id, final int registrationId, final int pniRegistrationId,\n      final boolean enabled) {\n    final Device device = new Device();\n    device.setId(id);\n    device.setRegistrationId(registrationId);\n    device.setPhoneNumberIdentityRegistrationId(pniRegistrationId);\n    device.setFetchesMessages(enabled);\n\n    return device;\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(\n        redisCommands,\n        messageSender,\n        accountsManager,\n        messagesManager,\n        rateLimiters,\n        rateLimiter,\n        cardinalityEstimator,\n        phoneNumberIdentifiers,\n        pushNotificationManager,\n        reportMessageManager\n    );\n  }\n\n  @AfterAll\n  static void teardownAll() {\n    messageDeliveryScheduler.dispose();\n  }\n\n  @Test\n  void testSingleDeviceCurrent() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response\", response.getStatus(), is(equalTo(200)));\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> captor = ArgumentCaptor.forClass(Map.class);\n      verify(messageSender).sendMessages(any(), any(), captor.capture(), any(), eq(Optional.empty()), any());\n\n      assertEquals(1, captor.getValue().size());\n      final Envelope message = captor.getValue().values().stream().findFirst().orElseThrow();\n\n      assertTrue(message.hasSourceServiceId());\n      assertTrue(message.hasSourceDevice());\n      assertTrue(message.getUrgent());\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testSingleDeviceSync(final boolean sendToPni) throws Exception {\n    final ServiceIdentifier serviceIdentifier = sendToPni\n        ? new PniServiceIdentifier(AuthHelper.VALID_PNI_3)\n        : new AciServiceIdentifier(AuthHelper.VALID_UUID_3);\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", serviceIdentifier.toServiceIdentifierString()))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_sync.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      if (sendToPni) {\n        assertThat(response.getStatus(), is(equalTo(403)));\n        verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n      } else {\n        assertThat(response.getStatus(), is(equalTo(200)));\n\n        verify(messageSender).sendMessages(any(),\n            eq(serviceIdentifier),\n            any(),\n            any(),\n            eq(Optional.of(Device.PRIMARY_ID)),\n            any());\n      }\n    }\n  }\n\n  @Test\n  void testSingleDeviceCurrentNotUrgent() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device_not_urgent.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response\", response.getStatus(), is(equalTo(200)));\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> captor = ArgumentCaptor.forClass(Map.class);\n      verify(messageSender).sendMessages(any(), any(), captor.capture(), any(), eq(Optional.empty()), any());\n\n      assertEquals(1, captor.getValue().size());\n      final Envelope message = captor.getValue().values().stream().findFirst().orElseThrow();\n\n      assertTrue(message.hasSourceServiceId());\n      assertTrue(message.hasSourceDevice());\n      assertFalse(message.getUrgent());\n    }\n  }\n\n  @Test\n  void testSingleDeviceCurrentByPni() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/PNI:%s\", SINGLE_DEVICE_PNI))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response\", response.getStatus(), is(equalTo(200)));\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> captor = ArgumentCaptor.forClass(Map.class);\n      verify(messageSender).sendMessages(any(), any(), captor.capture(), any(), eq(Optional.empty()), any());\n\n      assertEquals(1, captor.getValue().size());\n      final Envelope message = captor.getValue().values().stream().findFirst().orElseThrow();\n\n      assertTrue(message.hasSourceServiceId());\n      assertTrue(message.hasSourceDevice());\n    }\n  }\n\n  @Test\n  void testNullMessageInList() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_null_message_in_list.json\"), IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Bad request\", response.getStatus(), is(equalTo(422)));\n    }\n  }\n\n  @Test\n  void testSingleDeviceCurrentUnidentified() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response\", response.getStatus(), is(equalTo(200)));\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> captor = ArgumentCaptor.forClass(Map.class);\n      verify(messageSender).sendMessages(any(), any(), captor.capture(), any(), eq(Optional.empty()), any());\n\n      assertEquals(1, captor.getValue().size());\n      final Envelope message = captor.getValue().values().stream().findFirst().orElseThrow();\n\n      assertFalse(message.hasSourceServiceId());\n      assertFalse(message.hasSourceDevice());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testSingleDeviceCurrentGroupSendEndorsement(\n      ServiceIdentifier recipient, ServiceIdentifier authorizedRecipient,\n      Duration timeLeft, boolean includeUak, boolean story, int expectedResponse) throws Exception {\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS); // expiration times must be UTC midnight or libsignal will reject the endorsement\n    clock.pin(expiration.minus(timeLeft));\n\n    Invocation.Builder builder =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", recipient.toServiceIdentifierString()))\n            .queryParam(\"story\", story)\n            .request()\n            .header(HeaderUtils.GROUP_SEND_TOKEN,\n                AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(authorizedRecipient), expiration));\n\n    if (includeUak) {\n      builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_BYTES));\n    }\n\n    try (final Response response = builder\n        .put(Entity.entity(\n                SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response\", response.getStatus(), is(equalTo(expectedResponse)));\n      if (expectedResponse == 200) {\n        @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> captor = ArgumentCaptor.forClass(Map.class);\n        verify(messageSender).sendMessages(any(), any(), captor.capture(), any(), eq(Optional.empty()), any());\n\n        assertEquals(1, captor.getValue().size());\n        final Envelope message = captor.getValue().values().stream().findFirst().orElseThrow();\n\n        assertFalse(message.hasSourceServiceId());\n        assertFalse(message.hasSourceDevice());\n      } else {\n        verifyNoMoreInteractions(messageSender);\n      }\n    }\n  }\n\n  private static Stream<Arguments> testSingleDeviceCurrentGroupSendEndorsement() {\n    return Stream.of(\n        // valid endorsement\n        Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), false, false, 200),\n\n        // expired endorsement, not authorized\n        Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(-1), false, false, 401),\n\n        // endorsement for the wrong recipient, not authorized\n        Arguments.of(SINGLE_DEVICE_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(1), false, false, 401),\n\n        // expired endorsement for the wrong recipient, not authorized\n        Arguments.of(SINGLE_DEVICE_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(-1), false, false, 401),\n\n        // valid endorsement for the right recipient but they aren't registered, not found\n        Arguments.of(NONEXISTENT_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(1), false, false, 404),\n\n        // expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found)\n        Arguments.of(NONEXISTENT_ACI_ID, NONEXISTENT_ACI_ID, Duration.ofHours(-1), false, false, 401),\n\n        // valid endorsement but also a UAK, bad request\n        Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), true, false, 400),\n\n        // valid endorsement on a story, bad request\n        Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), false, true, 400),\n\n        // valid endorsement on a story with a UAK, bad request\n        Arguments.of(SINGLE_DEVICE_ACI_ID, SINGLE_DEVICE_ACI_ID, Duration.ofHours(1), true, true, 400));\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"-1, 422\",\n      \"0, 200\",\n      \"1, 200\",\n      \"8640000000000000, 200\",\n      \"8640000000000001, 422\",\n\n      // This is something of a quirk; because this failure is happening at the parsing layer (we can't parse it as a\n      // `long`) instead of the validation layer, we get a 400 instead of a 422\n      \"99999999999999999999999999999999999, 400\"\n  })\n  void testSingleDeviceExtremeTimestamp(final String timestamp, final int expectedStatus) {\n    final String jsonTemplate = \"\"\"\n        {\n            \"timestamp\" : %s,\n            \"messages\" : [{\n                \"type\" : 1,\n                \"destinationDeviceId\" : 1,\n                \"content\" : \"Zm9vYmFyego\"\n            }]\n        }\n        \"\"\";\n\n    final String json = String.format(jsonTemplate, timestamp);\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.json(json))) {\n\n      assertThat(response.getStatus(), is(equalTo(expectedStatus)));\n    }\n  }\n\n  @Test\n  void testSendBadAuth() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response\", response.getStatus(), is(equalTo(401)));\n    }\n  }\n\n  @Test\n  void testMultiDeviceMissing() throws Exception {\n    doThrow(new MismatchedDevicesException(new MismatchedDevices(Set.of((byte) 2, (byte) 3), Collections.emptySet(), Collections.emptySet())))\n        .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", MULTI_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(409)));\n\n      assertThat(\"Good Response Body\",\n          asJson(response.readEntity(MismatchedDevicesResponse.class)),\n          is(equalTo(jsonFixture(\"fixtures/missing_device_response.json\"))));\n    }\n  }\n\n  @Test\n  void testMultiDeviceExtra() throws Exception {\n    doThrow(new MismatchedDevicesException(new MismatchedDevices(Set.of((byte) 2), Set.of((byte) 4), Collections.emptySet())))\n        .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", MULTI_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_extra_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(409)));\n\n      assertThat(\"Good Response Body\",\n          asJson(response.readEntity(MismatchedDevicesResponse.class)),\n          is(equalTo(jsonFixture(\"fixtures/missing_device_response2.json\"))));\n    }\n  }\n\n  @Test\n  void testMultiDeviceDuplicate() throws Exception {\n    try (final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/messages/%s\", MULTI_DEVICE_UUID))\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_duplicate_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(422)));\n\n      verifyNoMoreInteractions(messageSender);\n    }\n  }\n\n  @Test\n  void testMultiDevice() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", MULTI_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_multi_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(200)));\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> envelopeCaptor =\n          ArgumentCaptor.forClass(Map.class);\n\n      verify(messageSender).sendMessages(any(Account.class), any(), envelopeCaptor.capture(), any(), eq(Optional.empty()), any());\n\n      assertEquals(3, envelopeCaptor.getValue().size());\n\n      envelopeCaptor.getValue().values().forEach(envelope -> assertTrue(envelope.getUrgent()));\n    }\n  }\n\n  @Test\n  void testMultiDeviceNotUrgent() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", MULTI_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_multi_device_not_urgent.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(200)));\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Map<Byte, Envelope>> envelopeCaptor =\n          ArgumentCaptor.forClass(Map.class);\n\n      verify(messageSender).sendMessages(any(Account.class), any(), envelopeCaptor.capture(), any(), eq(Optional.empty()), any());\n\n      assertEquals(3, envelopeCaptor.getValue().size());\n\n      envelopeCaptor.getValue().values().forEach(envelope -> assertFalse(envelope.getUrgent()));\n    }\n  }\n\n  @Test\n  void testMultiDeviceByPni() throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/PNI:%s\", MULTI_DEVICE_PNI))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_multi_device_pni.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(200)));\n\n      verify(messageSender).sendMessages(any(Account.class),\n          any(),\n          argThat(messagesByDeviceId -> messagesByDeviceId.size() == 3),\n          any(),\n          eq(Optional.empty()),\n          any());\n    }\n  }\n\n  @Test\n  void testRegistrationIdMismatch() throws Exception {\n    doThrow(new MismatchedDevicesException(new MismatchedDevices(Collections.emptySet(), Collections.emptySet(), Set.of((byte) 2))))\n        .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n    try (final Response response =\n        resources.getJerseyTest().target(String.format(\"/v1/messages/%s\", MULTI_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_registration_id.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(\"Good Response Code\", response.getStatus(), is(equalTo(410)));\n\n      assertThat(\"Good Response Body\",\n          asJson(response.readEntity(StaleDevicesResponse.class)),\n          is(equalTo(jsonFixture(\"fixtures/mismatched_registration_id.json\"))));\n    }\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"false, false\",\n      \"false, true\",\n      \"true, false\",\n      \"true, true\"\n  })\n  void testGetMessages(final boolean receiveStories, final boolean hasMore) {\n\n    final long timestampOne = 313377;\n    final long timestampTwo = 313388;\n\n    final UUID messageGuidOne = UUID.randomUUID();\n    final UUID messageGuidTwo = UUID.randomUUID();\n    final UUID sourceUuid = UUID.randomUUID();\n\n    final UUID updatedPniOne = UUID.randomUUID();\n\n    List<Envelope> envelopes = List.of(\n        generateEnvelope(messageGuidOne, Envelope.Type.CIPHERTEXT_VALUE, timestampOne, sourceUuid, (byte) 2,\n            AuthHelper.VALID_UUID, updatedPniOne, \"hi there\".getBytes(), 0, false),\n        generateEnvelope(messageGuidTwo, Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, timestampTwo, sourceUuid,\n            (byte) 2,\n            AuthHelper.VALID_UUID, null, null, 0, true)\n    );\n\n    when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(AuthHelper.VALID_DEVICE), anyBoolean()))\n        .thenReturn(Mono.just(new Pair<>(envelopes, hasMore)));\n\n    final String userAgent = \"Test-UA\";\n\n    OutgoingMessageEntityList response =\n        resources.getJerseyTest().target(\"/v1/messages/\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .header(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES, receiveStories ? \"true\" : \"false\")\n            .header(HttpHeaders.USER_AGENT, userAgent)\n            .accept(MediaType.APPLICATION_JSON_TYPE)\n            .get(OutgoingMessageEntityList.class);\n\n    List<OutgoingMessageEntity> messages = response.messages();\n    int expectedSize = receiveStories ? 2 : 1;\n    assertEquals(expectedSize, messages.size());\n\n    OutgoingMessageEntity first = messages.getFirst();\n    assertEquals(first.timestamp(), timestampOne);\n    assertEquals(first.guid(), messageGuidOne);\n    assertNotNull(first.sourceUuid());\n    assertEquals(first.sourceUuid().uuid(), sourceUuid);\n    assertEquals(updatedPniOne, first.updatedPni());\n\n    if (receiveStories) {\n      OutgoingMessageEntity second = messages.get(1);\n      assertEquals(second.timestamp(), timestampTwo);\n      assertEquals(second.guid(), messageGuidTwo);\n      assertNotNull(second.sourceUuid());\n      assertEquals(second.sourceUuid().uuid(), sourceUuid);\n      assertNull(second.updatedPni());\n    }\n\n    verify(pushNotificationManager).handleMessagesRetrieved(AuthHelper.VALID_ACCOUNT, AuthHelper.VALID_DEVICE, userAgent);\n\n    if (hasMore) {\n      verify(pushNotificationScheduler).scheduleDelayedNotification(eq(AuthHelper.VALID_ACCOUNT), eq(AuthHelper.VALID_DEVICE), any());\n    } else {\n      verify(pushNotificationScheduler, never()).scheduleDelayedNotification(any(), any(), any());\n    }\n  }\n\n  @Test\n  void testGetMessagesBadAuth() {\n    final long timestampOne = 313377;\n    final long timestampTwo = 313388;\n\n    final List<Envelope> messages = List.of(\n        generateEnvelope(UUID.randomUUID(), Envelope.Type.CIPHERTEXT_VALUE, timestampOne, UUID.randomUUID(), (byte) 2,\n            AuthHelper.VALID_UUID, null, \"hi there\".getBytes(), 0),\n        generateEnvelope(UUID.randomUUID(), Envelope.Type.SERVER_DELIVERY_RECEIPT_VALUE, timestampTwo,\n            UUID.randomUUID(), (byte) 2, AuthHelper.VALID_UUID, null, null, 0)\n    );\n\n    when(messagesManager.getMessagesForDevice(eq(AuthHelper.VALID_UUID), eq(AuthHelper.VALID_DEVICE), anyBoolean()))\n        .thenReturn(Mono.just(new Pair<>(messages, false)));\n\n    Response response =\n        resources.getJerseyTest().target(\"/v1/messages/\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))\n            .accept(MediaType.APPLICATION_JSON_TYPE)\n            .get();\n\n    assertThat(\"Unauthorized response\", response.getStatus(), is(equalTo(401)));\n  }\n\n  @Test\n  void testReportMessageByE164() {\n    final String senderNumber = \"+12125550001\";\n    final UUID senderAci = UUID.randomUUID();\n    final UUID senderPni = UUID.randomUUID();\n    final String userAgent = \"user-agent\";\n    UUID messageGuid = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(senderAci);\n    when(account.getNumber()).thenReturn(senderNumber);\n    when(account.getPhoneNumberIdentifier()).thenReturn(senderPni);\n\n    when(accountsManager.getByE164(senderNumber)).thenReturn(Optional.of(account));\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderNumber, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .header(HttpHeaders.USER_AGENT, userAgent)\n            .post(null)) {\n\n      assertThat(response.getStatus(), is(equalTo(202)));\n\n      verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni),\n          messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent);\n      verify(accountsManager, never()).findRecentlyDeletedPhoneNumberIdentifier(any(UUID.class));\n      verify(phoneNumberIdentifiers, never()).getPhoneNumber(any());\n    }\n  }\n\n  @Test\n  void testReportMesageByE164DeletedAccount() {\n    final String senderNumber = \"+12125550001\";\n    final UUID senderAci = UUID.randomUUID();\n    final UUID senderPni = UUID.randomUUID();\n    final String userAgent = \"user-agent\";\n    UUID messageGuid = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(senderAci);\n    when(account.getNumber()).thenReturn(senderNumber);\n    when(account.getPhoneNumberIdentifier()).thenReturn(senderPni);\n\n    when(accountsManager.getByE164(senderNumber)).thenReturn(Optional.empty());\n    when(phoneNumberIdentifiers.getPhoneNumberIdentifier(senderNumber)).thenReturn(CompletableFuture.completedFuture(senderPni));\n    when(accountsManager.findRecentlyDeletedAccountIdentifier(senderPni)).thenReturn(Optional.of(senderAci));\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderNumber, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .header(HttpHeaders.USER_AGENT, userAgent)\n            .post(null)) {\n\n      assertThat(response.getStatus(), is(equalTo(202)));\n\n      verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni),\n          messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent);\n    }\n  }\n\n  @Test\n  void testReportMessageByAci() {\n    final String senderNumber = \"+12125550001\";\n    final UUID senderAci = UUID.randomUUID();\n    final UUID senderPni = UUID.randomUUID();\n    final String userAgent = \"user-agent\";\n    UUID messageGuid = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(senderAci);\n    when(account.getNumber()).thenReturn(senderNumber);\n    when(account.getPhoneNumberIdentifier()).thenReturn(senderPni);\n\n    when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account));\n    when(phoneNumberIdentifiers.getPhoneNumber(senderPni)).thenReturn(CompletableFuture.completedFuture(List.of(senderNumber)));\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderAci, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .header(HttpHeaders.USER_AGENT, userAgent)\n            .post(null)) {\n\n      assertThat(response.getStatus(), is(equalTo(202)));\n\n      verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni),\n          messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent);\n      verify(accountsManager, never()).findRecentlyDeletedPhoneNumberIdentifier(any(UUID.class));\n      verify(phoneNumberIdentifiers, never()).getPhoneNumber(any());\n    }\n  }\n\n  @Test\n  void testReportMessageByAciDeletedAccount() {\n    final String senderNumber = \"+12125550001\";\n    final UUID senderAci = UUID.randomUUID();\n    final UUID senderPni = UUID.randomUUID();\n    final String userAgent = \"user-agent\";\n    final UUID messageGuid = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(senderAci);\n    when(account.getNumber()).thenReturn(senderNumber);\n    when(account.getPhoneNumberIdentifier()).thenReturn(senderPni);\n\n    when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.empty());\n    when(accountsManager.findRecentlyDeletedPhoneNumberIdentifier(senderAci)).thenReturn(Optional.of(senderPni));\n    when(phoneNumberIdentifiers.getPhoneNumber(senderPni)).thenReturn(CompletableFuture.completedFuture(List.of(senderNumber)));\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderAci, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .header(HttpHeaders.USER_AGENT, userAgent)\n            .post(null)) {\n\n      assertThat(response.getStatus(), is(equalTo(202)));\n\n      verify(reportMessageManager).report(Optional.of(senderNumber), Optional.of(senderAci), Optional.of(senderPni),\n          messageGuid, AuthHelper.VALID_UUID, Optional.empty(), userAgent);\n    }\n  }\n\n  @Test\n  void testReportMessageByAciWithSpamReportToken() {\n\n    final String senderNumber = \"+12125550001\";\n    final UUID senderAci = UUID.randomUUID();\n    final UUID senderPni = UUID.randomUUID();\n    UUID messageGuid = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(senderAci);\n    when(account.getNumber()).thenReturn(senderNumber);\n    when(account.getPhoneNumberIdentifier()).thenReturn(senderPni);\n\n    when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account));\n    when(accountsManager.findRecentlyDeletedPhoneNumberIdentifier(senderAci)).thenReturn(Optional.of(senderPni));\n    when(phoneNumberIdentifiers.getPhoneNumber(senderPni)).thenReturn(CompletableFuture.completedFuture(List.of(senderNumber)));\n\n    Entity<SpamReport> entity = Entity.entity(new SpamReport(new byte[3]), \"application/json\");\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderAci, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .post(entity)) {\n\n      assertThat(response.getStatus(), is(equalTo(202)));\n      verify(reportMessageManager).report(eq(Optional.of(senderNumber)),\n          eq(Optional.of(senderAci)),\n          eq(Optional.of(senderPni)),\n          eq(messageGuid),\n          eq(AuthHelper.VALID_UUID),\n          argThat(maybeBytes -> maybeBytes.map(bytes -> Arrays.equals(bytes, new byte[3])).orElse(false)),\n          any());\n      verify(accountsManager, never()).findRecentlyDeletedPhoneNumberIdentifier(any(UUID.class));\n      verify(phoneNumberIdentifiers, never()).getPhoneNumber(any());\n    }\n\n    when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.empty());\n\n    messageGuid = UUID.randomUUID();\n\n    entity = Entity.entity(new SpamReport(new byte[5]), \"application/json\");\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderAci, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .post(entity)) {\n\n      assertThat(response.getStatus(), is(equalTo(202)));\n      verify(reportMessageManager).report(eq(Optional.of(senderNumber)),\n          eq(Optional.of(senderAci)),\n          eq(Optional.of(senderPni)),\n          eq(messageGuid),\n          eq(AuthHelper.VALID_UUID),\n          argThat(maybeBytes -> maybeBytes.map(bytes -> Arrays.equals(bytes, new byte[5])).orElse(false)),\n          any());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testReportMessageByAciWithNullSpamReportToken(Entity<?> entity, boolean expectOk) {\n\n    final String senderNumber = \"+12125550001\";\n    final UUID senderAci = UUID.randomUUID();\n    final UUID senderPni = UUID.randomUUID();\n    UUID messageGuid = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(senderAci);\n    when(account.getNumber()).thenReturn(senderNumber);\n    when(account.getPhoneNumberIdentifier()).thenReturn(senderPni);\n\n    when(accountsManager.getByAccountIdentifier(senderAci)).thenReturn(Optional.of(account));\n    when(accountsManager.findRecentlyDeletedPhoneNumberIdentifier(senderAci)).thenReturn(Optional.of(senderPni));\n    when(phoneNumberIdentifiers.getPhoneNumber(senderPni)).thenReturn(CompletableFuture.completedFuture(List.of(senderNumber)));\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/report/%s/%s\", senderAci, messageGuid))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .post(entity)) {\n\n      Matcher<Integer> matcher = expectOk ? is(equalTo(202)) : not(equalTo(202));\n      assertThat(response.getStatus(), matcher);\n    }\n  }\n\n  private static Stream<Arguments> testReportMessageByAciWithNullSpamReportToken() {\n    return Stream.of(\n        Arguments.of(Entity.json(new SpamReport(new byte[5])), true),\n        Arguments.of(Entity.json(\"{\\\"token\\\":\\\"AAAAAAA\\\"}\"), true),\n        Arguments.of(Entity.json(new SpamReport(new byte[0])), true),\n        Arguments.of(Entity.json(new SpamReport(null)), true),\n        Arguments.of(Entity.json(\"{\\\"token\\\": \\\"\\\"}\"), true),\n        Arguments.of(Entity.json(\"{\\\"token\\\": null}\"), true),\n        Arguments.of(Entity.json(\"null\"), true),\n        Arguments.of(Entity.json(\"{\\\"weird\\\": 123}\"), true),\n        Arguments.of(Entity.json(\"\\\"weirder\\\"\"), false),\n        Arguments.of(Entity.json(\"weirdest\"), false),\n        Arguments.of(Entity.json(\"{\\\"token\\\":\\\"InvalidBase64[][][][]\\\"}\"), false)\n    );\n  }\n\n  @Test\n  void testValidateContentLength() throws MismatchedDevicesException, MessageTooLargeException, IOException {\n    doThrow(new MessageTooLargeException()).when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(\"fixtures/current_message_single_device.json\"),\n                    IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus(), is(equalTo(413)));\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testValidateEnvelopeType(String payloadFilename, boolean expectOk) throws Exception {\n    try (final Response response =\n        resources.getJerseyTest()\n            .target(String.format(\"/v1/messages/%s\", SINGLE_DEVICE_UUID))\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .header(HttpHeaders.USER_AGENT, \"Test-UA\")\n            .put(Entity.entity(SystemMapper.jsonMapper().readValue(jsonFixture(payloadFilename), IncomingMessageList.class),\n                MediaType.APPLICATION_JSON_TYPE))) {\n\n      if (expectOk) {\n        assertEquals(200, response.getStatus());\n        verify(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n      } else {\n        assertEquals(422, response.getStatus());\n        verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n      }\n    }\n  }\n\n  private static Stream<Arguments> testValidateEnvelopeType() {\n    return Stream.of(\n        Arguments.of(\"fixtures/current_message_single_device.json\", true),\n        Arguments.of(\"fixtures/current_message_single_device_server_receipt_type.json\", false)\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void sendMultiRecipientMessage(final Map<ServiceIdentifier, Account> accountsByServiceIdentifier,\n      final byte[] multiRecipientMessage,\n      final long timestamp,\n      final boolean isStory,\n      final boolean rateLimit,\n      final Optional<String> maybeAccessKey,\n      final Optional<String> maybeGroupSendToken,\n      final int expectedStatus,\n      final Set<Account> expectedResolvedAccounts,\n      final Set<ServiceIdentifier> expectedUuids404,\n      @Nullable final MultiRecipientMismatchedDevicesException mismatchedDevicesException)\n      throws MultiRecipientMismatchedDevicesException, MessageTooLargeException {\n\n    clock.pin(START_OF_DAY);\n\n    when(accountsManager.getByServiceIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    accountsByServiceIdentifier.forEach(((serviceIdentifier, account) ->\n        when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n            .thenReturn(CompletableFuture.completedFuture(Optional.of(account)))));\n\n    if (mismatchedDevicesException != null) {\n      doThrow(mismatchedDevicesException)\n          .when(messageSender).sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    final boolean ephemeral = true;\n    final boolean urgent = false;\n\n    final Invocation.Builder invocationBuilder = resources\n        .getJerseyTest()\n        .target(\"/v1/messages/multi_recipient\")\n        .queryParam(\"ts\", timestamp)\n        .queryParam(\"online\", ephemeral)\n        .queryParam(\"story\", isStory)\n        .queryParam(\"urgent\", urgent)\n        .request();\n\n    maybeAccessKey.ifPresent(accessKey ->\n        invocationBuilder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, accessKey));\n\n    maybeGroupSendToken.ifPresent(groupSendToken ->\n        invocationBuilder.header(HeaderUtils.GROUP_SEND_TOKEN, groupSendToken));\n\n    if (rateLimit) {\n      when(rateLimiter.validateAsync(any(UUID.class)))\n          .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(Duration.ofSeconds(77))));\n    } else {\n      when(rateLimiter.validateAsync(any(UUID.class)))\n          .thenReturn(CompletableFuture.completedFuture(null));\n    }\n\n    try (final Response response = invocationBuilder\n        .put(Entity.entity(multiRecipientMessage, MultiRecipientMessageProvider.MEDIA_TYPE))) {\n\n      assertThat(response.getStatus(), is(equalTo(expectedStatus)));\n\n      if (expectedStatus == 200) {\n        final SendMultiRecipientMessageResponse entity = response.readEntity(SendMultiRecipientMessageResponse.class);\n        assertThat(Set.copyOf(entity.uuids404()), equalTo(expectedUuids404));\n      }\n\n      if ((expectedStatus == 200 && !expectedResolvedAccounts.isEmpty()) || mismatchedDevicesException != null) {\n        verify(messageSender).sendMultiRecipientMessage(any(),\n            argThat(resolvedRecipients ->\n                new HashSet<>(resolvedRecipients.values()).equals(expectedResolvedAccounts)),\n            anyLong(),\n            eq(isStory),\n            eq(ephemeral),\n            eq(urgent),\n            any());\n      } else {\n        verify(messageSender, never())\n            .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n      }\n    }\n  }\n\n  private static List<Arguments> sendMultiRecipientMessage() throws Exception {\n    final UUID singleDeviceAccountAci = UUID.randomUUID();\n    final UUID singleDeviceAccountPni = UUID.randomUUID();\n    final UUID multiDeviceAccountAci = UUID.randomUUID();\n    final UUID multiDeviceAccountPni = UUID.randomUUID();\n\n    final byte[] singleDeviceAccountUak = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final byte[] multiDeviceAccountUak = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    final int singleDevicePrimaryRegistrationId = 1;\n    final int multiDevicePrimaryRegistrationId = 2;\n    final int multiDeviceLinkedRegistrationId = 3;\n\n    final Device singleDeviceAccountPrimary = mock(Device.class);\n    when(singleDeviceAccountPrimary.getId()).thenReturn(Device.PRIMARY_ID);\n    when(singleDeviceAccountPrimary.getRegistrationId(IdentityType.ACI)).thenReturn(singleDevicePrimaryRegistrationId);\n\n    final Device multiDeviceAccountPrimary = mock(Device.class);\n    when(multiDeviceAccountPrimary.getId()).thenReturn(Device.PRIMARY_ID);\n    when(multiDeviceAccountPrimary.getRegistrationId(IdentityType.ACI)).thenReturn(multiDevicePrimaryRegistrationId);\n\n    final Device multiDeviceAccountLinked = mock(Device.class);\n    when(multiDeviceAccountLinked.getId()).thenReturn((byte) (Device.PRIMARY_ID + 1));\n    when(multiDeviceAccountLinked.getRegistrationId(IdentityType.ACI)).thenReturn(multiDeviceLinkedRegistrationId);\n\n    final Account singleDeviceAccount = mock(Account.class);\n    when(singleDeviceAccount.getIdentifier(IdentityType.ACI)).thenReturn(singleDeviceAccountAci);\n    when(singleDeviceAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(singleDeviceAccountUak));\n    when(singleDeviceAccount.getDevices()).thenReturn(List.of(singleDeviceAccountPrimary));\n    when(singleDeviceAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(singleDeviceAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(singleDeviceAccountPrimary));\n\n    final Account multiDeviceAccount = mock(Account.class);\n    when(multiDeviceAccount.getIdentifier(IdentityType.ACI)).thenReturn(multiDeviceAccountAci);\n    when(multiDeviceAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(multiDeviceAccountUak));\n    when(multiDeviceAccount.getDevices()).thenReturn(List.of(multiDeviceAccountPrimary, multiDeviceAccountLinked));\n    when(multiDeviceAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(multiDeviceAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(multiDeviceAccountPrimary));\n    when(multiDeviceAccount.getDevice((byte) (Device.PRIMARY_ID + 1))).thenReturn(Optional.of(multiDeviceAccountLinked));\n\n    final String groupSendEndorsement = AuthHelper.validGroupSendTokenHeader(serverSecretParams,\n        List.of(new AciServiceIdentifier(singleDeviceAccountAci), new AciServiceIdentifier(multiDeviceAccountAci)),\n        START_OF_DAY.plus(Duration.ofDays(1)));\n\n    final Map<ServiceIdentifier, Account> accountsByServiceIdentifier = Map.of(\n        new AciServiceIdentifier(singleDeviceAccountAci), singleDeviceAccount,\n        new AciServiceIdentifier(multiDeviceAccountAci), multiDeviceAccount,\n        new PniServiceIdentifier(singleDeviceAccountPni), singleDeviceAccount,\n        new PniServiceIdentifier(multiDeviceAccountPni), multiDeviceAccount);\n\n    final byte[] aciMessage = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n        new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n        new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n        new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId, new byte[48])));\n\n    return List.of(\n        Arguments.argumentSet(\"Multi-recipient story\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.empty(),\n            Optional.empty(),\n            200,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Multi-recipient message with combined UAKs\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.of(Base64.getEncoder().encodeToString(UnidentifiedAccessUtil.getCombinedUnidentifiedAccessKey(List.of(singleDeviceAccount, multiDeviceAccount)))),\n            Optional.empty(),\n            200,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Multi-recipient message with group send endorsement\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            200,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Incorrect combined UAK\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.of(Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH))),\n            Optional.empty(),\n            401,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Incorrect group send endorsement\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(AuthHelper.validGroupSendTokenHeader(serverSecretParams,\n                List.of(new AciServiceIdentifier(UUID.randomUUID())),\n                START_OF_DAY.plus(Duration.ofDays(1)))),\n            401,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        // Stories don't require credentials of any kind, but for historical reasons, we don't reject a combined UAK if\n        // provided\n        Arguments.argumentSet(\"Story with combined UAKs\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.of(Base64.getEncoder().encodeToString(UnidentifiedAccessUtil.getCombinedUnidentifiedAccessKey(List.of(singleDeviceAccount, multiDeviceAccount)))),\n            Optional.empty(),\n            200,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Story with group send endorsement\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Conflicting credentials\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.of(Base64.getEncoder().encodeToString(UnidentifiedAccessUtil.getCombinedUnidentifiedAccessKey(List.of(singleDeviceAccount, multiDeviceAccount)))),\n            Optional.of(groupSendEndorsement),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"No credentials\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.empty(),\n            401,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Negative timestamp\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            -1,\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Excessive timestamp\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            MessageController.MAX_TIMESTAMP + 1,\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Empty recipient list\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of()),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(AuthHelper.validGroupSendTokenHeader(serverSecretParams,\n                List.of(),\n                START_OF_DAY.plus(Duration.ofDays(1)))),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Story with empty recipient list\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of()),\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.empty(),\n            Optional.empty(),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Duplicate recipient\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                    new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                    new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            400,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Missing account\",\n            Map.of(),\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            200,\n            Collections.emptySet(),\n            Set.of(new AciServiceIdentifier(singleDeviceAccountAci), new AciServiceIdentifier(multiDeviceAccountAci)),\n            null),\n\n        Arguments.argumentSet(\"One missing and one existing account\",\n            Map.of(new AciServiceIdentifier(singleDeviceAccountAci), singleDeviceAccount),\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            200,\n            Set.of(singleDeviceAccount),\n            Set.of(new AciServiceIdentifier(multiDeviceAccountAci)),\n            null),\n\n        Arguments.argumentSet(\"Missing account for story\",\n            Map.of(),\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.empty(),\n            Optional.empty(),\n            200,\n            Collections.emptySet(),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"One missing and one existing account for story\",\n            Map.of(new AciServiceIdentifier(singleDeviceAccountAci), singleDeviceAccount),\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.empty(),\n            Optional.empty(),\n            200,\n            Set.of(singleDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Missing device\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            409,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            new MultiRecipientMismatchedDevicesException(Map.of(new AciServiceIdentifier(multiDeviceAccountAci),\n                new MismatchedDevices(Set.of((byte) (Device.PRIMARY_ID + 1)), Collections.emptySet(), Collections.emptySet())))),\n\n        Arguments.argumentSet(\"Extra device\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId, new byte[48]),\n                new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), (byte) (Device.PRIMARY_ID + 2), multiDeviceLinkedRegistrationId + 1, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            409,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            new MultiRecipientMismatchedDevicesException(Map.of(new AciServiceIdentifier(multiDeviceAccountAci),\n                new MismatchedDevices(Collections.emptySet(), Set.of((byte) (Device.PRIMARY_ID + 2)), Collections.emptySet())))),\n\n        Arguments.argumentSet(\"Stale registration ID\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId + 1, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(groupSendEndorsement),\n            410,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            new MultiRecipientMismatchedDevicesException(Map.of(new AciServiceIdentifier(multiDeviceAccountAci),\n                new MismatchedDevices(Collections.emptySet(), Collections.emptySet(), Set.of((byte) (Device.PRIMARY_ID + 1)))))),\n\n        Arguments.argumentSet(\"Rate-limited story\",\n            accountsByServiceIdentifier,\n            aciMessage,\n            clock.instant().toEpochMilli(),\n            true,\n            true,\n            Optional.empty(),\n            Optional.empty(),\n            429,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Story to PNI recipients\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                new TestRecipient(new PniServiceIdentifier(singleDeviceAccountPni), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new PniServiceIdentifier(multiDeviceAccountPni), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new PniServiceIdentifier(multiDeviceAccountPni), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            true,\n            false,\n            Optional.empty(),\n            Optional.empty(),\n            200,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Multi-recipient message to PNI recipients with UAK\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                new TestRecipient(new PniServiceIdentifier(singleDeviceAccountPni), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new PniServiceIdentifier(multiDeviceAccountPni), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new PniServiceIdentifier(multiDeviceAccountPni), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.of(Base64.getEncoder().encodeToString(UnidentifiedAccessUtil.getCombinedUnidentifiedAccessKey(List.of(singleDeviceAccount, multiDeviceAccount)))),\n            Optional.empty(),\n            401,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null),\n\n        Arguments.argumentSet(\"Multi-recipient message to PNI recipients with group send endorsement\",\n            accountsByServiceIdentifier,\n            MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n                new TestRecipient(new PniServiceIdentifier(singleDeviceAccountPni), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new PniServiceIdentifier(multiDeviceAccountPni), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n                new TestRecipient(new PniServiceIdentifier(multiDeviceAccountPni), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId, new byte[48]))),\n            clock.instant().toEpochMilli(),\n            false,\n            false,\n            Optional.empty(),\n            Optional.of(AuthHelper.validGroupSendTokenHeader(serverSecretParams,\n                List.of(new PniServiceIdentifier(singleDeviceAccountPni), new PniServiceIdentifier(multiDeviceAccountPni)),\n                START_OF_DAY.plus(Duration.ofDays(1)))),\n            200,\n            Set.of(singleDeviceAccount, multiDeviceAccount),\n            Set.of(),\n            null)\n    );\n  }\n\n  @Test\n  void sendMultiRecipientMessageOversized() throws Exception {\n\n    clock.pin(START_OF_DAY);\n\n    final UUID singleDeviceAccountAci = UUID.randomUUID();\n    final UUID singleDeviceAccountPni = UUID.randomUUID();\n    final UUID multiDeviceAccountAci = UUID.randomUUID();\n    final UUID multiDeviceAccountPni = UUID.randomUUID();\n\n    final byte[] singleDeviceAccountUak = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final byte[] multiDeviceAccountUak = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    final int singleDevicePrimaryRegistrationId = 1;\n    final int multiDevicePrimaryRegistrationId = 2;\n    final int multiDeviceLinkedRegistrationId = 3;\n\n    final Device singleDeviceAccountPrimary = mock(Device.class);\n    when(singleDeviceAccountPrimary.getId()).thenReturn(Device.PRIMARY_ID);\n    when(singleDeviceAccountPrimary.getRegistrationId(IdentityType.ACI)).thenReturn(singleDevicePrimaryRegistrationId);\n\n    final Device multiDeviceAccountPrimary = mock(Device.class);\n    when(multiDeviceAccountPrimary.getId()).thenReturn(Device.PRIMARY_ID);\n    when(multiDeviceAccountPrimary.getRegistrationId(IdentityType.ACI)).thenReturn(multiDevicePrimaryRegistrationId);\n\n    final Device multiDeviceAccountLinked = mock(Device.class);\n    when(multiDeviceAccountLinked.getId()).thenReturn((byte) (Device.PRIMARY_ID + 1));\n    when(multiDeviceAccountLinked.getRegistrationId(IdentityType.ACI)).thenReturn(multiDeviceLinkedRegistrationId);\n\n    final Account singleDeviceAccount = mock(Account.class);\n    when(singleDeviceAccount.getIdentifier(IdentityType.ACI)).thenReturn(singleDeviceAccountAci);\n    when(singleDeviceAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(singleDeviceAccountUak));\n    when(singleDeviceAccount.getDevices()).thenReturn(List.of(singleDeviceAccountPrimary));\n    when(singleDeviceAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(singleDeviceAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(singleDeviceAccountPrimary));\n\n    final Account multiDeviceAccount = mock(Account.class);\n    when(multiDeviceAccount.getIdentifier(IdentityType.ACI)).thenReturn(multiDeviceAccountAci);\n    when(multiDeviceAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(multiDeviceAccountUak));\n    when(multiDeviceAccount.getDevices()).thenReturn(List.of(multiDeviceAccountPrimary, multiDeviceAccountLinked));\n    when(multiDeviceAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(multiDeviceAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(multiDeviceAccountPrimary));\n    when(multiDeviceAccount.getDevice((byte) (Device.PRIMARY_ID + 1))).thenReturn(Optional.of(multiDeviceAccountLinked));\n\n    final Map<ServiceIdentifier, Account> accountsByServiceIdentifier = Map.of(\n        new AciServiceIdentifier(singleDeviceAccountAci), singleDeviceAccount,\n        new AciServiceIdentifier(multiDeviceAccountAci), multiDeviceAccount,\n        new PniServiceIdentifier(singleDeviceAccountPni), singleDeviceAccount,\n        new PniServiceIdentifier(multiDeviceAccountPni), multiDeviceAccount);\n\n    final byte[] aciMessage = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n        new TestRecipient(new AciServiceIdentifier(singleDeviceAccountAci), Device.PRIMARY_ID, singleDevicePrimaryRegistrationId, new byte[48]),\n        new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), Device.PRIMARY_ID, multiDevicePrimaryRegistrationId, new byte[48]),\n        new TestRecipient(new AciServiceIdentifier(multiDeviceAccountAci), (byte) (Device.PRIMARY_ID + 1), multiDeviceLinkedRegistrationId, new byte[48])));\n\n    when(accountsManager.getByServiceIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    accountsByServiceIdentifier.forEach(((serviceIdentifier, account) ->\n        when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n            .thenReturn(CompletableFuture.completedFuture(Optional.of(account)))));\n\n    final boolean ephemeral = true;\n    final boolean urgent = false;\n    final boolean story = false;\n\n    final Invocation.Builder invocationBuilder = resources\n        .getJerseyTest()\n        .target(\"/v1/messages/multi_recipient\")\n        .queryParam(\"ts\", clock.millis())\n        .queryParam(\"online\", ephemeral)\n        .queryParam(\"story\", story)\n        .queryParam(\"urgent\", urgent)\n        .request()\n        .header(HeaderUtils.GROUP_SEND_TOKEN, AuthHelper.validGroupSendTokenHeader(serverSecretParams,\n            List.of(new AciServiceIdentifier(singleDeviceAccountAci), new AciServiceIdentifier(multiDeviceAccountAci)),\n            START_OF_DAY.plus(Duration.ofDays(1))));\n\n    when(rateLimiter.validateAsync(any(UUID.class)))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    doThrow(new MessageTooLargeException())\n        .when(messageSender).sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n\n    try (final Response response = invocationBuilder\n        .put(Entity.entity(aciMessage, MultiRecipientMessageProvider.MEDIA_TYPE))) {\n\n      assertThat(response.getStatus(), is(equalTo(413)));\n    }\n  }\n\n  @SuppressWarnings(\"SameParameterValue\")\n  private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid,\n      byte sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp) {\n    return generateEnvelope(guid, type, timestamp, sourceUuid, sourceDevice, destinationUuid, updatedPni, content, serverTimestamp, false);\n  }\n\n  private static Envelope generateEnvelope(UUID guid, int type, long timestamp, UUID sourceUuid,\n      byte sourceDevice, UUID destinationUuid, UUID updatedPni, byte[] content, long serverTimestamp, boolean story) {\n\n    final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder()\n        .setType(MessageProtos.Envelope.Type.forNumber(type))\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(serverTimestamp)\n        .setDestinationServiceId(destinationUuid.toString())\n        .setStory(story)\n        .setServerGuid(guid.toString());\n\n    if (sourceUuid != null) {\n      builder.setSourceServiceId(sourceUuid.toString());\n      builder.setSourceDevice(sourceDevice);\n    }\n\n    if (content != null) {\n      builder.setContent(ByteString.copyFrom(content));\n    }\n\n    if (updatedPni != null) {\n      builder.setUpdatedPni(updatedPni.toString());\n    }\n\n    return builder.build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/OneTimeDonationControllerTest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.when;\n\nimport com.stripe.model.PaymentIntent;\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport org.assertj.core.api.InstanceOfAssertFactories;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.OneTimeDonationsManager;\nimport org.whispersystems.textsecuregcm.subscriptions.BraintreeManager;\nimport org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;\nimport org.whispersystems.textsecuregcm.subscriptions.PayPalDonationsTranslator;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentDetails;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentStatus;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass OneTimeDonationControllerTest extends AbstractV1SubscriptionControllerTest {\n\n  private static final PaymentIntent PAYMENT_INTENT = mock(PaymentIntent.class);\n  private static final PayPalDonationsTranslator PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR = mock(\n      PayPalDonationsTranslator.class);\n  private static final OneTimeDonationsManager ONE_TIME_DONATIONS_MANAGER = mock(OneTimeDonationsManager.class);\n\n  private static final OneTimeDonationController ONE_TIME_CONTROLLER = new OneTimeDonationController(CLOCK,\n      ONETIME_CONFIG, STRIPE_MANAGER, BRAINTREE_MANAGER, PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR,\n      ZK_OPS, ISSUED_RECEIPTS_MANAGER, ONE_TIME_DONATIONS_MANAGER);\n\n  private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(CompletionExceptionMapper.class)\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(SubscriptionExceptionMapper.class)\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(ONE_TIME_CONTROLLER)\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    reset(CLOCK, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR);\n\n    when(STRIPE_MANAGER.getProvider()).thenReturn(PaymentProvider.STRIPE);\n    when(BRAINTREE_MANAGER.getProvider()).thenReturn(PaymentProvider.BRAINTREE);\n    when(PAYPAL_ONE_TIME_DONATION_LINE_ITEM_TRANSLATOR.translate(any(), any())).thenReturn(\"Donation to Signal Technology Foundation\");\n\n    List.of(STRIPE_MANAGER, BRAINTREE_MANAGER)\n        .forEach(manager -> when(manager.supportsPaymentMethod(any()))\n            .thenCallRealMethod());\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))\n        .thenReturn(Set.of(\"usd\", \"jpy\", \"bif\", \"eur\"));\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))\n        .thenReturn(Set.of(\"eur\"));\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.IDEAL))\n        .thenReturn(Set.of(\"eur\"));\n    when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL))\n        .thenReturn(Set.of(\"usd\", \"jpy\"));\n  }\n\n\n  @Test\n  void testCreateBoostPaymentIntentAmountBelowCurrencyMinimum() {\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))\n        .thenReturn(Set.of(\"usd\", \"jpy\", \"bif\", \"eur\"));\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/create\")\n        .request()\n        .post(Entity.json(\"\"\"\n              {\n                \"currency\": \"USD\",\n                \"amount\": 249,\n                \"level\": null\n              }\n            \"\"\"));\n    assertThat(response.getStatus()).isEqualTo(400);\n    assertThat(response.hasEntity()).isTrue();\n    final Map responseMap = response.readEntity(Map.class);\n    assertThat(responseMap.get(\"error\")).isEqualTo(\"amount_below_currency_minimum\");\n    assertThat(responseMap.get(\"minimum\")).isEqualTo(\"2.50\");\n  }\n\n  @Test\n  void testCreateBoostPaymentIntentAmountAboveSepaLimit() {\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))\n        .thenReturn(Set.of(\"eur\"));\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/create\")\n        .request()\n        .post(Entity.json(\"\"\"\n              {\n                \"currency\": \"EUR\",\n                \"amount\": 1000001,\n                \"level\": null,\n                \"paymentMethod\": \"SEPA_DEBIT\"\n              }\n            \"\"\"));\n    assertThat(response.getStatus()).isEqualTo(400);\n    assertThat(response.hasEntity()).isTrue();\n\n    final Map responseMap = response.readEntity(Map.class);\n    assertThat(responseMap.get(\"error\")).isEqualTo(\"amount_above_sepa_limit\");\n    assertThat(responseMap.get(\"maximum\")).isEqualTo(\"10000\");\n  }\n\n  @Test\n  void testCreateBoostPaymentIntentUnsupportedCurrency() {\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))\n        .thenReturn(Set.of(\"eur\"));\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/create\")\n        .request()\n        .post(Entity.json(\"\"\"\n              {\n                \"currency\": \"USD\",\n                \"amount\": 3000,\n                \"level\": null,\n                \"paymentMethod\": \"SEPA_DEBIT\"\n              }\n            \"\"\"));\n    assertThat(response.getStatus()).isEqualTo(400);\n    assertThat(response.hasEntity()).isTrue();\n\n    final Map responseMap = response.readEntity(Map.class);\n    assertThat(responseMap.get(\"error\")).isEqualTo(\"unsupported_currency\");\n  }\n\n  @Test\n  void testCreateBoostPaymentIntentLevelAmountMismatch() {\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))\n        .thenReturn(Set.of(\"usd\", \"jpy\", \"bif\", \"eur\"));\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/create\")\n        .request()\n        .post(Entity.json(\"\"\"\n              {\n                \"currency\": \"USD\",\n                \"amount\": 25,\n                \"level\": 100\n              }\n            \"\"\"\n        ));\n    assertThat(response.getStatus()).isEqualTo(409);\n  }\n\n  @Test\n  void testCreateBoostPaymentIntent() {\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))\n        .thenReturn(Set.of(\"usd\", \"jpy\", \"bif\", \"eur\"));\n    when(STRIPE_MANAGER.createPaymentIntent(anyString(), anyLong(), anyLong(), any()))\n        .thenReturn(CompletableFuture.completedFuture(PAYMENT_INTENT));\n\n    String clientSecret = \"some_client_secret\";\n    when(PAYMENT_INTENT.getClientSecret()).thenReturn(clientSecret);\n\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/create\")\n        .request()\n        .post(Entity.json(\"{\\\"currency\\\": \\\"USD\\\", \\\"amount\\\": 300, \\\"level\\\": null}\"));\n    assertThat(response.getStatus()).isEqualTo(200);\n  }\n\n  @Test\n  void testCreateBoostPayPal() {\n    final BraintreeManager.PayPalOneTimePaymentApprovalDetails payPalOneTimePaymentApprovalDetails = mock(\n        BraintreeManager.PayPalOneTimePaymentApprovalDetails.class);\n    when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL))\n        .thenReturn(Set.of(\"usd\", \"jpy\", \"bif\", \"eur\"));\n    when(BRAINTREE_MANAGER.createOneTimePayment(anyString(), anyLong(), anyString(), anyString(), anyString(), anyString()))\n        .thenReturn(CompletableFuture.completedFuture(payPalOneTimePaymentApprovalDetails));\n    when(payPalOneTimePaymentApprovalDetails.approvalUrl()).thenReturn(\"approvalUrl\");\n    when(payPalOneTimePaymentApprovalDetails.paymentId()).thenReturn(\"someId\");\n\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/paypal/create\")\n        .request()\n        .post(Entity.json(\"\"\"\n              {\n                \"currency\": \"USD\",\n                \"amount\": 300,\n                \"cancelUrl\": \"cancelUrl\",\n                \"returnUrl\": \"returnUrl\"\n              }\n            \"\"\"\n        ));\n    assertThat(response.getStatus()).isEqualTo(200);\n  }\n\n  @Test\n  void createBoostReceiptInvalid() {\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/receipt_credentials\")\n        .request()\n        // invalid, request body should have receiptCredentialRequest\n        .post(Entity.json(\"{\\\"paymentIntentId\\\": \\\"foo\\\"}\"));\n    assertThat(response.getStatus()).isEqualTo(422);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void createBoostReceiptPaymentRequired(final ChargeFailure chargeFailure, boolean expectChargeFailure) {\n    when(STRIPE_MANAGER.getPaymentDetails(any())).thenReturn(CompletableFuture.completedFuture(new PaymentDetails(\n        \"id\",\n        Collections.emptyMap(),\n        PaymentStatus.FAILED,\n        Instant.now(),\n        chargeFailure)\n    ));\n    Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/receipt_credentials\")\n        .request()\n        .post(Entity.json(\"\"\"\n            {\n              \"paymentIntentId\": \"foo\",\n              \"receiptCredentialRequest\": \"abcd\",\n              \"processor\": \"STRIPE\"\n            }\n          \"\"\"));\n    assertThat(response.getStatus()).isEqualTo(402);\n\n    if (expectChargeFailure) {\n      assertThat(response.readEntity(OneTimeDonationController.CreateBoostReceiptCredentialsErrorResponse.class).chargeFailure()).isEqualTo(chargeFailure);\n    } else {\n      assertThat(response.readEntity(String.class)).isEqualTo(\"{}\");\n    }\n  }\n\n  private static Stream<Arguments> createBoostReceiptPaymentRequired() {\n    return Stream.of(\n        Arguments.of(new ChargeFailure(\n            \"generic_decline\",\n            \"some failure message\",\n            null,\n            null,\n            null\n        ), true),\n        Arguments.of(null, false)\n    );\n  }\n\n  @Test\n  void confirmPaypalBoostProcessorError() {\n\n    when(BRAINTREE_MANAGER.captureOneTimePayment(anyString(), anyString(), anyString(), anyString(), anyLong(),\n        anyLong(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new SubscriptionProcessorException(PaymentProvider.BRAINTREE,\n            new ChargeFailure(\"2046\", \"Declined\", null, null, null))));\n\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/paypal/confirm\")\n        .request()\n        .post(Entity.json(Map.of(\"payerId\", \"payer123\",\n            \"paymentId\", \"PAYID-456\",\n            \"paymentToken\", \"EC-789\",\n            \"currency\", \"usd\",\n            \"amount\", 123)));\n\n    assertThat(response.getStatus()).isEqualTo(SubscriptionExceptionMapper.PROCESSOR_ERROR_STATUS_CODE);\n\n    final Map responseMap = response.readEntity(Map.class);\n    assertThat(responseMap.get(\"processor\")).isEqualTo(\"BRAINTREE\");\n    assertThat(responseMap.get(\"chargeFailure\")).asInstanceOf(\n            InstanceOfAssertFactories.map(String.class, Object.class))\n        .extracting(\"code\")\n        .isEqualTo(\"2046\");\n  }\n\n  @Test\n  void createBoostReceiptNoRequest() {\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/boost/receipt_credentials\")\n        .request()\n        .post(Entity.json(\"\"));\n    assertThat(response.getStatus()).isEqualTo(422);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/PaymentsControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.core.Response;\nimport java.math.BigDecimal;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass PaymentsControllerTest {\n\n  private static final ExternalServiceCredentialsGenerator paymentsCredentialsGenerator = mock(ExternalServiceCredentialsGenerator.class);\n  private static final CurrencyConversionManager currencyManager                      = mock(CurrencyConversionManager.class);\n\n  private final ExternalServiceCredentials validCredentials = new ExternalServiceCredentials(\"username\", \"password\");\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new PaymentsController(currencyManager, paymentsCredentialsGenerator))\n      .build();\n\n\n  @BeforeEach\n  void setup() {\n    when(paymentsCredentialsGenerator.generateForUuid(eq(AuthHelper.VALID_UUID))).thenReturn(validCredentials);\n    when(currencyManager.getCurrencyConversions()).thenReturn(Optional.of(\n        new CurrencyConversionEntityList(List.of(\n            new CurrencyConversionEntity(\"FOO\", Map.of(\n                \"USD\", new BigDecimal(\"2.35\"),\n                \"EUR\", new BigDecimal(\"1.89\")\n            )),\n            new CurrencyConversionEntity(\"BAR\", Map.of(\n                \"USD\", new BigDecimal(\"1.50\"),\n                \"EUR\", new BigDecimal(\"0.98\")\n            ))\n        ), System.currentTimeMillis())));\n  }\n\n  @Test\n  void testGetAuthToken() {\n    ExternalServiceCredentials token =\n        resources.getJerseyTest()\n            .target(\"/v1/payments/auth\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .get(ExternalServiceCredentials.class);\n\n    assertThat(token.username()).isEqualTo(validCredentials.username());\n    assertThat(token.password()).isEqualTo(validCredentials.password());\n  }\n\n  @Test\n  void testInvalidAuthGetAuthToken() {\n    Response response =\n        resources.getJerseyTest()\n            .target(\"/v1/payments/auth\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.INVALID_UUID, AuthHelper.INVALID_PASSWORD))\n            .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n  }\n\n  @Test\n  void testGetCurrencyConversions() {\n    CurrencyConversionEntityList conversions =\n        resources.getJerseyTest()\n                 .target(\"/v1/payments/conversions\")\n                 .request()\n                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                 .get(CurrencyConversionEntityList.class);\n\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(2);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"2.35\"));\n  }\n\n  @Test\n  void testGetCurrencyConversions_Json() {\n    String json =\n        resources.getJerseyTest()\n            .target(\"/v1/payments/conversions\")\n            .request()\n            .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n            .get(String.class);\n\n    // the currency serialization might occur in either order\n    assertThat(json).containsPattern(\"\\\\{(\\\"EUR\\\":1.89,\\\"USD\\\":2.35|\\\"USD\\\":2.35,\\\"EUR\\\":1.89)}\");\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProfileControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.refEq;\nimport static org.mockito.Mockito.any;\nimport static org.mockito.Mockito.clearInvocations;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.MultivaluedHashMap;\nimport jakarta.ws.rs.core.MultivaluedMap;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executors;\nimport java.util.stream.Stream;\nimport org.assertj.core.api.Condition;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerPublicParams;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKey;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.entities.BaseProfileResponse;\nimport org.whispersystems.textsecuregcm.entities.BatchIdentityCheckRequest;\nimport org.whispersystems.textsecuregcm.entities.BatchIdentityCheckResponse;\nimport org.whispersystems.textsecuregcm.entities.CreateProfileRequest;\nimport org.whispersystems.textsecuregcm.entities.ExpiringProfileKeyCredentialProfileResponse;\nimport org.whispersystems.textsecuregcm.entities.ProfileAvatarUploadAttributes;\nimport org.whispersystems.textsecuregcm.entities.VersionedProfileResponse;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.s3.PolicySigner;\nimport org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass ProfileControllerTest {\n\n  private static final TestClock clock = TestClock.now();\n  private static final AccountsManager accountsManager = mock(AccountsManager.class);\n  private static final ProfilesManager profilesManager = mock(ProfilesManager.class);\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final RateLimiter rateLimiter = mock(RateLimiter.class);\n  private static final RateLimiter usernameRateLimiter = mock(RateLimiter.class);\n\n  private static final PostPolicyGenerator postPolicyGenerator = new PostPolicyGenerator(\"us-west-1\", \"profile-bucket\",\n      \"accessKey\");\n  private static final PolicySigner policySigner = new PolicySigner(\"accessSecret\", \"us-west-1\");\n  private static final ServerZkProfileOperations zkProfileOperations = mock(ServerZkProfileOperations.class);\n  private static final ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n\n  private static final byte[] UNIDENTIFIED_ACCESS_KEY = \"sixteenbytes1234\".getBytes(StandardCharsets.UTF_8);\n  private static final IdentityKey ACCOUNT_IDENTITY_KEY = new IdentityKey(ECKeyPair.generate().getPublicKey());\n  private static final IdentityKey ACCOUNT_PHONE_NUMBER_IDENTITY_KEY = new IdentityKey(ECKeyPair.generate().getPublicKey());\n  private static final IdentityKey ACCOUNT_TWO_IDENTITY_KEY = new IdentityKey(ECKeyPair.generate().getPublicKey());\n  private static final IdentityKey ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY = new IdentityKey(ECKeyPair.generate().getPublicKey());\n  private static final String BASE_64_URL_USERNAME_HASH = \"9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE\";\n  private static final byte[] USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH);\n  @SuppressWarnings(\"unchecked\")\n  private static final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(\n      DynamicConfigurationManager.class);\n\n  private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;\n  private Account profileAccount;\n  private Account capabilitiesAccount;\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new ProfileController(\n          clock,\n          rateLimiters,\n          accountsManager,\n          profilesManager,\n          dynamicConfigurationManager,\n          (acceptableLanguages, accountBadges, isSelf) -> List.of(new Badge(\"TEST\", \"other\", \"Test Badge\",\n              \"This badge is in unit tests.\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\", List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")))\n          ),\n          new BadgesConfiguration(List.of(\n              new BadgeConfiguration(\"TEST\", \"other\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\", List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))),\n              new BadgeConfiguration(\"TEST1\", \"testing\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\", List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))),\n              new BadgeConfiguration(\"TEST2\", \"testing\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\", List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))),\n              new BadgeConfiguration(\"TEST3\", \"testing\", List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\", List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\")))\n          ), List.of(\"TEST1\"), Map.of(1L, \"TEST1\", 2L, \"TEST2\", 3L, \"TEST3\")),\n          postPolicyGenerator,\n          policySigner,\n          serverSecretParams,\n          zkProfileOperations,\n          Executors.newSingleThreadExecutor()))\n      .build();\n\n  @BeforeEach\n  void setup() {\n    reset(profilesManager);\n    clock.pin(Instant.ofEpochSecond(42));\n    AccountsHelper.setupMockUpdate(accountsManager);\n\n    dynamicPaymentsConfiguration = mock(DynamicPaymentsConfiguration.class);\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);\n    when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList());\n\n    when(rateLimiters.getProfileLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getUsernameLookupLimiter()).thenReturn(usernameRateLimiter);\n\n    profileAccount = mock(Account.class);\n\n    when(profileAccount.getIdentityKey(IdentityType.ACI)).thenReturn(ACCOUNT_TWO_IDENTITY_KEY);\n    when(profileAccount.getIdentityKey(IdentityType.PNI)).thenReturn(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);\n    when(profileAccount.getUuid()).thenReturn(AuthHelper.VALID_UUID_TWO);\n    when(profileAccount.getPhoneNumberIdentifier()).thenReturn(AuthHelper.VALID_PNI_TWO);\n    when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.empty());\n    when(profileAccount.getUsernameHash()).thenReturn(Optional.of(USERNAME_HASH));\n    when(profileAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n    when(profileAccount.isIdentifiedBy(eq(new AciServiceIdentifier(AuthHelper.VALID_UUID_TWO)))).thenReturn(true);\n    when(profileAccount.isIdentifiedBy(eq(new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO)))).thenReturn(true);\n\n    capabilitiesAccount = mock(Account.class);\n\n    when(capabilitiesAccount.getUuid()).thenReturn(AuthHelper.VALID_UUID);\n    when(capabilitiesAccount.getIdentityKey(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTITY_KEY);\n    when(capabilitiesAccount.getIdentityKey(IdentityType.PNI)).thenReturn(ACCOUNT_PHONE_NUMBER_IDENTITY_KEY);\n\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n\n    when(accountsManager.getByE164(AuthHelper.VALID_NUMBER_TWO)).thenReturn(Optional.of(profileAccount));\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(profileAccount));\n    when(accountsManager.getByPhoneNumberIdentifier(AuthHelper.VALID_PNI_TWO)).thenReturn(Optional.of(profileAccount));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(AuthHelper.VALID_UUID_TWO))).thenReturn(Optional.of(profileAccount));\n    when(accountsManager.getByServiceIdentifier(new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO))).thenReturn(Optional.of(profileAccount));\n    when(accountsManager.getByUsernameHash(USERNAME_HASH)).thenReturn(CompletableFuture.completedFuture(Optional.of(profileAccount)));\n\n    when(accountsManager.getByE164(AuthHelper.VALID_NUMBER)).thenReturn(Optional.of(capabilitiesAccount));\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(capabilitiesAccount));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(AuthHelper.VALID_UUID))).thenReturn(Optional.of(capabilitiesAccount));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_TWO));\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n\n    when(profilesManager.get(eq(AuthHelper.VALID_UUID), eq(versionHex(\"someversion\")))).thenReturn(Optional.empty());\n    when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), eq(versionHex(\"validversion\")))).thenReturn(Optional.of(new VersionedProfile(\n        versionHex(\"validversion\"), name, \"profiles/validavatar\", emoji, about, null, phoneNumberSharing, \"validcommitment\".getBytes())));\n\n    when(profilesManager.deleteAvatar(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n\n    clearInvocations(rateLimiter);\n    clearInvocations(accountsManager);\n    clearInvocations(usernameRateLimiter);\n    clearInvocations(profilesManager);\n    clearInvocations(zkProfileOperations);\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(accountsManager);\n    reset(rateLimiter);\n  }\n\n  @Test\n  void testProfileGetByAci() throws RateLimitExceededException {\n    final BaseProfileResponse profile = resources.getJerseyTest()\n                              .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO)\n                              .request()\n                              .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                              .get(BaseProfileResponse.class);\n\n    assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);\n    assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(\n        badge -> \"Test Badge\".equals(badge.getName()), \"has badge with expected name\"));\n\n    verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID);\n  }\n\n  @Test\n  void testProfileGetByAciRateLimited() throws RateLimitExceededException {\n    doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(rateLimiter)\n        .validate(AuthHelper.VALID_UUID);\n\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(429);\n    assertThat(response.getHeaderString(\"Retry-After\")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds()));\n  }\n\n  @Test\n  void testProfileGetByAciUnidentified() throws RateLimitExceededException {\n    final BaseProfileResponse profile = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO)\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))\n        .get(BaseProfileResponse.class);\n\n    assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);\n    assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(\n        badge -> \"Test Badge\".equals(badge.getName()), \"has badge with expected name\"));\n\n    verify(rateLimiter, never()).validate(AuthHelper.VALID_UUID);\n  }\n\n  @Test\n  void testProfileGetByAciUnidentifiedBadKey() {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO)\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"incorrect\".getBytes()))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n  }\n\n  @Test\n  void testProfileGetByAciUnidentifiedAccountNotFound() {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + UUID.randomUUID())\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testProfileGetWithGroupSendEndorsement(\n      UUID target, UUID authorizedTarget, Duration timeLeft, boolean includeUak, int expectedResponse) throws Exception {\n\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    clock.pin(expiration.minus(timeLeft));\n\n    Invocation.Builder builder = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + target)\n        .queryParam(\"pq\", \"true\")\n        .request()\n        .header(\n            HeaderUtils.GROUP_SEND_TOKEN,\n            AuthHelper.validGroupSendTokenHeader(serverSecretParams, List.of(new AciServiceIdentifier(authorizedTarget)), expiration));\n\n    if (includeUak) {\n      builder = builder.header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY));\n    }\n\n    Response response = builder.get();\n    assertThat(response.getStatus()).isEqualTo(expectedResponse);\n\n    if (expectedResponse == 200) {\n      final BaseProfileResponse profile = response.readEntity(BaseProfileResponse.class);\n      assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);\n      assertThat(profile.getBadges()).hasSize(1).element(0).has(new Condition<>(\n              badge -> \"Test Badge\".equals(badge.getName()), \"has badge with expected name\"));\n    }\n\n    verifyNoMoreInteractions(rateLimiter);\n  }\n\n  private static Stream<Arguments> testProfileGetWithGroupSendEndorsement() {\n    UUID notExistsUuid = UUID.randomUUID();\n\n    return Stream.of(\n        // valid endorsement\n        Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(1), false, 200),\n\n        // expired endorsement, not authorized\n        Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(-1), false, 401),\n\n        // endorsement for the wrong recipient, not authorized\n        Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID, Duration.ofHours(1), false, 401),\n\n        // expired endorsement for the wrong recipient, not authorized\n        Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID, Duration.ofHours(-1), false, 401),\n\n        // valid endorsement for the right recipient but they aren't registered, not found\n        Arguments.of(notExistsUuid, notExistsUuid, Duration.ofHours(1), false, 404),\n\n        // expired endorsement for the right recipient but they aren't registered, not authorized (NOT not found)\n        Arguments.of(notExistsUuid, notExistsUuid, Duration.ofHours(-1), false, 401),\n\n        // valid endorsement but also a UAK, bad request\n        Arguments.of(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_UUID_TWO, Duration.ofHours(1), true, 400));\n  }\n\n  @Test\n  void testProfileGetByPni() throws RateLimitExceededException {\n    final BaseProfileResponse profile = resources.getJerseyTest()\n        .target(\"/v1/profile/PNI:\" + AuthHelper.VALID_PNI_TWO)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(BaseProfileResponse.class);\n\n    assertThat(profile.getIdentityKey()).isEqualTo(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);\n    assertThat(profile.getBadges()).isEmpty();\n    assertThat(profile.getUuid()).isEqualTo(new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO));\n    assertThat(profile.getCapabilities()).isNotNull();\n    assertThat(profile.isUnrestrictedUnidentifiedAccess()).isFalse();\n    assertThat(profile.getUnidentifiedAccess()).isNull();\n\n    verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID);\n  }\n\n  @Test\n  void testProfileGetByPniRateLimited() throws RateLimitExceededException {\n    doThrow(new RateLimitExceededException(Duration.ofSeconds(13))).when(rateLimiter)\n        .validate(AuthHelper.VALID_UUID);\n\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_PNI_TWO)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(429);\n    assertThat(response.getHeaderString(\"Retry-After\")).isEqualTo(String.valueOf(Duration.ofSeconds(13).toSeconds()));\n  }\n\n  @Test\n  void testProfileGetByPniUnidentified() throws RateLimitExceededException {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/PNI:\" + AuthHelper.VALID_PNI_TWO)\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(UNIDENTIFIED_ACCESS_KEY))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n\n    verify(rateLimiter, never()).validate(AuthHelper.VALID_UUID);\n  }\n\n  @Test\n  void testProfileGetByPniUnidentifiedBadKey() {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_PNI_TWO)\n        .request()\n        .header(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, AuthHelper.getUnidentifiedAccessHeader(\"incorrect\".getBytes()))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n  }\n\n  @Test\n  void testProfileGetUnauthorized() {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO)\n        .request()\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n  }\n\n  @CartesianTest\n  void testProfileCapabilities(\n      @CartesianTest.Values(booleans = {true, false}) final boolean isAttachmentBackfillSupported) {\n    when(capabilitiesAccount.hasCapability(DeviceCapability.ATTACHMENT_BACKFILL)).thenReturn(isAttachmentBackfillSupported);\n    final BaseProfileResponse profile = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_UUID)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(BaseProfileResponse.class);\n\n    assertEquals(isAttachmentBackfillSupported, profile.getCapabilities().get(\"attachmentBackfill\"));\n  }\n\n  @Test\n  void testSetProfileWantAvatarUpload() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n\n    final ProfileAvatarUploadAttributes uploadAttributes = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"someversion\"),\n            name, null, null,\n            null, true, false, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class);\n\n    final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n    verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID), eq(versionHex(\"someversion\")));\n    verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID), profileArgumentCaptor.capture());\n\n    verifyNoMoreInteractions(profilesManager);\n\n    assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n    assertThat(profileArgumentCaptor.getValue().avatar()).isEqualTo(uploadAttributes.getKey());\n    assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(versionHex(\"someversion\"));\n    assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n    assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n    assertThat(profileArgumentCaptor.getValue().about()).isNull();\n  }\n\n  @Test\n  void testSetProfileWantAvatarUploadWithBadProfileSize() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(82);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"someversion\"), name,\n            null, null, null, true, false, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  @Test\n  void testSetProfileWithoutAvatarUpload() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"anotherversion\"), name, null, null,\n            null, false, false, Optional.of(List.of()), phoneNumberSharing), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(versionHex(\"anotherversion\")));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n\n      verifyNoMoreInteractions(profilesManager);\n\n      assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n      assertThat(profileArgumentCaptor.getValue().avatar()).isNull();\n      assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(versionHex(\"anotherversion\"));\n      assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n      assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n      assertThat(profileArgumentCaptor.getValue().about()).isNull();\n      assertThat(profileArgumentCaptor.getValue().phoneNumberSharing()).isEqualTo(phoneNumberSharing);\n    }\n  }\n\n  @Test\n  void testSetProfileWithAvatarUploadAndPreviousAvatar() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n\n    resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"validversion\"),\n            name, null, null,\n            null, true, false, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class);\n\n    final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n    verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(versionHex(\"validversion\")));\n    verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n    verify(profilesManager, times(1)).deleteAvatar(\"profiles/validavatar\");\n\n    assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n    assertThat(profileArgumentCaptor.getValue().avatar()).startsWith(\"profiles/\");\n    assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(versionHex(\"validversion\"));\n    assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n    assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n    assertThat(profileArgumentCaptor.getValue().about()).isNull();\n  }\n\n  @Test\n  void testSetProfileClearPreviousAvatar() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"validversion\"), name,\n            null, null, null, false, false, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(versionHex(\"validversion\")));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n      verify(profilesManager, times(1)).deleteAvatar(eq(\"profiles/validavatar\"));\n\n      assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n      assertThat(profileArgumentCaptor.getValue().avatar()).isNull();\n      assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(versionHex(\"validversion\"));\n      assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n      assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n      assertThat(profileArgumentCaptor.getValue().about()).isNull();\n    }\n  }\n\n  @Test\n  void testSetProfileWithSameAvatar() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"validversion\"), name,\n            null, null, null, true, true, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(versionHex(\"validversion\")));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n      verify(profilesManager, never()).deleteAvatar(anyString());\n\n      assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n      assertThat(profileArgumentCaptor.getValue().avatar()).isEqualTo(\"profiles/validavatar\");\n      assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(versionHex(\"validversion\"));\n      assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n      assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n      assertThat(profileArgumentCaptor.getValue().about()).isNull();\n    }\n  }\n\n  @Test\n  void testSetProfileClearPreviousAvatarDespiteSameAvatarFlagSet() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n\n    try (final Response ignored = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, versionHex(\"validversion\"), name,\n            null, null,\n            null, false, true, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(versionHex(\"validversion\")));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n      verify(profilesManager, times(1)).deleteAvatar(eq(\"profiles/validavatar\"));\n\n      assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n      assertThat(profileArgumentCaptor.getValue().avatar()).isNull();\n      assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(versionHex(\"validversion\"));\n      assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n      assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n      assertThat(profileArgumentCaptor.getValue().about()).isNull();\n    }\n  }\n\n  @Test\n  void testSetProfileWithSameAvatarDespiteNoPreviousAvatar() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final String version = versionHex(\"validversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name,\n            null, null, null, true, true, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID), eq(version));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID), profileArgumentCaptor.capture());\n      verify(profilesManager, never()).deleteAvatar(anyString());\n\n      assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n      assertThat(profileArgumentCaptor.getValue().avatar()).isNull();\n      assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(version);\n      assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n      assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n      assertThat(profileArgumentCaptor.getValue().about()).isNull();\n    }\n  }\n\n  @Test\n  void testSetProfileExtendedName() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO));\n\n    final byte[] name = TestRandomUtil.nextBytes(285);\n\n    final String version = versionHex(\"validversion\");\n    resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(\n            new CreateProfileRequest(commitment, version, name,\n                null, null, null, true, false, Optional.of(List.of()), null),\n            MediaType.APPLICATION_JSON_TYPE), ProfileAvatarUploadAttributes.class);\n\n    final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n    verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(version));\n    verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n    verify(profilesManager, times(1)).deleteAvatar(\"profiles/validavatar\");\n\n    assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n    assertThat(profileArgumentCaptor.getValue().avatar()).startsWith(\"profiles/\");\n    assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(version);\n    assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n    assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n    assertThat(profileArgumentCaptor.getValue().about()).isNull();\n  }\n\n  @Test\n  void testSetProfileEmojiAndBioText() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n\n    final String version = versionHex(\"anotherversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(\n            new CreateProfileRequest(commitment, version, name, emoji, about, null,\n                false, false, Optional.of(List.of()), null),\n            MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(version));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n\n      verifyNoMoreInteractions(profilesManager);\n\n      final VersionedProfile profile = profileArgumentCaptor.getValue();\n      assertThat(profile.commitment()).isEqualTo(commitment.serialize());\n      assertThat(profile.avatar()).isNull();\n      assertThat(profile.version()).isEqualTo(version);\n      assertThat(profile.name()).isEqualTo(name);\n      assertThat(profile.aboutEmoji()).isEqualTo(emoji);\n      assertThat(profile.about()).isEqualTo(about);\n      assertThat(profile.paymentAddress()).isNull();\n    }\n  }\n\n  @Test\n  void testSetProfilePaymentAddress() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n\n    final String version = versionHex(\"yetanotherversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(\n            new CreateProfileRequest(commitment, version, name,\n                null, null, paymentAddress, false, false,\n                Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager).get(eq(AuthHelper.VALID_UUID_TWO), eq(version));\n      verify(profilesManager).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n\n      verifyNoMoreInteractions(profilesManager);\n\n      final VersionedProfile profile = profileArgumentCaptor.getValue();\n      assertThat(profile.commitment()).isEqualTo(commitment.serialize());\n      assertThat(profile.avatar()).isNull();\n      assertThat(profile.version()).isEqualTo(version);\n      assertThat(profile.name()).isEqualTo(name);\n      assertThat(profile.aboutEmoji()).isNull();\n      assertThat(profile.about()).isNull();\n      assertThat(profile.paymentAddress()).isEqualTo(paymentAddress);\n    }\n  }\n\n  @Test\n  void testSetProfilePaymentAddressCountryNotAllowed() throws InvalidInputException {\n    when(dynamicPaymentsConfiguration.getDisallowedPrefixes())\n        .thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3)));\n\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(\n            new CreateProfileRequest(commitment, versionHex(\"yetanotherversion\"), name,\n                null, null, paymentAddress, false, false,\n                Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(403);\n      assertThat(response.hasEntity()).isFalse();\n\n      verify(profilesManager, never()).set(any(), any());\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testSetProfilePaymentAddressCountryNotAllowedExistingPaymentAddress(\n      final boolean existingPaymentAddressOnProfile) throws InvalidInputException {\n    when(dynamicPaymentsConfiguration.getDisallowedPrefixes())\n        .thenReturn(List.of(AuthHelper.VALID_NUMBER_TWO.substring(0, 3)));\n\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), any()))\n        .thenReturn(Optional.of(\n            new VersionedProfile(\"1\", name, null, null, null,\n                existingPaymentAddressOnProfile ? TestRandomUtil.nextBytes(582) : null,\n                phoneNumberSharing,\n                commitment.serialize())));\n\n    final String version = versionHex(\"yetanotherversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(\n            new CreateProfileRequest(commitment, version, name,\n                null, null, paymentAddress, false, false,\n                Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      if (existingPaymentAddressOnProfile) {\n        assertThat(response.getStatus()).isEqualTo(200);\n        assertThat(response.hasEntity()).isFalse();\n\n        final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n        verify(profilesManager).get(eq(AuthHelper.VALID_UUID_TWO), eq(version));\n        verify(profilesManager).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n\n        verifyNoMoreInteractions(profilesManager);\n\n        final VersionedProfile profile = profileArgumentCaptor.getValue();\n        assertThat(profile.commitment()).isEqualTo(commitment.serialize());\n        assertThat(profile.avatar()).isNull();\n        assertThat(profile.version()).isEqualTo(version);\n        assertThat(profile.name()).isEqualTo(name);\n        assertThat(profile.aboutEmoji()).isNull();\n        assertThat(profile.about()).isNull();\n        assertThat(profile.paymentAddress()).isEqualTo(paymentAddress);\n      } else {\n        assertThat(response.getStatus()).isEqualTo(403);\n        assertThat(response.hasEntity()).isFalse();\n\n        verify(profilesManager, never()).set(any(), any());\n      }\n    }\n  }\n\n  @Test\n  void testSetProfilePhoneNumberSharing() throws Exception {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final String version = versionHex(\"anotherversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name, null, null,\n            null, false, false, Optional.of(List.of()), phoneNumberSharing), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n      verify(profilesManager, times(1)).get(eq(AuthHelper.VALID_UUID_TWO), eq(version));\n      verify(profilesManager, times(1)).set(eq(AuthHelper.VALID_UUID_TWO), profileArgumentCaptor.capture());\n\n      verifyNoMoreInteractions(profilesManager);\n\n      assertThat(profileArgumentCaptor.getValue().commitment()).isEqualTo(commitment.serialize());\n      assertThat(profileArgumentCaptor.getValue().avatar()).isNull();\n      assertThat(profileArgumentCaptor.getValue().version()).isEqualTo(version);\n      assertThat(profileArgumentCaptor.getValue().name()).isEqualTo(name);\n      assertThat(profileArgumentCaptor.getValue().aboutEmoji()).isNull();\n      assertThat(profileArgumentCaptor.getValue().about()).isNull();\n    }\n  }\n\n  @Test\n  void testGetProfileByVersion() throws RateLimitExceededException {\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n\n    final String version = versionHex(\"validversion\");\n    when(profilesManager.get(eq(AuthHelper.VALID_UUID_TWO), eq(version))).thenReturn(Optional.of(new VersionedProfile(\n        version, name, \"profiles/validavatar\", emoji, about, null, phoneNumberSharing, \"validcommitment\".getBytes())));\n\n    final VersionedProfileResponse profile = resources.getJerseyTest()\n        .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO + \"/\" + version)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(VersionedProfileResponse.class);\n\n    assertThat(profile.baseProfileResponse().getIdentityKey()).isEqualTo(ACCOUNT_TWO_IDENTITY_KEY);\n    assertThat(profile.name()).containsExactly(name);\n    assertThat(profile.about()).containsExactly(about);\n    assertThat(profile.aboutEmoji()).containsExactly(emoji);\n    assertThat(profile.avatar()).isEqualTo(\"profiles/validavatar\");\n    assertThat(profile.phoneNumberSharing()).containsExactly(phoneNumberSharing);\n    assertThat(profile.baseProfileResponse().getUuid()).isEqualTo(new AciServiceIdentifier(AuthHelper.VALID_UUID_TWO));\n    assertThat(profile.baseProfileResponse().getBadges()).hasSize(1).element(0).has(new Condition<>(\n        badge -> \"Test Badge\".equals(badge.getName()), \"has badge with expected name\"));\n\n    verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID);\n  }\n\n  @Test\n  void testSetProfileUpdatesAccountCurrentVersion() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO));\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n\n    final String version = versionHex(\"someversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(\n            new CreateProfileRequest(commitment, version, name, null, null, paymentAddress, false, false,\n                Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      verify(AuthHelper.VALID_ACCOUNT_TWO).setCurrentProfileVersion(version);\n    }\n  }\n\n  @Test\n  void testGetProfileReturnsNoPaymentAddressIfCurrentVersionMismatch() {\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final String version = versionHex(\"validversion\");\n    when(profilesManager.get(AuthHelper.VALID_UUID_TWO, version)).thenReturn(\n        Optional.of(new VersionedProfile(null, null, null, null, null, paymentAddress, null, null)));\n\n    {\n      final VersionedProfileResponse profile = resources.getJerseyTest()\n          .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO + \"/\" + version)\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .get(VersionedProfileResponse.class);\n\n      assertThat(profile.paymentAddress()).containsExactly(paymentAddress);\n    }\n\n    when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of(version));\n\n    {\n      final VersionedProfileResponse profile = resources.getJerseyTest()\n          .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO + \"/\" + version)\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .get(VersionedProfileResponse.class);\n\n      assertThat(profile.paymentAddress()).containsExactly(paymentAddress);\n    }\n\n    when(profileAccount.getCurrentProfileVersion()).thenReturn(Optional.of(versionHex(\"someotherversion\")));\n\n    {\n      final VersionedProfileResponse profile = resources.getJerseyTest()\n          .target(\"/v1/profile/\" + AuthHelper.VALID_UUID_TWO + \"/\" + version)\n          .request()\n          .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n          .get(VersionedProfileResponse.class);\n\n      assertThat(profile.paymentAddress()).isNull();\n    }\n  }\n\n  @Test\n  void testGetProfileWithExpiringProfileKeyCredentialVersionNotFound() throws VerificationFailedException {\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID);\n    when(account.getCurrentProfileVersion()).thenReturn(Optional.of(versionHex(\"version\")));\n\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID)).thenReturn(Optional.of(account));\n    when(profilesManager.get(any(), any())).thenReturn(Optional.empty());\n\n    final ExpiringProfileKeyCredentialProfileResponse profile = resources.getJerseyTest()\n        .target(String.format(\"/v1/profile/%s/%s/%s\", AuthHelper.VALID_UUID, versionHex(\"version-that-does-not-exist\"), \"credential-request\"))\n        .queryParam(\"credentialType\", \"expiringProfileKey\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .get(ExpiringProfileKeyCredentialProfileResponse.class);\n\n    assertThat(profile.getVersionedProfileResponse().baseProfileResponse().getUuid())\n        .isEqualTo(new AciServiceIdentifier(AuthHelper.VALID_UUID));\n\n    assertThat(profile.getCredential()).isNull();\n\n    verify(zkProfileOperations, never()).issueExpiringProfileKeyCredential(any(), any(), any(), any());\n  }\n\n  @Test\n  void testSetProfileBadges() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n\n    final String version = versionHex(\"anotherversion\");\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name, emoji, about, null, false, false,\n            Optional.of(List.of(\"TEST2\")), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      @SuppressWarnings(\"unchecked\")\n      final ArgumentCaptor<List<AccountBadge>> badgeCaptor = ArgumentCaptor.forClass(List.class);\n      verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture());\n\n      final List<AccountBadge> badges = badgeCaptor.getValue();\n      assertThat(badges).isNotNull().hasSize(1).containsOnly(new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true));\n\n      clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n      when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of(\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true)\n      ));\n    }\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name, emoji, about, null, false, false,\n            Optional.of(List.of(\"TEST3\", \"TEST2\")), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      //noinspection unchecked\n      final ArgumentCaptor<List<AccountBadge>> badgeCaptor = ArgumentCaptor.forClass(List.class);\n      verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture());\n\n      final List<AccountBadge> badges = badgeCaptor.getValue();\n      assertThat(badges).isNotNull().hasSize(2).containsOnly(\n          new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), true),\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true));\n\n      clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n      when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of(\n          new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), true),\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true)\n      ));\n    }\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name, emoji, about, null, false, false,\n            Optional.of(List.of(\"TEST2\", \"TEST3\")), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      //noinspection unchecked\n      final ArgumentCaptor<List<AccountBadge>> badgeCaptor = ArgumentCaptor.forClass(List.class);\n      verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture());\n\n      final List<AccountBadge> badges = badgeCaptor.getValue();\n      assertThat(badges).isNotNull().hasSize(2).containsOnly(\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true),\n          new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), true));\n\n      clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n      when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of(\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true),\n          new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), true)\n      ));\n    }\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name, emoji, about, null, false, false,\n            Optional.of(List.of(\"TEST1\")), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      //noinspection unchecked\n      final ArgumentCaptor<List<AccountBadge>> badgeCaptor = ArgumentCaptor.forClass(List.class);\n      verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), badgeCaptor.capture());\n\n      final List<AccountBadge> badges = badgeCaptor.getValue();\n      assertThat(badges).isNotNull().hasSize(3).containsOnly(\n          new AccountBadge(\"TEST1\", Instant.ofEpochSecond(42 + 86400), true),\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), false),\n          new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), false));\n    }\n\n  }\n\n  @Test\n  void testSetProfileBadgeAfterUpdateTries() throws Exception {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(\n        new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final String version = versionHex(\"anotherversion\");\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n    reset(accountsManager);\n    final int accountsManagerUpdateRetryCount = 2;\n    AccountsHelper.setupMockUpdateWithRetries(accountsManager, accountsManagerUpdateRetryCount);\n    when(accountsManager.getByAccountIdentifier(AuthHelper.VALID_UUID_TWO)).thenReturn(Optional.of(AuthHelper.VALID_ACCOUNT_TWO));\n    // set up two invocations -- one for each AccountsManager#update try\n    when(AuthHelper.VALID_ACCOUNT_TWO.getBadges())\n        .thenReturn(List.of(\n            new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true),\n            new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), true)\n        ))\n        .thenReturn(List.of(\n            new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), true),\n            new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), true),\n            new AccountBadge(\"TEST4\", Instant.ofEpochSecond(43 + 86400), true)\n        ));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version, name, emoji, about, null, false, false,\n            Optional.of(List.of(\"TEST1\")), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      //noinspection unchecked\n      final ArgumentCaptor<List<AccountBadge>> badgeCaptor = ArgumentCaptor.forClass(List.class);\n      verify(AuthHelper.VALID_ACCOUNT_TWO, times(accountsManagerUpdateRetryCount)).setBadges(refEq(clock), badgeCaptor.capture());\n      // since the stubbing of getBadges() is brittle, we need to verify the number of invocations, to protect against upstream changes\n      verify(AuthHelper.VALID_ACCOUNT_TWO, times(accountsManagerUpdateRetryCount)).getBadges();\n\n      final List<AccountBadge> badges = badgeCaptor.getValue();\n      assertThat(badges).isNotNull().hasSize(4).containsOnly(\n          new AccountBadge(\"TEST1\", Instant.ofEpochSecond(42 + 86400), true),\n          new AccountBadge(\"TEST2\", Instant.ofEpochSecond(42 + 86400), false),\n          new AccountBadge(\"TEST3\", Instant.ofEpochSecond(42 + 86400), false),\n          new AccountBadge(\"TEST4\", Instant.ofEpochSecond(43 + 86400), false));\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testGetProfileWithExpiringProfileKeyCredential(final MultivaluedMap<String, Object> authHeaders)\n      throws VerificationFailedException, InvalidInputException {\n    final String version = versionHex(\"version\");\n\n    final ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n    final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();\n\n    final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams);\n    final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams);\n\n    final byte[] profileKeyBytes = TestRandomUtil.nextBytes(32);\n\n    final ProfileKey profileKey = new ProfileKey(profileKeyBytes);\n    final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    final VersionedProfile versionedProfile = mock(VersionedProfile.class);\n    when(versionedProfile.commitment()).thenReturn(profileKeyCommitment.serialize());\n\n    final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext =\n        clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(AuthHelper.VALID_UUID), profileKey);\n\n    final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID);\n    when(account.getCurrentProfileVersion()).thenReturn(Optional.of(version));\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n    when(account.isIdentifiedBy(new AciServiceIdentifier(AuthHelper.VALID_UUID))).thenReturn(true);\n\n    final Instant expiration = Instant.now().plus(org.whispersystems.textsecuregcm.util.ProfileHelper.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION)\n        .truncatedTo(ChronoUnit.DAYS);\n\n    final ExpiringProfileKeyCredentialResponse credentialResponse =\n        serverZkProfile.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(AuthHelper.VALID_UUID), profileKeyCommitment, expiration);\n\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(AuthHelper.VALID_UUID))).thenReturn(Optional.of(account));\n    when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile));\n    when(zkProfileOperations.issueExpiringProfileKeyCredential(eq(credentialRequest), eq(new ServiceId.Aci(AuthHelper.VALID_UUID)), eq(profileKeyCommitment), any()))\n        .thenReturn(credentialResponse);\n\n    final ExpiringProfileKeyCredentialProfileResponse profile = resources.getJerseyTest()\n        .target(String.format(\"/v1/profile/%s/%s/%s\", AuthHelper.VALID_UUID, version,\n            HexFormat.of().formatHex(credentialRequest.serialize())))\n        .queryParam(\"credentialType\", \"expiringProfileKey\")\n        .request()\n        .headers(authHeaders)\n        .get(ExpiringProfileKeyCredentialProfileResponse.class);\n\n    assertThat(profile.getVersionedProfileResponse().baseProfileResponse().getUuid())\n        .isEqualTo(new AciServiceIdentifier(AuthHelper.VALID_UUID));\n    assertThat(profile.getCredential()).isEqualTo(credentialResponse);\n\n    verify(zkProfileOperations).issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(AuthHelper.VALID_UUID), profileKeyCommitment, expiration);\n\n    final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams);\n    assertThatNoException().isThrownBy(() ->\n        clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, profile.getCredential()));\n  }\n\n  private static Stream<Arguments> testGetProfileWithExpiringProfileKeyCredential() {\n    return Stream.of(\n        Arguments.of(new MultivaluedHashMap<>(Map.of(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_KEY)))),\n        Arguments.of(new MultivaluedHashMap<>(Map.of(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD)))),\n        Arguments.of(new MultivaluedHashMap<>(Map.of(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))))\n    );\n  }\n\n  @Test\n  void testGetProfileWithExpiringProfileKeyCredentialBadRequest()\n      throws VerificationFailedException, InvalidInputException {\n    final String version = versionHex(\"version\");\n\n    final ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n    final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();\n\n    final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams);\n\n    final byte[] profileKeyBytes = TestRandomUtil.nextBytes(32);\n\n    final ProfileKey profileKey = new ProfileKey(profileKeyBytes);\n    final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    final VersionedProfile versionedProfile = mock(VersionedProfile.class);\n    when(versionedProfile.commitment()).thenReturn(profileKeyCommitment.serialize());\n\n    final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext =\n        clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(AuthHelper.VALID_UUID), profileKey);\n\n    final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest();\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(AuthHelper.VALID_UUID);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n    when(account.isIdentifiedBy(new AciServiceIdentifier(AuthHelper.VALID_UUID))).thenReturn(true);\n\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(AuthHelper.VALID_UUID))).thenReturn(Optional.of(account));\n    when(profilesManager.get(AuthHelper.VALID_UUID, version)).thenReturn(Optional.of(versionedProfile));\n    when(zkProfileOperations.issueExpiringProfileKeyCredential(any(), any(), any(), any()))\n        .thenThrow(new VerificationFailedException());\n\n    final Response response = resources.getJerseyTest()\n        .target(String.format(\"/v1/profile/%s/%s/%s\", AuthHelper.VALID_UUID, version,\n            HexFormat.of().formatHex(credentialRequest.serialize())))\n        .queryParam(\"credentialType\", \"expiringProfileKey\")\n        .request()\n        .headers(new MultivaluedHashMap<>(Map.of(HeaderUtils.UNIDENTIFIED_ACCESS_KEY, Base64.getEncoder().encodeToString(UNIDENTIFIED_ACCESS_KEY))))\n        .get();\n\n    assertEquals(400, response.getStatus());\n  }\n\n  @Test\n  void testSetProfileBadgesMissingFromRequest() throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID));\n\n    clearInvocations(AuthHelper.VALID_ACCOUNT_TWO);\n\n    final String name = ProfileTestHelper.generateRandomBase64FromByteArray(81);\n    final String emoji = ProfileTestHelper.generateRandomBase64FromByteArray(60);\n    final String text = ProfileTestHelper.generateRandomBase64FromByteArray(156);\n\n    when(AuthHelper.VALID_ACCOUNT_TWO.getBadges()).thenReturn(List.of(\n        new AccountBadge(\"TEST\", Instant.ofEpochSecond(42 + 86400), true)\n    ));\n\n    // Older clients may not include badges in their requests\n    final String requestJson = String.format(\"\"\"\n        {\n          \"commitment\": \"%s\",\n          \"version\": \"%s\",\n          \"name\": \"%s\",\n          \"avatar\": false,\n          \"aboutEmoji\": \"%s\",\n          \"about\": \"%s\"\n        }\n        \"\"\",\n        Base64.getEncoder().encodeToString(commitment.serialize()), versionHex(\"version\"), name, emoji, text);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .put(Entity.json(requestJson))) {\n\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.hasEntity()).isFalse();\n\n      verify(AuthHelper.VALID_ACCOUNT_TWO).setBadges(refEq(clock), eq(List.of(new AccountBadge(\"TEST\", Instant.ofEpochSecond(42 + 86400), true))));\n    }\n  }\n\n  @Test\n  void testBatchIdentityCheck() {\n    try (final Response response = resources.getJerseyTest().target(\"/v1/profile/identity_check/batch\").request()\n        .post(Entity.json(new BatchIdentityCheckRequest(List.of(\n            new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(AuthHelper.VALID_UUID),\n                convertKeyToFingerprint(ACCOUNT_IDENTITY_KEY)),\n            new BatchIdentityCheckRequest.Element(new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO),\n                convertKeyToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY)),\n            new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(AuthHelper.INVALID_UUID),\n                convertKeyToFingerprint(ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY))\n        ))))) {\n      assertThat(response).isNotNull();\n      assertThat(response.getStatus()).isEqualTo(200);\n      BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);\n      assertThat(identityCheckResponse).isNotNull();\n      assertThat(identityCheckResponse.elements()).isNotNull().isEmpty();\n    }\n\n    final Map<ServiceIdentifier, IdentityKey> expectedIdentityKeys = Map.of(\n        new AciServiceIdentifier(AuthHelper.VALID_UUID), ACCOUNT_IDENTITY_KEY,\n        new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO), ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY,\n        new AciServiceIdentifier(AuthHelper.VALID_UUID_TWO), ACCOUNT_TWO_IDENTITY_KEY);\n\n    final Condition<BatchIdentityCheckResponse.Element> isAnExpectedUuid =\n        new Condition<>(element -> element.identityKey()\n            .equals(expectedIdentityKeys.get(element.uuid())),\n            \"is an expected UUID with the correct identity key\");\n\n    final IdentityKey validAciIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n    final IdentityKey secondValidPniIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n    final IdentityKey invalidAciIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n    try (final Response response = resources.getJerseyTest().target(\"/v1/profile/identity_check/batch\").request()\n        .post(Entity.json(new BatchIdentityCheckRequest(List.of(\n            new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(AuthHelper.VALID_UUID),\n                convertKeyToFingerprint(validAciIdentityKey)),\n            new BatchIdentityCheckRequest.Element(new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO),\n                convertKeyToFingerprint(secondValidPniIdentityKey)),\n            new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(AuthHelper.INVALID_UUID),\n                convertKeyToFingerprint(invalidAciIdentityKey))\n        ))))) {\n      assertThat(response).isNotNull();\n      assertThat(response.getStatus()).isEqualTo(200);\n      BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);\n      assertThat(identityCheckResponse).isNotNull();\n      assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);\n      assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);\n      assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);\n    }\n\n    final List<BatchIdentityCheckRequest.Element> largeElementList = new ArrayList<>(List.of(\n        new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(AuthHelper.VALID_UUID),\n            convertKeyToFingerprint(validAciIdentityKey)),\n        new BatchIdentityCheckRequest.Element(new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO),\n            convertKeyToFingerprint(secondValidPniIdentityKey)),\n        new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(AuthHelper.INVALID_UUID),\n            convertKeyToFingerprint(invalidAciIdentityKey))));\n\n    for (int i = 0; i < 900; i++) {\n      largeElementList.add(\n          new BatchIdentityCheckRequest.Element(new AciServiceIdentifier(UUID.randomUUID()),\n              convertKeyToFingerprint(new IdentityKey(ECKeyPair.generate().getPublicKey()))));\n    }\n\n    try (final Response response = resources.getJerseyTest().target(\"/v1/profile/identity_check/batch\").request()\n        .post(Entity.json(new BatchIdentityCheckRequest(largeElementList)))) {\n      assertThat(response).isNotNull();\n      assertThat(response.getStatus()).isEqualTo(200);\n      BatchIdentityCheckResponse identityCheckResponse = response.readEntity(BatchIdentityCheckResponse.class);\n      assertThat(identityCheckResponse).isNotNull();\n      assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);\n      assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);\n      assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);\n    }\n  }\n\n  @Test\n  void testBatchIdentityCheckDeserialization() throws Exception {\n\n    final Map<ServiceIdentifier, IdentityKey> expectedIdentityKeys = Map.of(\n        new AciServiceIdentifier(AuthHelper.VALID_UUID), ACCOUNT_IDENTITY_KEY,\n        new PniServiceIdentifier(AuthHelper.VALID_PNI_TWO), ACCOUNT_TWO_PHONE_NUMBER_IDENTITY_KEY);\n\n    final Condition<BatchIdentityCheckResponse.Element> isAnExpectedUuid =\n        new Condition<>(element -> element.identityKey().equals(expectedIdentityKeys.get(element.uuid())),\n            \"is an expected UUID with the correct identity key\");\n\n    // null properties are ok to omit\n    final String json = String.format(\"\"\"\n            {\n              \"elements\": [\n                { \"uuid\": \"%s\", \"fingerprint\": \"%s\" },\n                { \"uuid\": \"%s\", \"fingerprint\": \"%s\" },\n                { \"uuid\": \"%s\", \"fingerprint\": \"%s\" }\n              ]\n            }\n            \"\"\", AuthHelper.VALID_UUID, Base64.getEncoder().encodeToString(convertKeyToFingerprint(new IdentityKey(ECKeyPair.generate().getPublicKey()))),\n        \"PNI:\" + AuthHelper.VALID_PNI_TWO, Base64.getEncoder().encodeToString(convertKeyToFingerprint(new IdentityKey(ECKeyPair.generate().getPublicKey()))),\n        AuthHelper.INVALID_UUID, Base64.getEncoder().encodeToString(convertKeyToFingerprint(new IdentityKey(ECKeyPair.generate().getPublicKey()))));\n\n    try (final Response response = resources.getJerseyTest().target(\"/v1/profile/identity_check/batch\").request()\n        .post(Entity.entity(json, \"application/json\"))) {\n      assertThat(response).isNotNull();\n      assertThat(response.getStatus()).isEqualTo(200);\n      String responseJson = response.readEntity(String.class);\n\n      // `null` properties should be omitted from the response\n      assertThat(responseJson).doesNotContain(\"null\");\n\n      final BatchIdentityCheckResponse identityCheckResponse =\n          SystemMapper.jsonMapper().readValue(responseJson, BatchIdentityCheckResponse.class);\n\n      assertThat(identityCheckResponse).isNotNull();\n      assertThat(identityCheckResponse.elements()).isNotNull().hasSize(2);\n      assertThat(identityCheckResponse.elements()).element(0).isNotNull().is(isAnExpectedUuid);\n      assertThat(identityCheckResponse.elements()).element(1).isNotNull().is(isAnExpectedUuid);\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testBatchIdentityCheckDeserializationBadRequest(final String json, final int expectedStatus) {\n    try (final Response response = resources.getJerseyTest().target(\"/v1/profile/identity_check/batch\").request()\n        .post(Entity.entity(json, \"application/json\"))) {\n      assertThat(response).isNotNull();\n      assertThat(response.getStatus()).isEqualTo(expectedStatus);\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"\", \"64charactersbutnothexFFFFFFFFFFF64charactersbutnothexFFFFFFFFFFF\", \"DEADBEEF\"})\n  void testInvalidVersionString(final String version) throws InvalidInputException {\n    final ProfileKeyCommitment commitment = new ProfileKey(new byte[32]).getCommitment(\n        new ServiceId.Aci(AuthHelper.VALID_UUID));\n    final byte[] name = TestRandomUtil.nextBytes(81);\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/profile/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new CreateProfileRequest(commitment, version,\n                name, null, null,\n                null, true, false, Optional.of(List.of()), null), MediaType.APPLICATION_JSON_TYPE))) {\n\n      assertThat(response.getStatus()).isEqualTo(422);\n    }\n  }\n\n  static Stream<Arguments> testBatchIdentityCheckDeserializationBadRequest() {\n    return Stream.of(\n        Arguments.of( // aci and uuid cannot both be null\n            String.format(\"\"\"\n                {\n                  \"elements\": [\n                    { \"uuid\": null, \"fingerprint\": \"%s\" }\n                  ]\n                }\n                \"\"\", Base64.getEncoder().encodeToString(convertKeyToFingerprint(new IdentityKey(ECKeyPair.generate().getPublicKey())))),\n            422),\n        Arguments.of( // a blank string is invalid\n            String.format(\"\"\"\n                {\n                  \"elements\": [\n                    { \"uuid\": \" \", \"fingerprint\": \"%s\" }\n                  ]\n                }\n                \"\"\", Base64.getEncoder().encodeToString(convertKeyToFingerprint(new IdentityKey(ECKeyPair.generate().getPublicKey())))),\n            400)\n    );\n  }\n\n\n\n  private static byte[] convertKeyToFingerprint(final IdentityKey publicKey) {\n    try {\n      return Util.truncate(MessageDigest.getInstance(\"SHA-256\").digest(publicKey.serialize()), 4);\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(\"All Java implementations must support SHA-256 MessageDigest algorithm\", e);\n    }\n  }\n\n  private static String versionHex(final String versionString) {\n    try {\n      return HexFormat.of().formatHex(MessageDigest.getInstance(\"SHA-256\").digest(versionString.getBytes(StandardCharsets.UTF_8)));\n    } catch (NoSuchAlgorithmException e) {\n      throw new AssertionError(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/ProvisioningControllerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.Base64;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.ProvisioningMessage;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.push.ProvisioningManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.websocket.ProvisioningConnectListener;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass ProvisioningControllerTest {\n\n  private RateLimiter messagesRateLimiter;\n\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private static final ProvisioningManager provisioningManager = mock(ProvisioningManager.class);\n\n  private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new ProvisioningController(rateLimiters, provisioningManager))\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    reset(rateLimiters, provisioningManager);\n\n    messagesRateLimiter = mock(RateLimiter.class);\n    when(rateLimiters.getMessagesLimiter()).thenReturn(messagesRateLimiter);\n  }\n\n  @Test\n  void sendProvisioningMessage() {\n    final String provisioningAddress = ProvisioningConnectListener.generateProvisioningAddress();\n    final byte[] messageBody = \"test\".getBytes(StandardCharsets.UTF_8);\n\n    when(provisioningManager.sendProvisioningMessage(any(), any())).thenReturn(true);\n\n    try (final Response response = RESOURCE_EXTENSION.getJerseyTest()\n        .target(\"/v1/provisioning/\" + provisioningAddress)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new ProvisioningMessage(Base64.getMimeEncoder().encodeToString(messageBody)),\n            MediaType.APPLICATION_JSON))) {\n\n      assertEquals(Response.Status.NO_CONTENT.getStatusCode(), response.getStatus());\n\n      verify(provisioningManager).sendProvisioningMessage(provisioningAddress, messageBody);\n    }\n  }\n\n  @Test\n  void sendProvisioningMessageRateLimited() throws RateLimitExceededException {\n    final String provisioningAddress = ProvisioningConnectListener.generateProvisioningAddress();\n    final byte[] messageBody = \"test\".getBytes(StandardCharsets.UTF_8);\n\n    doThrow(new RateLimitExceededException(Duration.ZERO))\n        .when(messagesRateLimiter).validate(AuthHelper.VALID_UUID);\n\n    try (final Response response = RESOURCE_EXTENSION.getJerseyTest()\n        .target(\"/v1/provisioning/\" + provisioningAddress)\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .put(Entity.entity(new ProvisioningMessage(Base64.getMimeEncoder().encodeToString(messageBody)),\n            MediaType.APPLICATION_JSON))) {\n\n      assertEquals(429, response.getStatus());\n\n      verify(provisioningManager, never()).sendProvisioningMessage(any(), any());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/RegistrationControllerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.HttpHeaders;\nimport jakarta.ws.rs.core.Response;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.EnumSet;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.apache.http.HttpStatus;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.ArgumentSets;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.PhoneVerificationTokenManager;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockError;\nimport org.whispersystems.textsecuregcm.auth.RegistrationLockVerificationManager;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.AccountCreationResponse;\nimport org.whispersystems.textsecuregcm.entities.AccountIdentityResponse;\nimport org.whispersystems.textsecuregcm.entities.ApnRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.DeviceActivationRequest;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.GcmRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.RegistrationRequest;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport org.whispersystems.textsecuregcm.spam.RegistrationRecoveryChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.DeviceSpec;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass RegistrationControllerTest {\n\n  private static final long SESSION_EXPIRATION_SECONDS = Duration.ofMinutes(10).toSeconds();\n\n\n  private static final String NUMBER = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n      PhoneNumberUtil.PhoneNumberFormat.E164);\n  private static final String PASSWORD = \"password\";\n  private static final String REGLOCK = RandomStringUtils.insecure().nextAlphanumeric(64);\n\n  private final AccountsManager accountsManager = mock(AccountsManager.class);\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class);\n  private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class);\n  private final RegistrationLockVerificationManager registrationLockVerificationManager = mock(\n      RegistrationLockVerificationManager.class);\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock(\n      RegistrationRecoveryPasswordsManager.class);\n  private final RegistrationRecoveryChecker registrationRecoveryChecker = mock(RegistrationRecoveryChecker.class);\n  private final RateLimiters rateLimiters = mock(RateLimiters.class);\n\n  private final RateLimiter registrationLimiter = mock(RateLimiter.class);\n\n  private final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .addProvider(new ImpossiblePhoneNumberExceptionMapper())\n      .addProvider(new NonNormalizedPhoneNumberExceptionMapper())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(\n          new RegistrationController(accountsManager,\n              new PhoneVerificationTokenManager(phoneNumberIdentifiers, registrationServiceClient,\n                  registrationRecoveryPasswordsManager, registrationRecoveryChecker),\n              registrationLockVerificationManager, rateLimiters))\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    when(rateLimiters.getRegistrationLimiter()).thenReturn(registrationLimiter);\n\n    when(accountsManager.update(any(), any())).thenAnswer(invocation -> {\n      final Account account = invocation.getArgument(0);\n      final Consumer<Account> accountUpdater = invocation.getArgument(1);\n\n      accountUpdater.accept(account);\n\n      return invocation.getArgument(0);\n    });\n  }\n\n  @Test\n  void unprocessableRequestJson() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request();\n    try (Response response = request.post(Entity.json(unprocessableJson()))) {\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  static Stream<Arguments> invalidRegistrationId() {\n    return Stream.of(\n        Arguments.of(Optional.of(1), Optional.of(1), 200),\n        Arguments.of(Optional.of(1), Optional.empty(), 422),\n        Arguments.of(Optional.of(0x3FFF), Optional.empty(), 422),\n        Arguments.of(Optional.empty(), Optional.of(1), 422),\n        Arguments.of(Optional.of(Integer.MAX_VALUE), Optional.empty(), 422),\n        Arguments.of(Optional.of(0x3FFF + 1), Optional.empty(), 422),\n        Arguments.of(Optional.of(1), Optional.of(0x3FFF + 1), 422)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void invalidRegistrationId(Optional<Integer> registrationId, Optional<Integer> pniRegistrationId, int statusCode) throws InterruptedException, JsonProcessingException {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final String json = requestJson(\"sessionId\", new byte[0], true, registrationId.orElse(0), pniRegistrationId.orElse(0));\n\n    try (Response response = request.post(Entity.json(json))) {\n      assertEquals(statusCode, response.getStatus());\n    }\n  }\n\n  @Test\n  void missingBasicAuthorization() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request();\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(400, response.getStatus());\n    }\n  }\n\n  @Test\n  void invalidBasicAuthorization() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, \"Basic but-invalid\");\n    try (Response response = request.post(Entity.json(invalidRequestJson()))) {\n      assertEquals(401, response.getStatus());\n    }\n  }\n\n  @Test\n  void invalidRequestBody() {\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(invalidRequestJson()))) {\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  @Test\n  void rateLimitedNumber() throws Exception {\n    doThrow(RateLimitExceededException.class)\n        .when(registrationLimiter).validate(NUMBER);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(429, response.getStatus());\n    }\n  }\n\n  @Test\n  void registrationServiceTimeout() {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());\n    }\n  }\n\n  @Test\n  void recoveryPasswordManagerVerificationFailureOrTimeout() {\n    when(phoneNumberIdentifiers.getPhoneNumberIdentifier(any()))\n        .thenReturn(CompletableFuture.completedFuture(UUID.randomUUID()));\n    when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any())).thenReturn(true);\n    when(registrationRecoveryPasswordsManager.verify(any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(new byte[32])))) {\n      assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void registrationServiceSessionCheck(@Nullable final RegistrationServiceSession session, final int expectedStatus,\n      final String message) {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(session)));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(expectedStatus, response.getStatus(), message);\n    }\n  }\n\n  static Stream<Arguments> registrationServiceSessionCheck() {\n    return Stream.of(\n        Arguments.of(null, 401, \"session not found\"),\n        Arguments.of(\n            new RegistrationServiceSession(new byte[16], \"+18005551234\", false, null, null, null,\n                SESSION_EXPIRATION_SECONDS),\n            400,\n            \"session number mismatch\"),\n        Arguments.of(\n            new RegistrationServiceSession(new byte[16], NUMBER, false, null, null, null, SESSION_EXPIRATION_SECONDS),\n            401,\n            \"session not verified\")\n    );\n  }\n\n  @Test\n  void recoveryPasswordManagerVerificationTrue() throws InterruptedException {\n    when(phoneNumberIdentifiers.getPhoneNumberIdentifier(any()))\n        .thenReturn(CompletableFuture.completedFuture(UUID.randomUUID()));\n    when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any())).thenReturn(true);\n    when(registrationRecoveryPasswordsManager.verify(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(true));\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    final byte[] recoveryPassword = new byte[32];\n    try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(recoveryPassword)))) {\n      assertEquals(200, response.getStatus());\n    }\n  }\n\n  @Test\n  void recoveryPasswordManagerVerificationFalse() {\n    when(registrationRecoveryPasswordsManager.verify(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(false));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(new byte[32])))) {\n      assertEquals(403, response.getStatus());\n    }\n  }\n\n  @Test\n  void registrationRecoveryCheckerAllowsAttempt() throws InterruptedException {\n    when(phoneNumberIdentifiers.getPhoneNumberIdentifier(any()))\n        .thenReturn(CompletableFuture.completedFuture(UUID.randomUUID()));\n    when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any())).thenReturn(true);\n    when(registrationRecoveryPasswordsManager.verify(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(true));\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    final byte[] recoveryPassword = new byte[32];\n    try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(recoveryPassword)))) {\n      assertEquals(200, response.getStatus());\n    }\n  }\n\n  @Test\n  void registrationRecoveryCheckerDisallowsAttempt() throws InterruptedException {\n    when(registrationRecoveryChecker.checkRegistrationRecoveryAttempt(any(), any())).thenReturn(false);\n    when(registrationRecoveryPasswordsManager.verify(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(true));\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    final byte[] recoveryPassword = new byte[32];\n    try (Response response = request.post(Entity.json(requestJsonRecoveryPassword(recoveryPassword)))) {\n      assertEquals(403, response.getStatus());\n    }\n  }\n\n  @CartesianTest\n  @CartesianTest.MethodFactory(\"registrationLockAndDeviceTransfer\")\n  void registrationLockAndDeviceTransfer(\n      final boolean deviceTransferSupported,\n      @Nullable final RegistrationLockError error)\n      throws Exception {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final Account account = mock(Account.class);\n    when(accountsManager.getByE164(any())).thenReturn(Optional.of(account));\n    when(account.hasCapability(DeviceCapability.TRANSFER)).thenReturn(deviceTransferSupported);\n\n    final int expectedStatus;\n    if (deviceTransferSupported) {\n      expectedStatus = 409;\n    } else if (error != null) {\n      final Exception e = switch (error) {\n        case MISMATCH -> new WebApplicationException(error.getExpectedStatus());\n        case RATE_LIMITED -> new RateLimitExceededException(null);\n      };\n      doThrow(e)\n          .when(registrationLockVerificationManager).verifyRegistrationLock(any(), any(), any(), any(), any());\n      expectedStatus = error.getExpectedStatus();\n    } else {\n      final Account createdAccount = mock(Account.class);\n      when(createdAccount.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n      when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n          .thenReturn(createdAccount);\n\n      expectedStatus = 200;\n    }\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(expectedStatus, response.getStatus());\n    }\n  }\n\n  @SuppressWarnings(\"unused\")\n  static ArgumentSets registrationLockAndDeviceTransfer() {\n    final Set<RegistrationLockError> registrationLockErrors = new HashSet<>(EnumSet.allOf(RegistrationLockError.class));\n    registrationLockErrors.add(null);\n\n    return ArgumentSets.argumentsForFirstParameter(true, false)\n        .argumentsForNextParameter(registrationLockErrors);\n  }\n\n\n  @ParameterizedTest\n  @CsvSource({\n      \"false, false, false, 200\",\n      \"true, false, false, 200\",\n      \"true, false, true, 200\",\n      \"true, true, false, 409\",\n      \"true, true, true, 200\"\n  })\n  void deviceTransferAvailable(final boolean existingAccount, final boolean transferSupported,\n      final boolean skipDeviceTransfer, final int expectedStatus) throws Exception {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final Optional<Account> maybeAccount;\n    if (existingAccount) {\n      final Account account = mock(Account.class);\n      when(account.hasCapability(DeviceCapability.TRANSFER)).thenReturn(transferSupported);\n      maybeAccount = Optional.of(account);\n    } else {\n      maybeAccount = Optional.empty();\n    }\n    when(accountsManager.getByE164(any())).thenReturn(maybeAccount);\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\", new byte[0], skipDeviceTransfer, 1, 2)))) {\n      assertEquals(expectedStatus, response.getStatus());\n    }\n  }\n\n  // this is functionally the same as deviceTransferAvailable(existingAccount=false)\n  @Test\n  void registrationSuccess() throws Exception {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(200, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void atomicAccountCreationConflictingChannel(final RegistrationRequest conflictingChannelRequest) {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    try (final Response response = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD))\n        .post(Entity.json(conflictingChannelRequest))) {\n\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  static List<Arguments> atomicAccountCreationConflictingChannel() {\n    final IdentityKey aciIdentityKey;\n    final IdentityKey pniIdentityKey;\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n    {\n      final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n      final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n      aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n      pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n      aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n      pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n      aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n      pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n    }\n\n    final AccountAttributes fetchesMessagesAccountAttributes =\n        new AccountAttributes(true, 1, 1, \"test\".getBytes(StandardCharsets.UTF_8), null, true,\n            DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION);\n\n    final AccountAttributes pushAccountAttributes =\n        new AccountAttributes(false, 1, 1, \"test\".getBytes(StandardCharsets.UTF_8), null, true,\n            DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION);\n\n    return List.of(\n        Arguments.argumentSet(\"\\\"Fetches messages\\\" is true, but an APNs token is provided\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                fetchesMessagesAccountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.of(new ApnRegistrationId(\"apns-token\")),\n                    Optional.empty()))),\n\n        Arguments.argumentSet(\"\\\"Fetches messages\\\" is true, but an FCM (GCM) token is provided\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                fetchesMessagesAccountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.of(new GcmRegistrationId(\"gcm-token\"))))),\n\n        Arguments.argumentSet(\"\\\"Fetches messages\\\" is false, but multiple types of push tokens are provided\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                pushAccountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.of(new ApnRegistrationId(\"apns-token\")),\n                    Optional.of(new GcmRegistrationId(\"gcm-token\")))))\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void atomicAccountCreationPartialSignedPreKeys(final RegistrationRequest partialSignedPreKeyRequest) {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n\n    try (final Response response = request.post(Entity.json(partialSignedPreKeyRequest))) {\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  static List<Arguments> atomicAccountCreationPartialSignedPreKeys() {\n    final IdentityKey aciIdentityKey;\n    final IdentityKey pniIdentityKey;\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n    {\n      final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n      final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n      aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n      pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n      aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n      pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n      aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n      pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n    }\n\n    final AccountAttributes accountAttributes =\n        new AccountAttributes(true, 1, 1, \"test\".getBytes(StandardCharsets.UTF_8), null, true,\n            DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION);\n\n    return List.of(\n        Arguments.argumentSet(\"Signed PNI EC pre-key is missing\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                accountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    null,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.empty()))),\n\n        Arguments.argumentSet(\"Signed ACI EC pre-key is missing\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                accountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(null,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.empty()))),\n\n        Arguments.argumentSet(\"Signed PNI KEM pre-key is missing\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                accountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    null,\n                    Optional.empty(),\n                    Optional.empty()))),\n\n        Arguments.argumentSet(\"Signed ACI KEM pre-key is missing\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                accountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    null,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.empty()))),\n\n        Arguments.argumentSet(\"All signed pre-keys are present, but ACI identity key is missing\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                accountAttributes,\n                true,\n                null,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.empty()))),\n\n        Arguments.argumentSet(\"All signed pre-keys are present, but PNI identity key is missing\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                accountAttributes,\n                true,\n                aciIdentityKey,\n                null,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.empty())))\n    );\n  }\n\n\n  @ParameterizedTest\n  @MethodSource\n  void atomicAccountCreationSuccess(final RegistrationRequest registrationRequest,\n      final IdentityKey expectedAciIdentityKey,\n      final IdentityKey expectedPniIdentityKey,\n      final DeviceSpec expectedDeviceSpec) throws InterruptedException {\n\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final UUID phoneNumberIdentifier = UUID.randomUUID();\n    final Device device = mock(Device.class);\n\n    final Account account = MockUtils.buildMock(Account.class, a -> {\n      when(a.getUuid()).thenReturn(accountIdentifier);\n      when(a.getPhoneNumberIdentifier()).thenReturn(phoneNumberIdentifier);\n      when(a.getPrimaryDevice()).thenReturn(device);\n    });\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n\n    try (Response response = request.post(Entity.json(registrationRequest))) {\n      assertEquals(200, response.getStatus());\n      final AccountIdentityResponse identityResponse = response.readEntity(AccountIdentityResponse.class);\n      assertEquals(accountIdentifier, identityResponse.uuid());\n    }\n\n    verify(accountsManager).create(\n        eq(NUMBER),\n        argThat(attributes -> accountAttributesEqual(attributes, registrationRequest.accountAttributes())),\n        eq(Collections.emptyList()),\n        eq(expectedAciIdentityKey),\n        eq(expectedPniIdentityKey),\n        eq(expectedDeviceSpec),\n        any());\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void reregistrationFlag(final boolean existingAccount) throws InterruptedException {\n    final RegistrationServiceSession registrationSession =\n        new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null, SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationSession)));\n\n    final Optional<Account> maybeAccount = Optional.ofNullable(existingAccount ? mock(Account.class) : null);\n    when(accountsManager.getByE164(any())).thenReturn(maybeAccount);\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    try (Response response = request.post(Entity.json(requestJson(\"sessionId\")))) {\n      assertEquals(200, response.getStatus());\n      final AccountCreationResponse creationResponse = response.readEntity(AccountCreationResponse.class);\n      assertEquals(existingAccount, creationResponse.reregistration());\n    }\n  }\n\n  @Test\n  void registrationMissingSpqrCapability() throws Exception {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                Optional.of(new RegistrationServiceSession(new byte[16], NUMBER, true, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    final Account account = mock(Account.class);\n    when(account.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n    when(accountsManager.create(any(), any(), any(), any(), any(), any(), any()))\n        .thenReturn(account);\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/registration\")\n        .request()\n        .header(HttpHeaders.AUTHORIZATION, AuthHelper.getProvisioningAuthHeader(NUMBER, PASSWORD));\n    final RegistrationRequest requestObj = request(\"sessionId\", new byte[0], false, 1, 2, Collections.emptySet());\n    try (final Response response = request.post(Entity.json(requestToJson(requestObj)))) {\n      assertEquals(499, response.getStatus());\n    }\n  }\n\n  private static boolean accountAttributesEqual(final AccountAttributes a, final AccountAttributes b) {\n    return a.getFetchesMessages() == b.getFetchesMessages()\n        && a.getRegistrationId() == b.getRegistrationId()\n        && a.isUnrestrictedUnidentifiedAccess() == b.isUnrestrictedUnidentifiedAccess()\n        && a.isDiscoverableByPhoneNumber() == b.isDiscoverableByPhoneNumber()\n        && Objects.equals(a.getPhoneNumberIdentityRegistrationId(), b.getPhoneNumberIdentityRegistrationId())\n        && Arrays.equals(a.getName(), b.getName())\n        && Objects.equals(a.getRegistrationLock(), b.getRegistrationLock())\n        && Arrays.equals(a.getUnidentifiedAccessKey(), b.getUnidentifiedAccessKey())\n        && Objects.equals(a.getCapabilities(), b.getCapabilities())\n        && Objects.equals(a.recoveryPassword(), b.recoveryPassword());\n  }\n\n  private static List<Arguments> atomicAccountCreationSuccess() {\n    final IdentityKey aciIdentityKey;\n    final IdentityKey pniIdentityKey;\n    final ECSignedPreKey aciSignedPreKey;\n    final ECSignedPreKey pniSignedPreKey;\n    final KEMSignedPreKey aciPqLastResortPreKey;\n    final KEMSignedPreKey pniPqLastResortPreKey;\n    {\n      final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n      final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n      aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n      pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n      aciSignedPreKey = KeysHelper.signedECPreKey(1, aciIdentityKeyPair);\n      pniSignedPreKey = KeysHelper.signedECPreKey(2, pniIdentityKeyPair);\n      aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair);\n      pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair);\n    }\n\n    final byte[] deviceName = \"test\".getBytes(StandardCharsets.UTF_8);\n    final int registrationId = 1;\n    final int pniRegistrationId = 2;\n\n    final Set<DeviceCapability> deviceCapabilities = DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION;\n\n    final AccountAttributes fetchesMessagesAccountAttributes =\n        new AccountAttributes(true, registrationId, pniRegistrationId, \"test\".getBytes(StandardCharsets.UTF_8), null, true, deviceCapabilities);\n\n    final AccountAttributes pushAccountAttributes =\n        new AccountAttributes(false, registrationId, pniRegistrationId, \"test\".getBytes(StandardCharsets.UTF_8), null, true, deviceCapabilities);\n\n    final String apnsToken = \"apns-token\";\n    final String gcmToken = \"gcm-token\";\n\n    return List.of(\n        Arguments.argumentSet(\"Fetches messages; no push tokens\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                fetchesMessagesAccountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.empty())),\n            aciIdentityKey,\n            pniIdentityKey,\n            new DeviceSpec(\n                deviceName,\n                PASSWORD,\n                null,\n                deviceCapabilities,\n                registrationId,\n                pniRegistrationId,\n                true,\n                Optional.empty(),\n                Optional.empty(),\n                aciSignedPreKey,\n                pniSignedPreKey,\n                aciPqLastResortPreKey,\n                pniPqLastResortPreKey)),\n\n        Arguments.argumentSet(\"Has APNs tokens\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                pushAccountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.of(new ApnRegistrationId(apnsToken)),\n                    Optional.empty())),\n            aciIdentityKey,\n            pniIdentityKey,\n            new DeviceSpec(\n                deviceName,\n                PASSWORD,\n                null,\n                deviceCapabilities,\n                registrationId,\n                pniRegistrationId,\n                false,\n                Optional.of(new ApnRegistrationId(apnsToken)),\n                Optional.empty(),\n                aciSignedPreKey,\n                pniSignedPreKey,\n                aciPqLastResortPreKey,\n                pniPqLastResortPreKey)),\n\n        Arguments.argumentSet(\"Has GCM token\",\n            new RegistrationRequest(\"session-id\",\n                new byte[0],\n                pushAccountAttributes,\n                true,\n                aciIdentityKey,\n                pniIdentityKey,\n                new DeviceActivationRequest(aciSignedPreKey,\n                    pniSignedPreKey,\n                    aciPqLastResortPreKey,\n                    pniPqLastResortPreKey,\n                    Optional.empty(),\n                    Optional.of(new GcmRegistrationId(gcmToken)))),\n            aciIdentityKey,\n            pniIdentityKey,\n            new DeviceSpec(\n                deviceName,\n                PASSWORD,\n                null,\n                deviceCapabilities,\n                registrationId,\n                pniRegistrationId,\n                false,\n                Optional.empty(),\n                Optional.of(new GcmRegistrationId(gcmToken)),\n                aciSignedPreKey,\n                pniSignedPreKey,\n                aciPqLastResortPreKey,\n                pniPqLastResortPreKey))\n    );\n  }\n\n  private static RegistrationRequest request(\n      final String sessionId,\n      final byte[] recoveryPassword,\n      final boolean skipDeviceTransfer,\n      final int registrationId,\n      int pniRegistrationId,\n      Set<DeviceCapability> deviceCapabilities) {\n    final ECKeyPair aciIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final IdentityKey aciIdentityKey = new IdentityKey(aciIdentityKeyPair.getPublicKey());\n    final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n\n    final AccountAttributes accountAttributes = new AccountAttributes(true, registrationId, pniRegistrationId,\n        \"name\".getBytes(StandardCharsets.UTF_8), REGLOCK,\n        true, deviceCapabilities);\n\n    return new RegistrationRequest(\n        Base64.getEncoder().encodeToString(sessionId.getBytes(StandardCharsets.UTF_8)),\n        recoveryPassword,\n        accountAttributes,\n        skipDeviceTransfer,\n        aciIdentityKey,\n        pniIdentityKey,\n        new DeviceActivationRequest(\n            KeysHelper.signedECPreKey(1, aciIdentityKeyPair),\n            KeysHelper.signedECPreKey(2, pniIdentityKeyPair),\n            KeysHelper.signedKEMPreKey(3, aciIdentityKeyPair),\n            KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair),\n            Optional.empty(),\n            Optional.empty()));\n  }\n\n  private static String requestToJson(RegistrationRequest request) {\n    try {\n      return SystemMapper.jsonMapper().writerWithDefaultPrettyPrinter().writeValueAsString(request);\n    } catch (final JsonProcessingException e) {\n      throw new UncheckedIOException(e);\n    }\n  }\n\n  /**\n   * Valid request JSON with the give session ID and skipDeviceTransfer\n   */\n  private static String requestJson(final String sessionId,\n      final byte[] recoveryPassword,\n      final boolean skipDeviceTransfer,\n      final int registrationId,\n      final int pniRegistrationId) {\n      return requestToJson(request(sessionId, recoveryPassword, skipDeviceTransfer, registrationId, pniRegistrationId, DeviceCapability.CAPABILITIES_REQUIRED_FOR_REGISTRATION));\n  }\n\n  /**\n   * Valid request JSON with the given session ID\n   */\n  private static String requestJson(final String sessionId) {\n    return requestJson(sessionId, new byte[0], false, 1, 2);\n  }\n\n  /**\n   * Valid request JSON with the given Recovery Password\n   */\n  private static String requestJsonRecoveryPassword(final byte[] recoveryPassword) {\n    return requestJson(\"\", recoveryPassword, false, 1, 2);\n  }\n\n  /**\n   * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.RegistrationRequest}, but that fails\n   * validation\n   */\n  private static String invalidRequestJson() {\n    return \"\"\"\n        {\n          \"sessionId\": null,\n          \"accountAttributes\": {},\n          \"skipDeviceTransfer\": false\n        }\n        \"\"\";\n  }\n\n  /**\n   * Request JSON that cannot be marshalled into {@link org.whispersystems.textsecuregcm.entities.RegistrationRequest}\n   */\n  private static String unprocessableJson() {\n    return \"\"\"\n        {\n          \"sessionId\": []\n        }\n        \"\"\";\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/RemoteConfigControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.entry;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.core.EntityTag;\nimport jakarta.ws.rs.core.Response;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Random;\nimport java.util.Set;\nimport org.assertj.core.data.Offset;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.params.IntRangeSource;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.RemoteConfigurationResponse;\nimport org.whispersystems.textsecuregcm.mappers.DeviceLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.RemoteConfig;\nimport org.whispersystems.textsecuregcm.storage.RemoteConfigsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass RemoteConfigControllerTest {\n\n  private static final RemoteConfigsManager remoteConfigsManager = mock(RemoteConfigsManager.class);\n\n  private static final long PINNED_EPOCH_SECONDS = 1701287216L;\n  private static final TestClock TEST_CLOCK = TestClock.pinned(Instant.ofEpochSecond(PINNED_EPOCH_SECONDS));\n\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addProvider(new DeviceLimitExceededExceptionMapper())\n      .addResource(new RemoteConfigController(remoteConfigsManager, Map.of(\"maxGroupSize\", \"42\"), TEST_CLOCK))\n      .build();\n\n\n  @BeforeEach\n  void setup() throws Exception {\n    when(remoteConfigsManager.getAll()).thenReturn(\n      List.of(\n          new RemoteConfig(\"android.stickers\", 100, Set.of(), null, null, null),\n          new RemoteConfig(\"ios.stickers\", 100, Set.of(), null, null, null),\n          new RemoteConfig(\"desktop.stickers\", 100, Set.of(), null, null, null),\n          new RemoteConfig(\"always.true\", 100, Set.of(), null, null, null),\n          new RemoteConfig(\"only.special\", 0, Set.of(AuthHelper.VALID_UUID), null, null, null),\n          new RemoteConfig(\"value.always.true\", 100, Set.of(), \"foo\", \"bar\", null),\n          new RemoteConfig(\"value.only.special\", 0, Set.of(AuthHelper.VALID_UUID), \"abc\", \"xyz\", null),\n          new RemoteConfig(\"value.always.false\", 0, Set.of(), \"red\", \"green\", null),\n          new RemoteConfig(\"linked.config.0\", 50, Set.of(), null, null, null),\n          new RemoteConfig(\"linked.config.1\", 50, Set.of(), null, null, \"linked.config.0\"),\n          new RemoteConfig(\"unlinked.config\", 50, Set.of(), null, null, null)));\n  }\n\n  @AfterEach\n  void teardown() {\n    reset(remoteConfigsManager);\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  void testRetrieveConfig(ClientPlatform platform) {\n    RemoteConfigurationResponse configuration = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(\"User-Agent\", String.format(\"Signal-%s/7.6.2\", platform.name()))\n        .get(RemoteConfigurationResponse.class);\n\n    verify(remoteConfigsManager, times(1)).getAll();\n\n    assertThat(configuration.config()).hasSize(10);\n    assertThat(configuration.config()).containsKeys(platform.name().toLowerCase() + \".stickers\", \"linked.config.0\", \"linked.config.1\", \"unlinked.config\");\n    assertThat(configuration.config()).contains(\n        entry(\"always.true\", \"true\"),\n        entry(\"only.special\", \"true\"),\n        entry(\"value.always.true\", \"bar\"),\n        entry(\"value.only.special\", \"xyz\"),\n        entry(\"value.always.false\", \"red\"),\n        entry(\"global.maxGroupSize\", \"42\"));\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  void testRetrieveConfigNotSpecial(ClientPlatform platform) {\n    RemoteConfigurationResponse configuration = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .header(\"User-Agent\", String.format(\"Signal-%s/7.6.2\", platform.name()))\n        .get(RemoteConfigurationResponse.class);\n\n    verify(remoteConfigsManager, times(1)).getAll();\n\n    assertThat(configuration.config()).hasSize(10);\n    assertThat(configuration.config()).containsKeys(platform.name().toLowerCase() + \".stickers\", \"linked.config.0\", \"linked.config.1\", \"unlinked.config\");\n    assertThat(configuration.config()).contains(\n        entry(\"always.true\", \"true\"),\n        entry(\"only.special\", \"false\"),\n        entry(\"value.always.true\", \"bar\"),\n        entry(\"value.only.special\", \"abc\"),\n        entry(\"value.always.false\", \"red\"),\n        entry(\"global.maxGroupSize\", \"42\"));\n  }\n\n  @Test\n  void testRetrieveConfigUnrecognizedPlatform() {\n    RemoteConfigurationResponse configuration = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO))\n        .header(\"User-Agent\", \"Third-Party-Signal-Client/1.0.0\")\n        .get(RemoteConfigurationResponse.class);\n\n    verify(remoteConfigsManager, times(1)).getAll();\n\n    assertThat(configuration.config()).hasSize(9);\n    assertThat(configuration.config()).containsKeys(\"linked.config.0\", \"linked.config.1\", \"unlinked.config\");\n    assertThat(configuration.config()).contains(\n        entry(\"always.true\", \"true\"),\n        entry(\"only.special\", \"false\"),\n        entry(\"value.always.true\", \"bar\"),\n        entry(\"value.only.special\", \"abc\"),\n        entry(\"value.always.false\", \"red\"),\n        entry(\"global.maxGroupSize\", \"42\"));\n  }\n\n  @Test\n  void testHashKeyLinkedConfigs() {\n    boolean allUnlinkedConfigsMatched = true;\n    for (AuthHelper.TestAccount testAccount : AuthHelper.TEST_ACCOUNTS) {\n      RemoteConfigurationResponse configuration = resources.getJerseyTest()\n          .target(\"/v2/config/\")\n          .request()\n          .header(\"Authorization\", testAccount.getAuthHeader())\n          .get(RemoteConfigurationResponse.class);\n\n      assertThat(configuration.config().get(\"linked.config.0\")).isEqualTo(configuration.config().get(\"linked.config.1\"));\n      allUnlinkedConfigsMatched &= (configuration.config().get(\"linked.config.0\") == configuration.config().get(\"unlinked.config\"));\n    }\n\n    // with 20 test accounts, 1 in 2^20 chance that this fails when it shouldn't, but\n    // AuthHelper#generateTestAccounts uses a constant random seed that doesn't fail as of the time\n    // of this writing; if this starts failing for no apparent reason, it's likely that we've\n    // changed the order of the sequence of random numbers used during test initialization in such\n    // a way that we've accidentally picked an unlucky set of accounts here\n    assertThat(allUnlinkedConfigsMatched).isFalse();\n  }\n\n  @Test\n  void testRetrieveConfigUnauthorized() {\n    Response response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.INVALID_PASSWORD))\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n\n    verifyNoMoreInteractions(remoteConfigsManager);\n  }\n\n  @Test\n  void testRetrieveConfigUnchanged() {\n    Response response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(\"User-Agent\", \"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\")\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getLength()).isPositive();\n    final EntityTag etag = response.getEntityTag();\n    assertThat(etag).isNotNull();\n\n    response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(\"User-Agent\", \"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\")\n        .header(\"If-None-Match\", etag)\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(304);\n    assertThat(response.getLength()).isNotPositive();\n  }\n\n  @Test\n  void testRetrieveConfigChanged() {\n    Response response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(\"User-Agent\", \"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\")\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getLength()).isPositive();\n    final EntityTag etag = response.getEntityTag();\n    assertThat(etag).isNotNull();\n\n    final List<RemoteConfig> configs = new ArrayList<>(remoteConfigsManager.getAll());\n    configs.add(new RemoteConfig(\"android.new.config\", 100, Set.of(), null, null, null));\n    when(remoteConfigsManager.getAll()).thenReturn(configs);\n\n    response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n        .header(\"User-Agent\", \"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\")\n        .header(\"If-None-Match\", etag)\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getLength()).isPositive();\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testEtag(boolean expect304, String userAgent1, String authHeader1, String userAgent2, String authHeader2) {\n    // Use a deterministic config; account 1 is special, 2 and 3 are identical\n    List<RemoteConfig> configs = remoteConfigsManager.getAll().stream().filter(config -> config.getPercentage() == 0 || config.getPercentage() == 100).toList();\n    when(remoteConfigsManager.getAll()).thenReturn(configs);\n\n    Response response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", authHeader1)\n        .header(\"User-Agent\", userAgent1)\n        .get();\n\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getLength()).isPositive();\n    final EntityTag etag = response.getEntityTag();\n    assertThat(etag).isNotNull();\n\n    response = resources.getJerseyTest()\n        .target(\"/v2/config/\")\n        .request()\n        .header(\"Authorization\", authHeader2)\n        .header(\"User-Agent\", userAgent2)\n        .header(\"If-None-Match\", etag)\n        .get();\n\n    if (expect304) {\n      assertThat(response.getStatus()).isEqualTo(304);\n      assertThat(response.getLength()).isNotPositive();\n    } else {\n      assertThat(response.getStatus()).isEqualTo(200);\n      assertThat(response.getLength()).isPositive();\n    }\n  }\n\n  static List<Arguments> testEtag() {\n    final String uuid1AuthHeader = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD);\n    final String uuid2AuthHeader = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_TWO, AuthHelper.VALID_PASSWORD_TWO);\n    final String uuid3AuthHeader = AuthHelper.getAuthHeader(AuthHelper.VALID_UUID_3, AuthHelper.VALID_PASSWORD_3_PRIMARY);\n\n    final String ios762 = \"Signal-iOS/7.6.2 iOS/18.5 libsignal/0.46.0\";\n    final String android762 = \"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\";\n    final String android763 = \"Signal-Android/7.6.3 Android/34 libsignal/0.46.0\";\n\n    // boolean is expect304\n    return List.of(\n        Arguments.argumentSet(\"User change\", false, android762, uuid1AuthHeader, android762, uuid2AuthHeader),\n        Arguments.argumentSet(\"Irrelevant user change\", true, android762, uuid2AuthHeader, android762, uuid3AuthHeader),\n        Arguments.argumentSet(\"User agent change\", false, android762, uuid1AuthHeader, ios762, uuid1AuthHeader),\n        Arguments.argumentSet(\"Irrelevant user agent change\", true, android762, uuid1AuthHeader, android763, uuid1AuthHeader)\n    );\n  }\n\n  @ParameterizedTest\n  @IntRangeSource(from = 1, to = 99)\n  void testMath(int percentage) throws NoSuchAlgorithmException {\n    final MessageDigest digest = MessageDigest.getInstance(\"SHA-256\");\n    final Random random = new Random(9424242L);  // the seed value doesn't matter so much as it's constant to make the test not flaky\n    final int iterations = 10000;\n    int enabledCount = 0;\n\n    for (int i = 0; i < iterations; i++) {\n      if (RemoteConfigController.isInBucket(digest, AuthHelper.getRandomUUID(random), \"test\".getBytes(), percentage, Set.of())) {\n          enabledCount++;\n      }\n    }\n\n\n    // https://en.wikipedia.org/wiki/Binomial_distribution#Expected_value_and_variance\n    final double expectedCount = iterations * percentage / 100.0;\n    final double stdev = Math.sqrt(expectedCount * (1 - percentage / 100.0));\n\n    // 3 standard deviations = 99.73% chance of success for one bucket, 23.5%\n    // chance of any failure in 99 buckets; if this starts failing after a\n    // change, run it again with a few different random seeds to make sure it\n    // fails only about on about one seed in four\n    assertThat((double) enabledCount).isCloseTo(expectedCount, Offset.offset(3 * stdev));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 100})\n  void testMathExactForZeroOrOneHundred(int percentage) throws NoSuchAlgorithmException {\n    final MessageDigest digest = MessageDigest.getInstance(\"SHA-256\");\n    final Random random = new Random();\n    final int iterations = 10000;\n    int enabledCount = 0;\n\n    for (int i = 0; i < iterations; i++) {\n      if (RemoteConfigController.isInBucket(digest, AuthHelper.getRandomUUID(random), \"test\".getBytes(), percentage, Set.of())) {\n          enabledCount++;\n      }\n    }\n\n    assertThat(enabledCount).isEqualTo(iterations * percentage / 100);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureStorageControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.core.Response;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass SecureStorageControllerTest {\n\n  private static final SecureStorageServiceConfiguration STORAGE_CFG = MockUtils.buildMock(\n      SecureStorageServiceConfiguration.class,\n      cfg -> when(cfg.userAuthenticationTokenSharedSecret()).thenReturn(randomSecretBytes(32)));\n\n  private static final ExternalServiceCredentialsGenerator STORAGE_CREDENTIAL_GENERATOR = SecureStorageController\n      .credentialsGenerator(STORAGE_CFG);\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new SecureStorageController(STORAGE_CREDENTIAL_GENERATOR))\n      .build();\n\n\n  @Test\n  void testGetCredentials() throws Exception {\n    ExternalServiceCredentials credentials = resources.getJerseyTest()\n                                                      .target(\"/v1/storage/auth\")\n                                                      .request()\n                                                      .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                                                      .get(ExternalServiceCredentials.class);\n\n    assertThat(credentials.password()).isNotEmpty();\n    assertThat(credentials.username()).isNotEmpty();\n  }\n\n  @Test\n  void testGetCredentialsBadAuth() throws Exception {\n    Response response = resources.getJerseyTest()\n                                 .target(\"/v1/storage/auth\")\n                                 .request()\n                                 .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.INVALID_UUID, AuthHelper.INVALID_PASSWORD))\n                                 .get();\n\n    assertThat(response.getStatus()).isEqualTo(401);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/SecureValueRecovery2ControllerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;\n\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.Mockito;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport org.whispersystems.textsecuregcm.entities.AuthCheckRequest;\nimport org.whispersystems.textsecuregcm.entities.AuthCheckResponseV2;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class SecureValueRecovery2ControllerTest {\n\n  private static final SecureValueRecoveryConfiguration CFG = new SecureValueRecoveryConfiguration(\n      \"\",\n      randomSecretBytes(32),\n      randomSecretBytes(32),\n      null,\n      null,\n      null\n  );\n\n  private static final MutableClock CLOCK = new MutableClock();\n\n  private static final ExternalServiceCredentialsGenerator CREDENTIAL_GENERATOR =\n      SecureValueRecovery2Controller.credentialsGenerator(CFG, CLOCK);\n\n  private static final AccountsManager ACCOUNTS_MANAGER = mock(AccountsManager.class);\n  private static final SecureValueRecovery2Controller CONTROLLER =\n      new SecureValueRecovery2Controller(CREDENTIAL_GENERATOR, ACCOUNTS_MANAGER);\n\n  private static final ResourceExtension RESOURCES = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(CONTROLLER)\n      .build();\n\n  @Nested\n  class WithBackupsPrefix extends SecureValueRecoveryControllerBaseTest {\n    protected WithBackupsPrefix() {\n      super(\"/v2/backup\");\n    }\n  }\n\n  @Nested\n  class WithSvr2Prefix extends SecureValueRecoveryControllerBaseTest {\n    protected WithSvr2Prefix() {\n      super(\"/v2/svr\");\n    }\n  }\n\n  static abstract class SecureValueRecoveryControllerBaseTest {\n    private static final UUID USER_1 = UUID.randomUUID();\n    private static final UUID USER_2 = UUID.randomUUID();\n    private static final UUID USER_3 = UUID.randomUUID();\n    private static final String E164_VALID = \"+18005550123\";\n    private static final String E164_INVALID = \"1(800)555-0123\";\n\n    private final String pathPrefix;\n\n    @BeforeEach\n    public void before() throws Exception {\n      Mockito.reset(ACCOUNTS_MANAGER);\n      Mockito.when(ACCOUNTS_MANAGER.getByE164(E164_VALID)).thenReturn(Optional.of(account(USER_1)));\n    }\n\n    protected SecureValueRecoveryControllerBaseTest(final String pathPrefix) {\n      this.pathPrefix = pathPrefix;\n    }\n\n    enum CheckStatus {\n      MATCH,\n      NO_MATCH,\n      INVALID\n    }\n\n    private Map<String, CheckStatus> parseCheckResponse(final Response response) {\n      final AuthCheckResponseV2 authCheckResponseV2 = response.readEntity(AuthCheckResponseV2.class);\n      return authCheckResponseV2.matches().entrySet().stream().collect(Collectors.toMap(\n          Map.Entry::getKey, e -> switch (e.getValue()) {\n            case MATCH -> CheckStatus.MATCH;\n            case INVALID -> CheckStatus.INVALID;\n            case NO_MATCH -> CheckStatus.NO_MATCH;\n          }\n      ));\n    }\n\n    @Test\n    public void testOneMatch() {\n      validate(Map.of(\n          token(USER_1, dayToMillis(1)), CheckStatus.MATCH,\n          token(USER_2, dayToMillis(1)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(1)), CheckStatus.NO_MATCH\n      ), dayToMillis(2));\n    }\n\n    @Test\n    public void testNoMatch() {\n      validate(Map.of(\n          token(USER_2, dayToMillis(1)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(1)), CheckStatus.NO_MATCH\n      ), dayToMillis(2));\n    }\n\n    @Test\n    public void testSomeInvalid() {\n      final ExternalServiceCredentials user1Cred = credentials(USER_1, dayToMillis(1));\n      final ExternalServiceCredentials user2Cred = credentials(USER_2, dayToMillis(1));\n      final ExternalServiceCredentials user3Cred = credentials(USER_3, dayToMillis(1));\n\n      final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));\n      validate(Map.of(\n          token(user1Cred), CheckStatus.MATCH,\n          token(user2Cred), CheckStatus.NO_MATCH,\n          fakeToken, CheckStatus.INVALID\n      ), dayToMillis(2));\n    }\n\n    @Test\n    public void testSomeExpired() {\n      final long nowDay = 200;\n      final long maxAgeDays = SecureValueRecovery2Controller.MAX_AGE.toDays();\n      validate(Map.of(\n          token(USER_1, dayToMillis(nowDay - maxAgeDays)), CheckStatus.MATCH,\n          token(USER_2, dayToMillis(nowDay - maxAgeDays)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(nowDay - maxAgeDays - 2)), CheckStatus.INVALID,\n          token(USER_3, dayToMillis(nowDay - maxAgeDays - 1)), CheckStatus.INVALID\n      ), dayToMillis(nowDay));\n    }\n\n    @Test\n    public void testSomeHaveNewerVersions() {\n      validate(Map.of(\n          token(USER_1, dayToMillis(10)), CheckStatus.INVALID,\n          token(USER_1, dayToMillis(20)), CheckStatus.MATCH,\n          token(USER_2, dayToMillis(10)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(20)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(10)), CheckStatus.INVALID\n      ), dayToMillis(25));\n    }\n\n    private void validate(\n        final Map<String, CheckStatus> expected,\n        final long nowMillis) {\n      CLOCK.setTimeMillis(nowMillis);\n      final AuthCheckRequest request = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));\n      final Response response = RESOURCES.getJerseyTest().target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(request, MediaType.APPLICATION_JSON));\n      try (response) {\n        assertEquals(200, response.getStatus());\n        final Map<String, CheckStatus> res = parseCheckResponse(response);\n        assertEquals(expected, res);\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeSuccess() {\n      final Map<String, CheckStatus> expected = Map.of(\n          token(USER_1, dayToMillis(10)), CheckStatus.INVALID,\n          token(USER_1, dayToMillis(20)), CheckStatus.MATCH,\n          token(USER_2, dayToMillis(10)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(20)), CheckStatus.NO_MATCH,\n          token(USER_3, dayToMillis(10)), CheckStatus.INVALID\n      );\n\n      CLOCK.setTimeMillis(dayToMillis(25));\n\n      final AuthCheckRequest in = new AuthCheckRequest(E164_VALID, List.copyOf(expected.keySet()));\n\n      final Response response = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(in, MediaType.APPLICATION_JSON));\n\n      try (response) {\n        assertEquals(200, response.getStatus());\n        assertEquals(expected, parseCheckResponse(response));\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeWhenInvalidNumber() {\n      final AuthCheckRequest in = new AuthCheckRequest(E164_INVALID, Collections.singletonList(\"1\"));\n      final Response response = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(in, MediaType.APPLICATION_JSON));\n\n      try (response) {\n        assertEquals(422, response.getStatus());\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeWhenTooManyTokens() {\n      final AuthCheckRequest inOkay = new AuthCheckRequest(E164_VALID, List.of(\n          \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\"\n      ));\n      final AuthCheckRequest inTooMany = new AuthCheckRequest(E164_VALID, List.of(\n          \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \"10\", \"11\"\n      ));\n      final AuthCheckRequest inNoTokens = new AuthCheckRequest(E164_VALID, Collections.emptyList());\n\n      final Response responseOkay = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(inOkay, MediaType.APPLICATION_JSON));\n\n      final Response responseError1 = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(inTooMany, MediaType.APPLICATION_JSON));\n\n      final Response responseError2 = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(inNoTokens, MediaType.APPLICATION_JSON));\n\n      try (responseOkay; responseError1; responseError2) {\n        assertEquals(200, responseOkay.getStatus());\n        assertEquals(422, responseError1.getStatus());\n        assertEquals(422, responseError2.getStatus());\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeWhenPasswordsMissing() {\n      final Response response = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(\"\"\"\n            {\n              \"number\": \"123\"\n            }\n            \"\"\", MediaType.APPLICATION_JSON));\n\n      try (response) {\n        assertEquals(422, response.getStatus());\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeWhenNumberMissing() {\n      final Response response = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(\"\"\"\n            {\n              \"passwords\": [\"aaa:bbb\"]\n            }\n            \"\"\", MediaType.APPLICATION_JSON));\n\n      try (response) {\n        assertEquals(422, response.getStatus());\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeWhenExtraFields() {\n      final Response response = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(\"\"\"\n            {\n              \"number\": \"+18005550123\",\n              \"passwords\": [\"aaa:bbb\"],\n              \"unexpected\": \"value\"\n            }\n            \"\"\", MediaType.APPLICATION_JSON));\n\n      try (response) {\n        assertEquals(200, response.getStatus());\n      }\n    }\n\n    @Test\n    public void testAcceptsPasswordsOrTokens() {\n      final Response passwordsResponse = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(\"\"\"\n            {\n              \"number\": \"+18005550123\",\n              \"passwords\": [\"aaa:bbb\"]\n            }\n            \"\"\", MediaType.APPLICATION_JSON));\n      try (passwordsResponse) {\n        assertEquals(200, passwordsResponse.getStatus());\n      }\n\n      final Response tokensResponse = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(\"\"\"\n            {\n              \"number\": \"+18005550123\",\n              \"tokens\": [\"aaa:bbb\"]\n            }\n            \"\"\", MediaType.APPLICATION_JSON));\n      try (tokensResponse) {\n        assertEquals(200, tokensResponse.getStatus());\n      }\n    }\n\n    @Test\n    public void testHttpResponseCodeWhenNotAJson() {\n      final Response response = RESOURCES.getJerseyTest()\n          .target(pathPrefix + \"/auth/check\")\n          .request()\n          .post(Entity.entity(\"random text\", MediaType.APPLICATION_JSON));\n\n      try (response) {\n        assertEquals(400, response.getStatus());\n      }\n    }\n\n    private String token(final UUID uuid, final long timeMillis) {\n      return token(credentials(uuid, timeMillis));\n    }\n\n    private static String token(final ExternalServiceCredentials credentials) {\n      return credentials.username() + \":\" + credentials.password();\n    }\n\n    private ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {\n      CLOCK.setTimeMillis(timeMillis);\n      return CREDENTIAL_GENERATOR.generateForUuid(uuid);\n    }\n\n    private static long dayToMillis(final long n) {\n      return TimeUnit.DAYS.toMillis(n);\n    }\n\n    private static Account account(final UUID uuid) {\n      final Account a = new Account();\n      a.setUuid(uuid);\n      return a;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/StickerControllerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.core.Response;\nimport java.util.Base64;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.entities.StickerPackFormUploadAttributes;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass StickerControllerTest {\n\n  private static final RateLimiter  rateLimiter  = mock(RateLimiter.class );\n  private static final RateLimiters rateLimiters = mock(RateLimiters.class);\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new StickerController(rateLimiters, \"foo\", \"bar\", \"us-east-1\", \"mybucket\"))\n      .build();\n\n  @BeforeEach\n  void setup() {\n    when(rateLimiters.getStickerPackLimiter()).thenReturn(rateLimiter);\n  }\n\n  @Test\n  void testCreatePack() throws RateLimitExceededException {\n    StickerPackFormUploadAttributes attributes  = resources.getJerseyTest()\n                                                           .target(\"/v1/sticker/pack/form/10\")\n                                                           .request()\n                                                           .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                                                           .get(StickerPackFormUploadAttributes.class);\n\n    assertThat(attributes.getPackId()).isNotNull();\n    assertThat(attributes.getPackId().length()).isEqualTo(32);\n\n    assertThat(attributes.getManifest()).isNotNull();\n    assertThat(attributes.getManifest().getKey()).isEqualTo(\"stickers/\" + attributes.getPackId() + \"/manifest.proto\");\n    assertThat(attributes.getManifest().getAcl()).isEqualTo(\"private\");\n    assertThat(attributes.getManifest().getPolicy()).isNotEmpty();\n    assertThat(new String(Base64.getDecoder().decode(attributes.getManifest().getPolicy()))).contains(\"[\\\"content-length-range\\\", 1, 10240]\");\n    assertThat(attributes.getManifest().getSignature()).isNotEmpty();\n    assertThat(attributes.getManifest().getAlgorithm()).isEqualTo(\"AWS4-HMAC-SHA256\");\n    assertThat(attributes.getManifest().getCredential()).isNotEmpty();\n    assertThat(attributes.getManifest().getId()).isEqualTo(-1);\n\n    assertThat(attributes.getStickers().size()).isEqualTo(10);\n\n    for (int i=0;i<10;i++) {\n      assertThat(attributes.getStickers().get(i).getId()).isEqualTo(i);\n      assertThat(attributes.getStickers().get(i).getKey()).isEqualTo(\"stickers/\" + attributes.getPackId() + \"/full/\" + i);\n      assertThat(attributes.getStickers().get(i).getAcl()).isEqualTo(\"private\");\n      assertThat(attributes.getStickers().get(i).getPolicy()).isNotEmpty();\n      assertThat(new String(Base64.getDecoder().decode(attributes.getStickers().get(i).getPolicy()))).contains(\"[\\\"content-length-range\\\", 1, 308224]\");\n      assertThat(attributes.getStickers().get(i).getSignature()).isNotEmpty();\n      assertThat(attributes.getStickers().get(i).getAlgorithm()).isEqualTo(\"AWS4-HMAC-SHA256\");\n      assertThat(attributes.getStickers().get(i).getCredential()).isNotEmpty();\n    }\n\n    verify(rateLimiters, times(1)).getStickerPackLimiter();\n    verify(rateLimiter, times(1)).validate(AuthHelper.VALID_UUID);\n  }\n\n  @Test\n  void testCreateTooLargePack() {\n    Response response = resources.getJerseyTest()\n                        .target(\"/v1/sticker/pack/form/202\")\n                        .request()\n                        .header(\"Authorization\", AuthHelper.getAuthHeader(AuthHelper.VALID_UUID, AuthHelper.VALID_PASSWORD))\n                        .get();\n\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/SubscriptionControllerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.b;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.n;\nimport static org.whispersystems.textsecuregcm.util.AttributeValues.s;\n\nimport io.dropwizard.auth.AuthValueFactoryProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.core.Response;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Predicate;\nimport java.util.stream.Stream;\nimport org.assertj.core.api.InstanceOfAssertFactories;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.badges.BadgeTranslator;\nimport org.whispersystems.textsecuregcm.configuration.SubscriptionConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicBackupConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetBankMandateResponse;\nimport org.whispersystems.textsecuregcm.controllers.SubscriptionController.GetSubscriptionConfigurationResponse;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.SubscriptionExceptionMapper;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.PaymentTime;\nimport org.whispersystems.textsecuregcm.storage.SubscriptionManager;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions;\nimport org.whispersystems.textsecuregcm.subscriptions.AppleAppStoreManager;\nimport org.whispersystems.textsecuregcm.subscriptions.BankMandateTranslator;\nimport org.whispersystems.textsecuregcm.subscriptions.ChargeFailure;\nimport org.whispersystems.textsecuregcm.subscriptions.CustomerAwareSubscriptionPaymentProcessor;\nimport org.whispersystems.textsecuregcm.subscriptions.GooglePlayBillingManager;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentMethod;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionChargeFailurePaymentRequiredException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionNotFoundException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiredException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionPaymentRequiresActionException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorConflictException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionProcessorException;\nimport org.whispersystems.textsecuregcm.subscriptions.SubscriptionReceiptRequestedForOpenPaymentException;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass SubscriptionControllerTest extends AbstractV1SubscriptionControllerTest {\n\n  private static final long MAX_TOTAL_BACKUP_MEDIA_BYTES = 1234L;\n  private static final SubscriptionConfiguration SUBSCRIPTION_CONFIG = ConfigHelper.getSubscriptionConfig();\n  private static final Subscriptions SUBSCRIPTIONS = mock(Subscriptions.class);\n  private static final GooglePlayBillingManager PLAY_MANAGER = MockUtils.buildMock(GooglePlayBillingManager.class,\n      mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.GOOGLE_PLAY_BILLING));\n  private static final AppleAppStoreManager APPSTORE_MANAGER = MockUtils.buildMock(AppleAppStoreManager.class,\n      mgr -> when(mgr.getProvider()).thenReturn(PaymentProvider.APPLE_APP_STORE));\n  private static final ServerZkReceiptOperations ZK_OPS = mock(ServerZkReceiptOperations.class);\n  private static final BadgeTranslator BADGE_TRANSLATOR = mock(BadgeTranslator.class);\n  private static final BankMandateTranslator BANK_MANDATE_TRANSLATOR = mock(BankMandateTranslator.class);\n  private static final DynamicConfigurationManager<DynamicConfiguration> DYNAMIC_CONFIGURATION_MANAGER = mock(DynamicConfigurationManager.class);\n  private final static SubscriptionController SUBSCRIPTION_CONTROLLER = new SubscriptionController(CLOCK,\n      SUBSCRIPTION_CONFIG, ONETIME_CONFIG,\n      new SubscriptionManager(SUBSCRIPTIONS, List.of(STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER),\n          ZK_OPS, ISSUED_RECEIPTS_MANAGER), STRIPE_MANAGER, BRAINTREE_MANAGER, PLAY_MANAGER, APPSTORE_MANAGER,\n      BADGE_TRANSLATOR, BANK_MANDATE_TRANSLATOR, DYNAMIC_CONFIGURATION_MANAGER);\n  private static final ResourceExtension RESOURCE_EXTENSION = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(AuthHelper.getAuthFilter())\n      .addProvider(CompletionExceptionMapper.class)\n      .addProvider(new AuthValueFactoryProvider.Binder<>(AuthenticatedDevice.class))\n      .addProvider(SubscriptionExceptionMapper.class)\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(SUBSCRIPTION_CONTROLLER)\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    reset(CLOCK, SUBSCRIPTIONS, STRIPE_MANAGER, BRAINTREE_MANAGER, ZK_OPS, ISSUED_RECEIPTS_MANAGER, BADGE_TRANSLATOR);\n\n    when(STRIPE_MANAGER.getProvider()).thenReturn(PaymentProvider.STRIPE);\n    when(BRAINTREE_MANAGER.getProvider()).thenReturn(PaymentProvider.BRAINTREE);\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    when(dynamicConfiguration.getBackupConfiguration())\n        .thenReturn(new DynamicBackupConfiguration(null, null, null, null, MAX_TOTAL_BACKUP_MEDIA_BYTES));\n    when(DYNAMIC_CONFIGURATION_MANAGER.getConfiguration()).thenReturn(dynamicConfiguration);\n\n    List.of(STRIPE_MANAGER, BRAINTREE_MANAGER)\n        .forEach(manager -> when(manager.supportsPaymentMethod(any()))\n            .thenCallRealMethod());\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.CARD))\n        .thenReturn(Set.of(\"usd\", \"jpy\", \"bif\", \"eur\"));\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.SEPA_DEBIT))\n        .thenReturn(Set.of(\"eur\"));\n    when(STRIPE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.IDEAL))\n        .thenReturn(Set.of(\"eur\"));\n    when(BRAINTREE_MANAGER.getSupportedCurrenciesForPaymentMethod(PaymentMethod.PAYPAL))\n        .thenReturn(Set.of(\"usd\", \"jpy\"));\n  }\n\n  @Nested\n  class SetSubscriptionLevel {\n\n    private final long levelId = 5L;\n    private final String currency = \"jpy\";\n\n    private String subscriberId;\n\n    @BeforeEach\n    void setUp() {\n      when(CLOCK.instant()).thenReturn(Instant.now());\n\n      final byte[] subscriberUserAndKey = new byte[32];\n      Arrays.fill(subscriberUserAndKey, (byte) 1);\n      subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n      final ProcessorCustomer processorCustomer = new ProcessorCustomer(\"testCustomerId\", PaymentProvider.STRIPE);\n\n      final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n          Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n          Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n          Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID, b(processorCustomer.toDynamoBytes())\n      );\n      final Subscriptions.Record record = Subscriptions.Record.from(\n          Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n      when(SUBSCRIPTIONS.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any()))\n          .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n      when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong()))\n          .thenReturn(CompletableFuture.completedFuture(null));\n    }\n\n    @Test\n    void createSubscriptionSuccess() throws SubscriptionException {\n      when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))\n          .thenReturn(mock(CustomerAwareSubscriptionPaymentProcessor.SubscriptionId.class));\n\n      final String level = String.valueOf(levelId);\n      final String idempotencyKey = UUID.randomUUID().toString();\n      final Response response = RESOURCE_EXTENSION.target(\n              String.format(\"/v1/subscription/%s/level/%s/%s/%s\", subscriberId, level, currency, idempotencyKey))\n          .request()\n          .put(Entity.json(\"\"));\n\n      assertThat(response.getStatus()).isEqualTo(200);\n    }\n\n    @Test\n    void createSubscriptionProcessorDeclined() throws SubscriptionException {\n      when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))\n          .thenThrow(new SubscriptionProcessorException(PaymentProvider.STRIPE,\n              new ChargeFailure(\"card_declined\", \"Insufficient funds\", null, null, null)));\n\n      final String level = String.valueOf(levelId);\n      final String idempotencyKey = UUID.randomUUID().toString();\n      final Response response = RESOURCE_EXTENSION.target(\n              String.format(\"/v1/subscription/%s/level/%s/%s/%s\", subscriberId, level, currency, idempotencyKey))\n          .request()\n          .put(Entity.json(\"\"));\n\n      assertThat(response.getStatus()).isEqualTo(SubscriptionExceptionMapper.PROCESSOR_ERROR_STATUS_CODE);\n\n      final Map responseMap = response.readEntity(Map.class);\n      assertThat(responseMap.get(\"processor\")).isEqualTo(\"STRIPE\");\n      assertThat(responseMap.get(\"chargeFailure\")).asInstanceOf(\n              InstanceOfAssertFactories.map(String.class, Object.class))\n          .extracting(\"code\")\n          .isEqualTo(\"card_declined\");\n    }\n\n    @Test\n    void missingCustomerId() {\n      final byte[] subscriberUserAndKey = new byte[32];\n      Arrays.fill(subscriberUserAndKey, (byte) 1);\n      subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n      final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n          Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n          Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())\n          // missing processor:customer field\n      );\n      final Subscriptions.Record record = Subscriptions.Record.from(\n          Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n      when(SUBSCRIPTIONS.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any()))\n          .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n      final String level = String.valueOf(levelId);\n      final String idempotencyKey = UUID.randomUUID().toString();\n      final Response response = RESOURCE_EXTENSION.target(\n              String.format(\"/v1/subscription/%s/level/%s/%s/%s\", subscriberId, level, currency, idempotencyKey))\n          .request()\n          .put(Entity.json(\"\"));\n      assertThat(response.getStatus()).isEqualTo(409);\n      assertThat(response.readEntity(Map.class)).containsOnlyKeys(\"code\", \"message\");\n    }\n\n    @Test\n    void wrongProcessor() {\n      final byte[] subscriberUserAndKey = new byte[32];\n      Arrays.fill(subscriberUserAndKey, (byte) 1);\n      subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n      final ProcessorCustomer processorCustomer = new ProcessorCustomer(\"testCustomerId\", PaymentProvider.BRAINTREE);\n      final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n          Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n          Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n          Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID, b(processorCustomer.toDynamoBytes())\n      );\n      final Subscriptions.Record record = Subscriptions.Record.from(\n          Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n      when(SUBSCRIPTIONS.get(eq(Arrays.copyOfRange(subscriberUserAndKey, 0, 16)), any()))\n          .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n      final Response response = RESOURCE_EXTENSION\n          .target(String.format(\"/v1/subscription/%s/create_payment_method\", subscriberId))\n          .request()\n          .post(Entity.json(\"\"));\n\n      assertThat(response.getStatus()).isEqualTo(409);\n      assertThat(response.readEntity(Map.class)).containsOnlyKeys(\"code\", \"message\");\n    }\n\n    @Test\n    void stripePaymentIntentRequiresAction()\n        throws SubscriptionInvalidArgumentsException, SubscriptionProcessorException {\n      when(STRIPE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))\n          .thenThrow(new SubscriptionPaymentRequiresActionException());\n\n      final String level = String.valueOf(levelId);\n      final String idempotencyKey = UUID.randomUUID().toString();\n      final Response response = RESOURCE_EXTENSION.target(\n              String.format(\"/v1/subscription/%s/level/%s/%s/%s\", subscriberId, level, currency, idempotencyKey))\n          .request()\n          .put(Entity.json(\"\"));\n\n      assertThat(response.getStatus()).isEqualTo(400);\n\n      assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelErrorResponse.class))\n          .satisfies(errorResponse ->\n              assertThat(errorResponse.errors())\n                  .anySatisfy(error ->\n                      assertThat(error.type())\n                          .isEqualTo(\n                              SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.PAYMENT_REQUIRES_ACTION)));\n    }\n  }\n\n  @Test\n  void createSubscriber() {\n    when(CLOCK.instant()).thenReturn(Instant.now());\n\n    // basic create\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    when(SUBSCRIPTIONS.get(any(), any())).thenReturn(CompletableFuture.completedFuture(\n        Subscriptions.GetResult.NOT_STORED));\n\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())\n    );\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    when(SUBSCRIPTIONS.create(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(record));\n\n    final Response createResponse = RESOURCE_EXTENSION.target(String.format(\"/v1/subscription/%s\", subscriberId))\n        .request()\n        .put(Entity.json(\"\"));\n    assertThat(createResponse.getStatus()).isEqualTo(200);\n\n    // creating should be idempotent\n    when(SUBSCRIPTIONS.get(any(), any())).thenReturn(CompletableFuture.completedFuture(\n        Subscriptions.GetResult.found(record)));\n    when(SUBSCRIPTIONS.accessedAt(any(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final Response idempotentCreateResponse = RESOURCE_EXTENSION.target(\n            String.format(\"/v1/subscription/%s\", subscriberId))\n        .request()\n        .put(Entity.json(\"\"));\n    assertThat(idempotentCreateResponse.getStatus()).isEqualTo(200);\n\n    // when the manager returns `null`, it means there was a password mismatch from the storage layer `create`.\n    // this could happen if there is a race between two concurrent `create` requests for the same user ID\n    when(SUBSCRIPTIONS.get(any(), any())).thenReturn(CompletableFuture.completedFuture(\n        Subscriptions.GetResult.NOT_STORED));\n    when(SUBSCRIPTIONS.create(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final Response managerCreateNullResponse = RESOURCE_EXTENSION.target(\n            String.format(\"/v1/subscription/%s\", subscriberId))\n        .request()\n        .put(Entity.json(\"\"));\n    assertThat(managerCreateNullResponse.getStatus()).isEqualTo(403);\n\n    final byte[] subscriberUserAndMismatchedKey = new byte[32];\n    Arrays.fill(subscriberUserAndMismatchedKey, 0, 16, (byte) 1);\n    Arrays.fill(subscriberUserAndMismatchedKey, 16, 32, (byte) 2);\n    final String mismatchedSubscriberId = Base64.getEncoder().encodeToString(subscriberUserAndMismatchedKey);\n\n    // a password mismatch for an existing record\n    when(SUBSCRIPTIONS.get(any(), any())).thenReturn(CompletableFuture.completedFuture(\n        Subscriptions.GetResult.PASSWORD_MISMATCH));\n\n    final Response passwordMismatchResponse = RESOURCE_EXTENSION.target(\n            String.format(\"/v1/subscription/%s\", mismatchedSubscriberId))\n        .request()\n        .put(Entity.json(\"\"));\n\n    assertThat(passwordMismatchResponse.getStatus()).isEqualTo(403);\n\n    // invalid request data is a 404\n    final byte[] malformedUserAndKey = new byte[16];\n    Arrays.fill(malformedUserAndKey, (byte) 1);\n    final String malformedUserId = Base64.getEncoder().encodeToString(malformedUserAndKey);\n\n    final Response malformedUserAndKeyResponse = RESOURCE_EXTENSION.target(\n            String.format(\"/v1/subscription/%s\", malformedUserId))\n        .request()\n        .put(Entity.json(\"\"));\n\n    assertThat(malformedUserAndKeyResponse.getStatus()).isEqualTo(404);\n  }\n\n  @Test\n  void createPaymentMethod() {\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any())).thenReturn(CompletableFuture.completedFuture(\n        Subscriptions.GetResult.NOT_STORED));\n\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())\n    );\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    when(SUBSCRIPTIONS.create(any(), any(), any(Instant.class)))\n        .thenReturn(CompletableFuture.completedFuture(record));\n\n    final Response createSubscriberResponse = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s\", subscriberId))\n        .request()\n        .put(Entity.json(\"\"));\n\n    assertThat(createSubscriberResponse.getStatus()).isEqualTo(200);\n\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    final String customerId = \"some-customer-id\";\n    final ProcessorCustomer customer = new ProcessorCustomer(\n        customerId, PaymentProvider.STRIPE);\n    when(STRIPE_MANAGER.createCustomer(any(), any()))\n        .thenReturn(customer);\n\n    final Map<String, AttributeValue> dynamoItemWithProcessorCustomer = new HashMap<>(dynamoItem);\n    dynamoItemWithProcessorCustomer.put(Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,\n        b(new ProcessorCustomer(customerId, PaymentProvider.STRIPE).toDynamoBytes()));\n    final Subscriptions.Record recordWithCustomerId = Subscriptions.Record.from(record.user,\n        dynamoItemWithProcessorCustomer);\n\n    when(SUBSCRIPTIONS.setProcessorAndCustomerId(any(Subscriptions.Record.class), any(),\n        any(Instant.class)))\n        .thenReturn(CompletableFuture.completedFuture(recordWithCustomerId));\n\n    final String clientSecret = \"some-client-secret\";\n    when(STRIPE_MANAGER.createPaymentMethodSetupToken(customerId))\n        .thenReturn(clientSecret);\n\n    final SubscriptionController.CreatePaymentMethodResponse createPaymentMethodResponse = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/create_payment_method\", subscriberId))\n        .request()\n        .post(Entity.json(\"\"))\n        .readEntity(SubscriptionController.CreatePaymentMethodResponse.class);\n\n    assertThat(createPaymentMethodResponse.processor()).isEqualTo(PaymentProvider.STRIPE);\n    assertThat(createPaymentMethodResponse.clientSecret()).isEqualTo(clientSecret);\n\n  }\n\n  @Test\n  void setSubscriptionLevelMissingProcessorCustomer() {\n    // set up record\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())\n    );\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    when(SUBSCRIPTIONS.create(any(), any(), any(Instant.class)))\n        .thenReturn(CompletableFuture.completedFuture(record));\n\n    // set up mocks\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/level/%d/%s/%s\", subscriberId, 5, \"usd\", \"abcd\"))\n        .request()\n        .put(Entity.json(\"\"));\n\n    assertThat(response.getStatus()).isEqualTo(409);\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"5, M1\",\n      \"15, M2\",\n      \"35, M3\",\n      \"201, M4\",\n  })\n  void setSubscriptionLevel(long levelId, String expectedProcessorId)\n      throws SubscriptionProcessorConflictException, SubscriptionProcessorException {\n    // set up record\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final String customerId = \"customer\";\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,\n        b(new ProcessorCustomer(customerId, PaymentProvider.BRAINTREE).toDynamoBytes())\n    );\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    when(SUBSCRIPTIONS.create(any(), any(), any(Instant.class)))\n        .thenReturn(CompletableFuture.completedFuture(record));\n\n    // set up mocks\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    when(BRAINTREE_MANAGER.createSubscription(any(), any(), anyLong(), anyLong()))\n        .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(\"subscription\"));\n    when(SUBSCRIPTIONS.subscriptionCreated(any(), any(), any(), anyLong()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/level/%d/%s/%s\", subscriberId, levelId, \"usd\", \"abcd\"))\n        .request()\n        .put(Entity.json(\"\"));\n\n    verify(BRAINTREE_MANAGER).createSubscription(eq(customerId), eq(expectedProcessorId), eq(levelId), eq(0L));\n    verifyNoMoreInteractions(BRAINTREE_MANAGER);\n\n    assertThat(response.getStatus()).isEqualTo(200);\n\n    assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class))\n        .extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::level)\n        .isEqualTo(levelId);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setSubscriptionLevelExistingSubscription(final String existingCurrency, final long existingLevel,\n      final String requestCurrency, final long requestLevel, final boolean expectUpdate)\n      throws SubscriptionProcessorConflictException, SubscriptionProcessorException {\n\n    // set up record\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final String customerId = \"customer\";\n    final String existingSubscriptionId = \"existingSubscription\";\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,\n        b(new ProcessorCustomer(customerId, PaymentProvider.BRAINTREE).toDynamoBytes()),\n        Subscriptions.KEY_SUBSCRIPTION_ID, s(existingSubscriptionId)\n    );\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    when(SUBSCRIPTIONS.create(any(), any(), any(Instant.class)))\n        .thenReturn(CompletableFuture.completedFuture(record));\n\n    // set up mocks\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    final Object subscriptionObj = new Object();\n    when(BRAINTREE_MANAGER.getSubscription(any())).thenReturn(subscriptionObj);\n    when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))\n        .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(existingLevel, existingCurrency));\n    final String updatedSubscriptionId = \"updatedSubscriptionId\";\n\n    if (expectUpdate) {\n      when(BRAINTREE_MANAGER.updateSubscription(any(), any(), anyLong(), anyString()))\n          .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.SubscriptionId(updatedSubscriptionId));\n      when(SUBSCRIPTIONS.subscriptionLevelChanged(any(), any(), anyLong(), anyString()))\n          .thenReturn(CompletableFuture.completedFuture(null));\n    }\n\n    final String idempotencyKey = \"abcd\";\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/level/%d/%s/%s\", subscriberId, requestLevel, requestCurrency,\n            idempotencyKey))\n        .request()\n        .put(Entity.json(\"\"));\n\n    verify(BRAINTREE_MANAGER).getSubscription(any());\n    verify(BRAINTREE_MANAGER).getLevelAndCurrencyForSubscription(any());\n\n    if (expectUpdate) {\n      verify(BRAINTREE_MANAGER).updateSubscription(any(), any(), eq(requestLevel), eq(idempotencyKey));\n      verify(SUBSCRIPTIONS).subscriptionLevelChanged(any(), any(), eq(requestLevel), eq(updatedSubscriptionId));\n    }\n\n    verifyNoMoreInteractions(BRAINTREE_MANAGER);\n\n    assertThat(response.getStatus()).isEqualTo(200);\n\n    assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class))\n        .extracting(SubscriptionController.SetSubscriptionLevelSuccessResponse::level)\n        .isEqualTo(requestLevel);\n  }\n\n  static Stream<Arguments> setSubscriptionLevelExistingSubscription() {\n    return Stream.of(\n        Arguments.of(\"usd\", 5, \"usd\", 5, false),\n        Arguments.of(\"usd\", 5, \"jpy\", 5, true),\n        Arguments.of(\"usd\", 5, \"usd\", 15, true),\n        Arguments.of(\"usd\", 5, \"jpy\", 15, true),\n        Arguments.of(\"usd\", 201, \"usd\", 201, false),\n        Arguments.of(\"usd\", 201, \"jpy\", 201, true)\n    );\n  }\n\n  @Test\n  public void changeSubscriptionLevelInvalid() {\n    // set up record\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final String customerId = \"customer\";\n    final String existingSubscriptionId = \"existingSubscription\";\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,\n        b(new ProcessorCustomer(customerId, PaymentProvider.BRAINTREE).toDynamoBytes()),\n        Subscriptions.KEY_SUBSCRIPTION_ID, s(existingSubscriptionId));\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    when(SUBSCRIPTIONS.create(any(), any(), any(Instant.class)))\n        .thenReturn(CompletableFuture.completedFuture(record));\n\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    final Object subscriptionObj = new Object();\n    when(BRAINTREE_MANAGER.getSubscription(any())).thenReturn(subscriptionObj);\n    when(BRAINTREE_MANAGER.getLevelAndCurrencyForSubscription(subscriptionObj))\n        .thenReturn(new CustomerAwareSubscriptionPaymentProcessor.LevelAndCurrency(201, \"usd\"));\n\n    // Try to change from a backup subscription (201) to a donation subscription (5)\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/level/%d/%s/%s\", subscriberId, 5, \"usd\", \"abcd\"))\n        .request()\n        .put(Entity.json(\"\"));\n    assertThat(response.getStatus()).isEqualTo(400);\n    assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelErrorResponse.class))\n        .extracting(resp -> resp.errors())\n        .asInstanceOf(InstanceOfAssertFactories.list(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.class))\n        .hasSize(1).first()\n        .extracting(error -> error.type())\n        .isEqualTo(SubscriptionController.SetSubscriptionLevelErrorResponse.Error.Type.UNSUPPORTED_LEVEL);\n  }\n\n  @Test\n  public void setAppStoreTransactionId()\n      throws SubscriptionInvalidArgumentsException, SubscriptionPaymentRequiredException, RateLimitExceededException, SubscriptionNotFoundException {\n    final String originalTxId = \"aTxId\";\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final byte[] user = Arrays.copyOfRange(subscriberUserAndKey, 0, 16);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final Instant now = Instant.now();\n    when(CLOCK.instant()).thenReturn(now);\n\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()));\n\n    final Subscriptions.Record record = Subscriptions.Record.from(user, dynamoItem);\n\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    when(APPSTORE_MANAGER.validateTransaction(eq(originalTxId)))\n        .thenReturn(99L);\n\n    when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/appstore/%s\", subscriberId, originalTxId))\n        .request()\n        .post(Entity.json(\"\"));\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class).level())\n        .isEqualTo(99L);\n\n    verify(SUBSCRIPTIONS, times(1)).setIapPurchase(\n        any(),\n        eq(new ProcessorCustomer(originalTxId, PaymentProvider.APPLE_APP_STORE)),\n        eq(originalTxId),\n        eq(99L),\n        eq(now));\n  }\n\n\n  @Test\n  public void setPlayPurchaseToken() throws RateLimitExceededException, SubscriptionException {\n    final String purchaseToken = \"aPurchaseToken\";\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final byte[] user = Arrays.copyOfRange(subscriberUserAndKey, 0, 16);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final Instant now = Instant.now();\n    when(CLOCK.instant()).thenReturn(now);\n\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond())\n    );\n    final Subscriptions.Record record = Subscriptions.Record.from(user, dynamoItem);\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class);\n    when(validatedToken.getLevel()).thenReturn(99L);\n    when(PLAY_MANAGER.validateToken(eq(purchaseToken))).thenReturn(validatedToken);\n\n    when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/playbilling/%s\", subscriberId, purchaseToken))\n        .request()\n        .post(Entity.json(\"\"));\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class).level())\n        .isEqualTo(99L);\n\n    verify(SUBSCRIPTIONS, times(1)).setIapPurchase(\n        any(),\n        eq(new ProcessorCustomer(purchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING)),\n        eq(purchaseToken),\n        eq(99L),\n        eq(now));\n  }\n\n  @Test\n  public void replacePlayPurchaseToken() throws RateLimitExceededException, SubscriptionException {\n    final String oldPurchaseToken = \"oldPurchaseToken\";\n    final String newPurchaseToken = \"newPurchaseToken\";\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final byte[] user = Arrays.copyOfRange(subscriberUserAndKey, 0, 16);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final Instant now = Instant.now();\n    when(CLOCK.instant()).thenReturn(now);\n\n    final ProcessorCustomer oldPc = new ProcessorCustomer(oldPurchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING);\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID, b(oldPc.toDynamoBytes()));\n    final Subscriptions.Record record = Subscriptions.Record.from(user, dynamoItem);\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n\n    final GooglePlayBillingManager.ValidatedToken validatedToken = mock(GooglePlayBillingManager.ValidatedToken.class);\n    when(validatedToken.getLevel()).thenReturn(99L);\n\n    when(PLAY_MANAGER.validateToken(eq(newPurchaseToken))).thenReturn(validatedToken);\n\n    when(SUBSCRIPTIONS.setIapPurchase(any(), any(), anyString(), anyLong(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/playbilling/%s\", subscriberId, newPurchaseToken))\n        .request()\n        .post(Entity.json(\"\"));\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.readEntity(SubscriptionController.SetSubscriptionLevelSuccessResponse.class).level())\n        .isEqualTo(99L);\n\n    verify(SUBSCRIPTIONS, times(1)).setIapPurchase(\n        any(),\n        eq(new ProcessorCustomer(newPurchaseToken, PaymentProvider.GOOGLE_PLAY_BILLING)),\n        eq(newPurchaseToken),\n        eq(99L),\n        eq(now));\n\n    verify(PLAY_MANAGER, times(1)).cancelAllActiveSubscriptions(oldPurchaseToken);\n  }\n\n  @Test\n  void createReceiptChargeFailure()\n      throws InvalidInputException, VerificationFailedException, SubscriptionException {\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(Subscriptions.Record.from(\n            Arrays.copyOfRange(subscriberUserAndKey, 0, 16),\n            Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n                Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n                Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n                Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,\n                b(new ProcessorCustomer(\"customer\", PaymentProvider.STRIPE).toDynamoBytes()),\n                Subscriptions.KEY_SUBSCRIPTION_ID, s(\"subscriptionId\"))))));\n    when(STRIPE_MANAGER.getReceiptItem(any()))\n        .thenThrow(new SubscriptionChargeFailurePaymentRequiredException(\n            PaymentProvider.STRIPE,\n            new ChargeFailure(\"card_declined\", \"Insufficient funds\", null, null, null)));\n\n    final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations(\n        ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(\n        new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/receipt_credentials\", subscriberId))\n        .request()\n        .post(Entity.json(new SubscriptionController.GetReceiptCredentialsRequest(receiptRequest.serialize())));\n\n    assertThat(response.getStatus()).isEqualTo(402);\n    final Map responseMap = response.readEntity(Map.class);\n    assertThat(responseMap.get(\"processor\")).isEqualTo(\"STRIPE\");\n    assertThat(responseMap.get(\"chargeFailure\")).asInstanceOf(\n            InstanceOfAssertFactories.map(String.class, Object.class))\n        .extracting(\"code\")\n        .isEqualTo(\"card_declined\");\n  }\n\n  @ParameterizedTest\n  @CsvSource({\"5, P45D\", \"201, P13D\"})\n  public void createReceiptCredential(long level, Duration expectedExpirationWindow)\n      throws InvalidInputException, VerificationFailedException, SubscriptionChargeFailurePaymentRequiredException, SubscriptionReceiptRequestedForOpenPaymentException {\n    final byte[] subscriberUserAndKey = new byte[32];\n    Arrays.fill(subscriberUserAndKey, (byte) 1);\n    final String subscriberId = Base64.getEncoder().encodeToString(subscriberUserAndKey);\n\n    final String customerId = \"customer\";\n    final String subscriptionId = \"subscriptionId\";\n    final Map<String, AttributeValue> dynamoItem = Map.of(Subscriptions.KEY_PASSWORD, b(new byte[16]),\n        Subscriptions.KEY_CREATED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_ACCESSED_AT, n(Instant.now().getEpochSecond()),\n        Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID,\n        b(new ProcessorCustomer(customerId, PaymentProvider.BRAINTREE).toDynamoBytes()),\n        Subscriptions.KEY_SUBSCRIPTION_ID, s(subscriptionId));\n    final Subscriptions.Record record = Subscriptions.Record.from(\n        Arrays.copyOfRange(subscriberUserAndKey, 0, 16), dynamoItem);\n    final ReceiptCredentialRequest receiptRequest = new ClientZkReceiptOperations(\n        ServerSecretParams.generate().getPublicParams()).createReceiptCredentialRequestContext(\n        new ReceiptSerial(new byte[ReceiptSerial.SIZE])).getRequest();\n    final ReceiptCredentialResponse receiptCredentialResponse = mock(ReceiptCredentialResponse.class);\n\n    when(CLOCK.instant()).thenReturn(Instant.now());\n    when(SUBSCRIPTIONS.get(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Subscriptions.GetResult.found(record)));\n    when(BRAINTREE_MANAGER.getReceiptItem(subscriptionId)).thenReturn(\n        new CustomerAwareSubscriptionPaymentProcessor.ReceiptItem(\n            \"itemId\",\n            PaymentTime.periodStart(Instant.ofEpochSecond(10).plus(Duration.ofDays(1))),\n            level));\n    when(ISSUED_RECEIPTS_MANAGER.recordIssuance(eq(\"itemId\"), eq(PaymentProvider.BRAINTREE), eq(receiptRequest), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n    when(ZK_OPS.issueReceiptCredential(any(), anyLong(), eq(level))).thenReturn(receiptCredentialResponse);\n    when(receiptCredentialResponse.serialize()).thenReturn(new byte[0]);\n    final Response response = RESOURCE_EXTENSION\n        .target(String.format(\"/v1/subscription/%s/receipt_credentials\", subscriberId))\n        .request()\n        .post(Entity.json(new SubscriptionController.GetReceiptCredentialsRequest(receiptRequest.serialize())));\n    assertThat(response.getStatus()).isEqualTo(200);\n\n    long expectedExpiration = Instant.EPOCH\n        // Truncated current time is day 1\n        .plus(Duration.ofDays(1))\n        // Expected expiration window\n        .plus(expectedExpirationWindow)\n        // + one day to forgive skew\n        .plus(Duration.ofDays(1)).getEpochSecond();\n    verify(ZK_OPS).issueReceiptCredential(any(), eq(expectedExpiration), eq(level));\n  }\n\n  @Test\n  void testGetBankMandate() {\n    when(BANK_MANDATE_TRANSLATOR.translate(any(), any())).thenReturn(\"bankMandate\");\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/bank_mandate/sepa_debit\")\n        .request()\n        .get();\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.readEntity(GetBankMandateResponse.class).mandate()).isEqualTo(\"bankMandate\");\n  }\n\n  @Test\n  void testGetBankMandateInvalidBankTransferType() {\n    final Response response = RESOURCE_EXTENSION.target(\"/v1/subscription/bank_mandate/ach\")\n        .request()\n        .get();\n    assertThat(response.getStatus()).isEqualTo(400);\n  }\n\n  @Test\n  void getSubscriptionConfiguration() {\n    when(BADGE_TRANSLATOR.translate(any(), eq(\"B1\"))).thenReturn(new Badge(\"B1\", \"cat1\", \"name1\", \"desc1\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n        List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))));\n    when(BADGE_TRANSLATOR.translate(any(), eq(\"B2\"))).thenReturn(new Badge(\"B2\", \"cat2\", \"name2\", \"desc2\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n        List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))));\n    when(BADGE_TRANSLATOR.translate(any(), eq(\"B3\"))).thenReturn(new Badge(\"B3\", \"cat3\", \"name3\", \"desc3\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n        List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))));\n    when(BADGE_TRANSLATOR.translate(any(), eq(\"BOOST\"))).thenReturn(new Badge(\"BOOST\", \"boost1\", \"boost1\", \"boost1\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n        List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))));\n    when(BADGE_TRANSLATOR.translate(any(), eq(\"GIFT\"))).thenReturn(new Badge(\"GIFT\", \"gift1\", \"gift1\", \"gift1\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"), \"SVG\",\n        List.of(new BadgeSvg(\"sl\", \"sd\"), new BadgeSvg(\"ml\", \"md\"), new BadgeSvg(\"ll\", \"ld\"))));\n\n    GetSubscriptionConfigurationResponse response = RESOURCE_EXTENSION.target(\"/v1/subscription/configuration\")\n        .request()\n        .get(GetSubscriptionConfigurationResponse.class);\n\n    assertThat(response.sepaMaximumEuros()).isEqualTo(\"10000\");\n    assertThat(response.currencies()).containsKeys(\"usd\", \"jpy\", \"bif\", \"eur\").satisfies(currencyMap -> {\n      assertThat(currencyMap).extractingByKey(\"usd\").satisfies(currency -> {\n        assertThat(currency.minimum()).isEqualByComparingTo(\n            BigDecimal.valueOf(2.5).setScale(2, RoundingMode.HALF_EVEN));\n        assertThat(currency.oneTime()).isEqualTo(\n            Map.of(\"1\",\n                List.of(BigDecimal.valueOf(5.5).setScale(2, RoundingMode.HALF_EVEN), BigDecimal.valueOf(6),\n                    BigDecimal.valueOf(7), BigDecimal.valueOf(8),\n                    BigDecimal.valueOf(9), BigDecimal.valueOf(10)), \"100\",\n                List.of(BigDecimal.valueOf(20))));\n        assertThat(currency.subscription()).isEqualTo(\n            Map.of(\"5\", BigDecimal.valueOf(5), \"15\", BigDecimal.valueOf(15), \"35\", BigDecimal.valueOf(35)));\n        assertThat(currency.backupSubscription()).isEqualTo(Map.of(\"201\", BigDecimal.valueOf(5)));\n        assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of(\"CARD\", \"PAYPAL\"));\n      });\n\n      assertThat(currencyMap).extractingByKey(\"jpy\").satisfies(currency -> {\n        assertThat(currency.minimum()).isEqualByComparingTo(\n            BigDecimal.valueOf(250));\n        assertThat(currency.oneTime()).isEqualTo(\n            Map.of(\"1\",\n                List.of(BigDecimal.valueOf(550), BigDecimal.valueOf(600),\n                    BigDecimal.valueOf(700), BigDecimal.valueOf(800),\n                    BigDecimal.valueOf(900), BigDecimal.valueOf(1000)), \"100\",\n                List.of(BigDecimal.valueOf(2000))));\n        assertThat(currency.subscription()).isEqualTo(\n            Map.of(\"5\", BigDecimal.valueOf(500), \"15\", BigDecimal.valueOf(1500), \"35\", BigDecimal.valueOf(3500)));\n        assertThat(currency.backupSubscription()).isEqualTo(Map.of(\"201\", BigDecimal.valueOf(500)));\n        assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of(\"CARD\", \"PAYPAL\"));\n      });\n\n      assertThat(currencyMap).extractingByKey(\"bif\").satisfies(currency -> {\n        assertThat(currency.minimum()).isEqualByComparingTo(\n            BigDecimal.valueOf(2500));\n        assertThat(currency.oneTime()).isEqualTo(\n            Map.of(\"1\",\n                List.of(BigDecimal.valueOf(5500), BigDecimal.valueOf(6000),\n                    BigDecimal.valueOf(7000), BigDecimal.valueOf(8000),\n                    BigDecimal.valueOf(9000), BigDecimal.valueOf(10000)), \"100\",\n                List.of(BigDecimal.valueOf(20000))));\n        assertThat(currency.subscription()).isEqualTo(\n            Map.of(\"5\", BigDecimal.valueOf(5000), \"15\", BigDecimal.valueOf(15000), \"35\", BigDecimal.valueOf(35000)));\n        assertThat(currency.backupSubscription()).isEqualTo(Map.of(\"201\", BigDecimal.valueOf(5000)));\n        assertThat(currency.supportedPaymentMethods()).isEqualTo(List.of(\"CARD\"));\n      });\n\n      assertThat(currencyMap).extractingByKey(\"eur\").satisfies(currency -> {\n        assertThat(currency.minimum()).isEqualByComparingTo(\n            BigDecimal.valueOf(3));\n        assertThat(currency.oneTime()).isEqualTo(\n            Map.of(\"1\",\n                List.of(BigDecimal.valueOf(5), BigDecimal.valueOf(10),\n                    BigDecimal.valueOf(20), BigDecimal.valueOf(30), BigDecimal.valueOf(50), BigDecimal.valueOf(100)), \"100\",\n                List.of(BigDecimal.valueOf(5))));\n        assertThat(currency.subscription()).isEqualTo(\n            Map.of(\"5\", BigDecimal.valueOf(5), \"15\", BigDecimal.valueOf(15), \"35\", BigDecimal.valueOf(35)));\n        assertThat(currency.backupSubscription()).isEqualTo(Map.of(\"201\", BigDecimal.valueOf(5)));\n        final List<String> expectedPaymentMethods = List.of(\"CARD\", \"SEPA_DEBIT\", \"IDEAL\");\n        assertThat(currency.supportedPaymentMethods()).isEqualTo(expectedPaymentMethods);\n      });\n    });\n\n    assertThat(response.levels()).containsKeys(\"1\", \"5\", \"15\", \"35\", \"100\").satisfies(levelsMap -> {\n      assertThat(levelsMap).extractingByKey(\"1\").satisfies(\n          level -> assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge)\n              .satisfies(badge -> {\n                assertThat(badge.getId()).isEqualTo(\"BOOST\");\n                assertThat(badge.getName()).isEqualTo(\"boost1\");\n              }));\n\n      assertThat(levelsMap).extractingByKey(\"100\").satisfies(\n          level -> assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge)\n              .satisfies(badge -> {\n                assertThat(badge.getId()).isEqualTo(\"GIFT\");\n                assertThat(badge.getName()).isEqualTo(\"gift1\");\n              }));\n\n      assertThat(levelsMap).extractingByKey(\"5\").satisfies(level -> {\n        assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge)\n            .satisfies(badge -> {\n              assertThat(badge.getId()).isEqualTo(\"B1\");\n              assertThat(badge.getName()).isEqualTo(\"name1\");\n            });\n      });\n\n      assertThat(levelsMap).extractingByKey(\"15\").satisfies(level ->\n          assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge)\n              .satisfies(badge -> {\n                assertThat(badge.getId()).isEqualTo(\"B2\");\n                assertThat(badge.getName()).isEqualTo(\"name2\");\n              }));\n\n      assertThat(levelsMap).extractingByKey(\"35\").satisfies(level -> {\n        assertThat(level).extracting(SubscriptionController.LevelConfiguration::badge)\n            .satisfies(badge -> {\n              assertThat(badge.getId()).isEqualTo(\"B3\");\n              assertThat(badge.getName()).isEqualTo(\"name3\");\n            });\n      });\n    });\n\n    assertThat(response.backup().levels()).containsOnlyKeys(\"201\").extractingByKey(\"201\").satisfies(configuration -> {\n      assertThat(configuration.storageAllowanceBytes()).isEqualTo(MAX_TOTAL_BACKUP_MEDIA_BYTES);\n      assertThat(configuration.playProductId()).isEqualTo(\"testPlayProductId\");\n      assertThat(configuration.mediaTtlDays()).isEqualTo(40);\n    });\n    assertThat(response.backup().freeTierMediaDays()).isEqualTo(30);\n\n    // check the badge vs purchasable badge fields\n    // subscription levels are Badge, while one-time levels are PurchasableBadge, which adds `duration`\n    Map<String, Object> genericResponse = RESOURCE_EXTENSION.target(\"/v1/subscription/configuration\")\n        .request()\n        .get(Map.class);\n\n    assertThat(genericResponse.get(\"levels\")).satisfies(levels -> {\n      final Set<String> oneTimeLevels = Set.of(\"1\", \"100\");\n      oneTimeLevels.forEach(\n          oneTimeLevel -> assertThat((Map<String, Map<String, Map<String, Object>>>) levels)\n              .extractingByKey(oneTimeLevel)\n              .satisfies(level -> assertThat(level.get(\"badge\")).containsKeys(\"duration\")));\n\n      ((Map<String, ?>) levels).keySet().stream()\n          .filter(Predicate.not(oneTimeLevels::contains))\n          .forEach(subscriptionLevel ->\n              assertThat((Map<String, Map<String, Map<String, Object>>>) levels)\n                  .extractingByKey(subscriptionLevel)\n                  .satisfies(level -> assertThat(level.get(\"badge\")).doesNotContainKeys(\"duration\")));\n    });\n  }\n\n\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/controllers/VerificationControllerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.controllers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport jakarta.ws.rs.client.Entity;\nimport jakarta.ws.rs.client.Invocation;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.stream.Stream;\nimport org.apache.commons.lang3.StringUtils;\nimport org.apache.http.HttpStatus;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.captcha.AssessmentResult;\nimport org.whispersystems.textsecuregcm.captcha.RegistrationCaptchaManager;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicCarrierDataLookupConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRegistrationConfiguration;\nimport org.whispersystems.textsecuregcm.entities.RegistrationServiceSession;\nimport org.whispersystems.textsecuregcm.entities.VerificationSessionResponse;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.mappers.ImpossiblePhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.NonNormalizedPhoneNumberExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.ObsoletePhoneNumberFormatExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RateLimitExceededExceptionMapper;\nimport org.whispersystems.textsecuregcm.mappers.RegistrationServiceSenderExceptionMapper;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.registration.RegistrationFraudException;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceClient;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceException;\nimport org.whispersystems.textsecuregcm.registration.RegistrationServiceSenderException;\nimport org.whispersystems.textsecuregcm.registration.TransportNotAllowedException;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\nimport org.whispersystems.textsecuregcm.spam.RegistrationFraudChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.PhoneNumberIdentifiers;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.VerificationSessionManager;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataProvider;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass VerificationControllerTest {\n\n  private static final long SESSION_EXPIRATION_SECONDS = Duration.ofMinutes(10).toSeconds();\n\n  private static final byte[] SESSION_ID = \"session\".getBytes(StandardCharsets.UTF_8);\n  private static final String NUMBER = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n      PhoneNumberUtil.PhoneNumberFormat.E164);\n\n  private static final UUID PNI = UUID.randomUUID();\n  private final RegistrationServiceClient registrationServiceClient = mock(RegistrationServiceClient.class);\n  private final VerificationSessionManager verificationSessionManager = mock(VerificationSessionManager.class);\n  private final PushNotificationManager pushNotificationManager = mock(PushNotificationManager.class);\n  private final RegistrationCaptchaManager registrationCaptchaManager = mock(RegistrationCaptchaManager.class);\n  private final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager = mock(\n      RegistrationRecoveryPasswordsManager.class);\n  private final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class);\n  private final RateLimiters rateLimiters = mock(RateLimiters.class);\n  private final AccountsManager accountsManager = mock(AccountsManager.class);\n  private final CarrierDataProvider carrierDataProvider = mock(CarrierDataProvider.class);\n  private final Clock clock = Clock.systemUTC();\n\n  private final RateLimiter captchaLimiter = mock(RateLimiter.class);\n  private final RateLimiter pushChallengeLimiter = mock(RateLimiter.class);\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(\n      DynamicConfigurationManager.class);\n  private final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n\n  private final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(new RateLimitExceededExceptionMapper())\n      .addProvider(new ImpossiblePhoneNumberExceptionMapper())\n      .addProvider(new NonNormalizedPhoneNumberExceptionMapper())\n      .addProvider(new ObsoletePhoneNumberFormatExceptionMapper())\n      .addProvider(new RegistrationServiceSenderExceptionMapper())\n      .addProvider(new TestRemoteAddressFilterProvider(\"127.0.0.1\"))\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(\n          new VerificationController(registrationServiceClient, verificationSessionManager, pushNotificationManager,\n              registrationCaptchaManager, registrationRecoveryPasswordsManager, phoneNumberIdentifiers, rateLimiters, accountsManager,\n              carrierDataProvider, RegistrationFraudChecker.noop(), dynamicConfigurationManager, clock))\n      .build();\n\n  @BeforeEach\n  void setUp() {\n    when(rateLimiters.getVerificationCaptchaLimiter())\n        .thenReturn(captchaLimiter);\n    when(rateLimiters.getVerificationPushChallengeLimiter())\n        .thenReturn(pushChallengeLimiter);\n    when(accountsManager.getByE164(any()))\n        .thenReturn(Optional.empty());\n    when(dynamicConfiguration.getRegistrationConfiguration())\n        .thenReturn(new DynamicRegistrationConfiguration(false));\n    when(dynamicConfiguration.getCarrierDataLookupConfiguration())\n        .thenReturn(new DynamicCarrierDataLookupConfiguration());\n    when(dynamicConfigurationManager.getConfiguration())\n        .thenReturn(dynamicConfiguration);\n    when(phoneNumberIdentifiers.getPhoneNumberIdentifier(NUMBER))\n        .thenReturn(CompletableFuture.completedFuture(PNI));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void createSessionUnprocessableRequestJson(final String number, final String pushToken, final String pushTokenType) {\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request();\n    try (Response response = request.post(\n        Entity.json(unprocessableCreateSessionJson(number, pushToken, pushTokenType)))) {\n      assertEquals(400, response.getStatus());\n    }\n\n  }\n\n  static Stream<Arguments> createSessionUnprocessableRequestJson() {\n    return Stream.of(\n        Arguments.of(\"[]\", null, null),\n        Arguments.of(String.format(\"\\\"%s\\\"\", NUMBER), \"some-push-token\", \"invalid-token-type\")\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void createSessionInvalidRequestJson(final String number, final String pushToken, final String pushTokenType) {\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(createSessionJson(number, pushToken, pushTokenType)))) {\n      assertEquals(422, response.getStatus());\n    }\n  }\n\n  static Stream<Arguments> createSessionInvalidRequestJson() {\n    return Stream.of(\n        Arguments.of(null, null, null),\n        Arguments.of(\"+1800\", null, null),\n        Arguments.of(\" \", null, null),\n        Arguments.of(NUMBER, null, \"fcm\"),\n        Arguments.of(NUMBER, \"some-push-token\", null)\n    );\n  }\n\n  @Test\n  void createSessionRateLimited() {\n    when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RateLimitExceededException(null)));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(createSessionJson(NUMBER, null, null)))) {\n      assertEquals(429, response.getStatus());\n    }\n  }\n\n  @Test\n  void createSessionRegistrationServiceError() {\n    when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException(\"expected service error\")));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(createSessionJson(NUMBER, null, null)))) {\n      assertEquals(HttpStatus.SC_INTERNAL_SERVER_ERROR, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void createBeninSessionSuccess(final String requestedNumber, final String expectedNumber) {\n    when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any(), any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                new RegistrationServiceSession(SESSION_ID, requestedNumber, false, null, null, null,\n                    SESSION_EXPIRATION_SECONDS)));\n    when(verificationSessionManager.insert(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(createSessionJson(requestedNumber, \"token\", \"fcm\")))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final ArgumentCaptor<Phonenumber.PhoneNumber> phoneNumberArgumentCaptor = ArgumentCaptor.forClass(\n          Phonenumber.PhoneNumber.class);\n      verify(registrationServiceClient).createRegistrationSession(phoneNumberArgumentCaptor.capture(), anyString(), anyBoolean(), any(), any(), any());\n      final Phonenumber.PhoneNumber phoneNumber = phoneNumberArgumentCaptor.getValue();\n\n      assertEquals(expectedNumber, PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164));\n    }\n  }\n\n  private static Stream<Arguments> createBeninSessionSuccess() {\n    // libphonenumber 8.13.50 and on generate new-format numbers for Benin\n    final String newFormatBeninE164 = PhoneNumberUtil.getInstance()\n        .format(PhoneNumberUtil.getInstance().getExampleNumber(\"BJ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n    return Stream.of(\n        Arguments.of(newFormatBeninE164, newFormatBeninE164),\n        Arguments.of(NUMBER, NUMBER)\n    );\n  }\n\n  @Test\n  void createBeninSessionFailure() {\n    // libphonenumber 8.13.50 and on generate new-format numbers for Benin\n    final String newFormatBeninE164 = PhoneNumberUtil.getInstance()\n        .format(PhoneNumberUtil.getInstance().getExampleNumber(\"BJ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n    final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst(\"01\", \"\");\n\n    when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any(), any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,\n                    SESSION_EXPIRATION_SECONDS)));\n    when(verificationSessionManager.insert(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(createSessionJson(oldFormatBeninE164, \"token\", \"fcm\")))) {\n      assertEquals(499, response.getStatus());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void createSessionSuccess(final String pushToken, final String pushTokenType,\n      final List<VerificationSession.Information> expectedRequestedInformation) {\n    when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any(), any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,\n                    SESSION_EXPIRATION_SECONDS)));\n    when(verificationSessionManager.insert(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(createSessionJson(NUMBER, pushToken, pushTokenType)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n      assertEquals(expectedRequestedInformation, verificationSessionResponse.requestedInformation());\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertFalse(verificationSessionResponse.verified());\n    }\n  }\n\n  static Stream<Arguments> createSessionSuccess() {\n    return Stream.of(\n        Arguments.of(null, null, List.of(VerificationSession.Information.CAPTCHA)),\n        Arguments.of(\"token\", \"fcm\",\n            List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA))\n    );\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void createSessionReregistration(final boolean isReregistration) throws NumberParseException {\n    when(registrationServiceClient.createRegistrationSession(any(), anyString(), anyBoolean(), any(), any(), any()))\n        .thenReturn(\n            CompletableFuture.completedFuture(\n                new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,\n                    SESSION_EXPIRATION_SECONDS)));\n\n    when(verificationSessionManager.insert(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    when(accountsManager.getByE164(NUMBER))\n        .thenReturn(isReregistration ? Optional.of(mock(Account.class)) : Optional.empty());\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n\n    try (final Response response = request.post(Entity.json(createSessionJson(NUMBER, null, null)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      verify(registrationServiceClient).createRegistrationSession(\n          eq(PhoneNumberUtil.getInstance().parse(NUMBER, null)),\n          anyString(),\n          eq(isReregistration),\n          any(),\n          any(),\n          any()\n      );\n    }\n  }\n\n  @Test\n  void patchSessionMalformedId() {\n    final String invalidSessionId = \"()()()\";\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + invalidSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(\"{}\"))) {\n      assertEquals(HttpStatus.SC_UNPROCESSABLE_ENTITY, response.getStatus());\n    }\n  }\n\n  @Test\n  void patchSessionNotFound() {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodeSessionId(SESSION_ID))\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(\"{}\"))) {\n      assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());\n    }\n  }\n\n  @Test\n  void patchSessionPushToken() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                new VerificationSession(encodedSessionId, null, null, List.of(VerificationSession.Information.CAPTCHA), Collections.emptyList(),\n                    null, null, false, clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(null, null, \"abcde\", \"fcm\")))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),\n          updatedSession.requestedInformation());\n      assertTrue(updatedSession.submittedInformation().isEmpty());\n      assertNotNull(updatedSession.pushChallenge());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),\n          verificationSessionResponse.requestedInformation());\n    }\n  }\n\n  @Test\n  void patchSessionCaptchaRateLimited() throws Exception {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, false,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    doThrow(RateLimitExceededException.class)\n        .when(captchaLimiter).validate(anyString());\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(\"captcha\", null, null, null)))) {\n      assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void patchSessionPushChallengeRateLimited() throws Exception {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, false,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    doThrow(RateLimitExceededException.class)\n        .when(pushChallengeLimiter).validate(anyString());\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(null, \"challenge\", null, null)))) {\n      assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void patchSessionPushChallengeMismatch() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, \"challenge\", null, List.of(VerificationSession.Information.PUSH_CHALLENGE),\n                Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n                registrationServiceSession.expiration()))));\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(null, \"mismatched\", null, null)))) {\n      assertEquals(HttpStatus.SC_FORBIDDEN, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertEquals(List.of(\n          VerificationSession.Information.PUSH_CHALLENGE), verificationSessionResponse.requestedInformation());\n    }\n  }\n\n  @Test\n  void patchSessionCaptchaInvalid() throws Exception {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, List.of(VerificationSession.Information.CAPTCHA),\n                Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n                registrationServiceSession.expiration()))));\n\n    when(registrationCaptchaManager.assessCaptcha(any(), any(), any(), any()))\n        .thenReturn(Optional.of(AssessmentResult.invalid()));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(\"captcha\", null, null, null)))) {\n      assertEquals(HttpStatus.SC_FORBIDDEN, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertEquals(List.of(VerificationSession.Information.CAPTCHA),\n          updatedSession.requestedInformation());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertEquals(List.of(\n          VerificationSession.Information.CAPTCHA), verificationSessionResponse.requestedInformation());\n    }\n  }\n\n  @Test\n  void patchSessionPushChallengeAlreadySubmitted() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId,\n                \"challenge\",\n                null,\n                List.of(VerificationSession.Information.CAPTCHA),\n                List.of(VerificationSession.Information.PUSH_CHALLENGE),\n                null, null, false,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(null, \"challenge\", null, null)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE),\n          updatedSession.submittedInformation());\n      assertEquals(List.of(VerificationSession.Information.CAPTCHA), updatedSession.requestedInformation());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertEquals(List.of(\n          VerificationSession.Information.CAPTCHA), verificationSessionResponse.requestedInformation());\n    }\n  }\n\n  @Test\n  void patchSessionAlreadyVerified() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        true, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, \"challenge\", null, List.of(), List.of(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(null, \"challenge\", null, null)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.verified());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n\n      verify(registrationRecoveryPasswordsManager).remove(PNI);\n    }\n  }\n\n  @Test\n  void patchSessionPushChallengeSuccess() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId,\n                \"challenge\",\n                null,\n                List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),\n                Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n                registrationServiceSession.expiration()))));\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(null, \"challenge\", null, null)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE),\n          updatedSession.submittedInformation());\n      assertTrue(updatedSession.requestedInformation().isEmpty());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void patchSessionCaptchaSuccess() throws Exception {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, List.of(VerificationSession.Information.CAPTCHA),\n                Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n                registrationServiceSession.expiration()))));\n\n    when(registrationCaptchaManager.assessCaptcha(any(), any(), any(), any()))\n        .thenReturn(Optional.of(AssessmentResult.alwaysValid()));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\", Entity.json(updateSessionJson(\"captcha\", null, null, null)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertEquals(List.of(VerificationSession.Information.CAPTCHA),\n          updatedSession.submittedInformation());\n      assertTrue(updatedSession.requestedInformation().isEmpty());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void patchSessionPushAndCaptchaSuccess() throws Exception {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId,\n                \"challenge\",\n                null,\n                List.of(VerificationSession.Information.CAPTCHA, VerificationSession.Information.CAPTCHA),\n                Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n                registrationServiceSession.expiration()))));\n\n    when(registrationCaptchaManager.assessCaptcha(any(), any(), any(), any()))\n        .thenReturn(Optional.of(AssessmentResult.alwaysValid()));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\",\n        Entity.json(updateSessionJson(\"captcha\", \"challenge\", null, null)))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),\n          updatedSession.submittedInformation());\n      assertTrue(updatedSession.requestedInformation().isEmpty());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void patchSessionTokenUpdatedCaptchaError() throws Exception {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId,\n                null,\n                null,\n                List.of(VerificationSession.Information.CAPTCHA),\n                Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n                registrationServiceSession.expiration()))));\n\n    when(registrationCaptchaManager.assessCaptcha(any(), any(), any(), any()))\n        .thenThrow(new IOException(\"expected service error\"));\n\n    when(verificationSessionManager.update(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.method(\"PATCH\",\n        Entity.json(updateSessionJson(\"captcha\", null, \"token\", \"fcm\")))) {\n      assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());\n\n      final ArgumentCaptor<VerificationSession> verificationSessionArgumentCaptor = ArgumentCaptor.forClass(\n          VerificationSession.class);\n\n      verify(verificationSessionManager).update(verificationSessionArgumentCaptor.capture());\n\n      final VerificationSession updatedSession = verificationSessionArgumentCaptor.getValue();\n      assertTrue(updatedSession.submittedInformation().isEmpty());\n      assertEquals(List.of(VerificationSession.Information.PUSH_CHALLENGE, VerificationSession.Information.CAPTCHA),\n          updatedSession.requestedInformation());\n      assertNotNull(updatedSession.pushChallenge());\n    }\n  }\n\n  @Test\n  void getSessionMalformedId() {\n    final String invalidSessionId = \"()()()\";\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + invalidSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_UNPROCESSABLE_ENTITY, response.getStatus());\n    }\n  }\n\n  @Test\n  void getSessionInvalidArgs() {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new StatusRuntimeException(Status.INVALID_ARGUMENT)));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodeSessionId(SESSION_ID))\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_BAD_REQUEST, response.getStatus());\n    }\n  }\n\n  @Test\n  void getSessionNotFound() {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n    when(verificationSessionManager.findForId(encodeSessionId(SESSION_ID)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodeSessionId(SESSION_ID))\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());\n    }\n\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n\n    request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodeSessionId(SESSION_ID))\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());\n    }\n  }\n\n  @Test\n  void getSessionError() {\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodeSessionId(SESSION_ID))\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_SERVICE_UNAVAILABLE, response.getStatus());\n    }\n  }\n\n  @Test\n  void getSessionSuccess() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                new RegistrationServiceSession(SESSION_ID, NUMBER, false, null, null, null,\n                    SESSION_EXPIRATION_SECONDS))));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(VerificationSession.class))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n    }\n  }\n\n  @Test\n  void getSessionSuccessAlreadyVerified() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        true, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(mock(VerificationSession.class))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId)\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.get()) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      verify(registrationRecoveryPasswordsManager).remove(PNI);\n    }\n  }\n\n  @Test\n  void requestVerificationCodeAlreadyVerified() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        true, null, null,\n        null, SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(registrationServiceSession));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"sms\", \"android\")))) {\n      assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.verified());\n    }\n  }\n\n  @Test\n  void requestVerificationCodeNotAllowedInformationRequested() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(new VerificationSession(encodedSessionId, null, null,\n            List.of(VerificationSession.Information.CAPTCHA), Collections.emptyList(), null, null, false, clock.millis(), clock.millis(),\n            registrationServiceSession.expiration()))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"sms\", \"ios\")))) {\n      assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertEquals(List.of(VerificationSession.Information.CAPTCHA),\n          verificationSessionResponse.requestedInformation());\n    }\n  }\n\n  @Test\n  void requestVerificationCodeNotAllowed() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, null,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(\n                registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, false,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"voice\", \"android\")))) {\n      assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertFalse(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void requestVerificationCodeRateLimitExceeded() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null,\n        null, SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(\n            new CompletionException(new VerificationSessionRateLimitExceededException(registrationServiceSession,\n                Duration.ofMinutes(1), true))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"sms\", \"android\")))) {\n      assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void requestVerificationCodeTransportNotAllowed() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null,\n        null, SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(\n            new CompletionException(new TransportNotAllowedException(registrationServiceSession))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n\n    try (final Response response = request.post(Entity.json(requestVerificationCodeJson(\"sms\", \"android\")))) {\n      assertEquals(418, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse =\n          response.readEntity(VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void requestVerificationCodeSuccess() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null,\n        null, SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(registrationServiceSession));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"sms\", \"android\")))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void requestVerificationCodeExternalServiceRefused(final boolean expectedPermanent, final String expectedReason,\n      final RegistrationServiceSenderException exception) {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))\n        .thenReturn(\n            CompletableFuture.failedFuture(new CompletionException(exception)));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"voice\", \"ios\")))) {\n      assertEquals(RegistrationServiceSenderExceptionMapper.REMOTE_SERVICE_REJECTED_REQUEST_STATUS, response.getStatus());\n\n      final Map<String, Object> responseMap = response.readEntity(Map.class);\n\n      assertEquals(expectedReason, responseMap.get(\"reason\"));\n      assertEquals(expectedPermanent, responseMap.get(\"permanentFailure\"));\n    }\n  }\n\n  static Stream<Arguments> requestVerificationCodeExternalServiceRefused() {\n    return Stream.of(\n        Arguments.of(true, \"illegalArgument\", RegistrationServiceSenderException.illegalArgument(true)),\n        Arguments.of(true, \"providerRejected\", RegistrationServiceSenderException.rejected(true)),\n        Arguments.of(false, \"providerUnavailable\", RegistrationServiceSenderException.unknown(false))\n    );\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {false, true})\n  void fraudError(boolean shadowFailure) {\n    if (shadowFailure) {\n      when(this.dynamicConfiguration.getRegistrationConfiguration())\n          .thenReturn(new DynamicRegistrationConfiguration(true));\n    }\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(registrationServiceClient.sendVerificationCode(any(), any(), any(), any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new CompletionException(\n            new RegistrationFraudException(RegistrationServiceSenderException.rejected(true)))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.post(Entity.json(requestVerificationCodeJson(\"voice\", \"ios\")))) {\n      if (shadowFailure) {\n        assertEquals(200, response.getStatus());\n      } else {\n        assertEquals(RegistrationServiceSenderExceptionMapper.REMOTE_SERVICE_REJECTED_REQUEST_STATUS, response.getStatus());\n        final Map<String, Object> responseMap = response.readEntity(Map.class);\n        assertEquals(\"providerRejected\", responseMap.get(\"reason\"));\n      }\n    }\n  }\n\n\n  @Test\n  void verifyCodeServerError() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(registrationServiceClient.checkVerificationCode(any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new CompletionException(new RuntimeException())));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.put(Entity.json(submitVerificationCodeJson(\"123456\")))) {\n      assertEquals(HttpStatus.SC_INTERNAL_SERVER_ERROR, response.getStatus());\n    }\n  }\n\n  @Test\n  void verifyCodeAlreadyVerified() {\n\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        true, null, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.put(\n        Entity.json(submitVerificationCodeJson(\"123456\")))) {\n\n      verify(registrationServiceClient).getSession(any(), any());\n      verifyNoMoreInteractions(registrationServiceClient);\n\n      assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n      assertTrue(verificationSessionResponse.verified());\n\n      verify(registrationRecoveryPasswordsManager).remove(PNI);\n    }\n  }\n\n  @Test\n  void verifyCodeNoCodeRequested() {\n\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, 0L, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    // There is no explicit indication in the exception that no code has been sent, but we treat all RegistrationServiceExceptions\n    // in which the response has a session object as conflicted state\n    when(registrationServiceClient.checkVerificationCode(any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new CompletionException(\n            new RegistrationServiceException(new RegistrationServiceSession(SESSION_ID, NUMBER, false, 0L, null, null,\n                SESSION_EXPIRATION_SECONDS)))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.put(Entity.json(submitVerificationCodeJson(\"123456\")))) {\n      assertEquals(HttpStatus.SC_CONFLICT, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertNotNull(verificationSessionResponse.nextSms());\n      assertNull(verificationSessionResponse.nextVerificationAttempt());\n    }\n  }\n\n  @Test\n  void verifyCodeNoSession() {\n\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n\n    when(registrationServiceClient.checkVerificationCode(any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new CompletionException(new RegistrationServiceException(null))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.put(Entity.json(submitVerificationCodeJson(\"123456\")))) {\n      assertEquals(HttpStatus.SC_NOT_FOUND, response.getStatus());\n    }\n  }\n\n  @Test\n  void verifyCodeRateLimitExceeded() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(registrationServiceClient.checkVerificationCode(any(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(\n            new CompletionException(new VerificationSessionRateLimitExceededException(registrationServiceSession,\n                Duration.ofMinutes(1), true))));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.put(Entity.json(submitVerificationCodeJson(\"567890\")))) {\n      assertEquals(HttpStatus.SC_TOO_MANY_REQUESTS, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.allowedToRequestCode());\n      assertTrue(verificationSessionResponse.requestedInformation().isEmpty());\n    }\n  }\n\n  @Test\n  void verifyCodeSuccess() {\n    final String encodedSessionId = encodeSessionId(SESSION_ID);\n    final RegistrationServiceSession registrationServiceSession = new RegistrationServiceSession(SESSION_ID, NUMBER,\n        false, null, null, 0L, SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.getSession(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(registrationServiceSession)));\n    when(verificationSessionManager.findForId(any()))\n        .thenReturn(CompletableFuture.completedFuture(\n            Optional.of(new VerificationSession(encodedSessionId, null, null, Collections.emptyList(), Collections.emptyList(), null, null, true,\n                clock.millis(), clock.millis(), registrationServiceSession.expiration()))));\n    when(registrationRecoveryPasswordsManager.remove(any()))\n        .thenReturn(CompletableFuture.completedFuture(true));\n\n    final RegistrationServiceSession verifiedSession = new RegistrationServiceSession(SESSION_ID, NUMBER, true, null,\n        null, 0L,\n        SESSION_EXPIRATION_SECONDS);\n    when(registrationServiceClient.checkVerificationCode(any(), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(verifiedSession));\n\n    final Invocation.Builder request = resources.getJerseyTest()\n        .target(\"/v1/verification/session/\" + encodedSessionId + \"/code\")\n        .request()\n        .header(HttpHeaders.X_FORWARDED_FOR, \"127.0.0.1\");\n    try (Response response = request.put(Entity.json(submitVerificationCodeJson(\"123456\")))) {\n      assertEquals(HttpStatus.SC_OK, response.getStatus());\n\n      final VerificationSessionResponse verificationSessionResponse = response.readEntity(\n          VerificationSessionResponse.class);\n\n      assertTrue(verificationSessionResponse.verified());\n\n      verify(registrationRecoveryPasswordsManager).remove(PNI);\n    }\n  }\n\n  /**\n   * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest}\n   */\n  private static String createSessionJson(final String number, final String pushToken,\n      final String pushTokenType) {\n    return String.format(\"\"\"\n        {\n          \"number\": %s,\n          \"pushToken\": %s,\n          \"pushTokenType\": %s\n        }\n        \"\"\", quoteIfNotNull(number), quoteIfNotNull(pushToken), quoteIfNotNull(pushTokenType));\n  }\n\n  /**\n   * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.UpdateVerificationSessionRequest}\n   */\n  private static String updateSessionJson(final String captcha, final String pushChallenge, final String pushToken,\n      final String pushTokenType) {\n    return String.format(\"\"\"\n            {\n              \"captcha\": %s,\n              \"pushChallenge\": %s,\n              \"pushToken\": %s,\n              \"pushTokenType\": %s\n            }\n            \"\"\", quoteIfNotNull(captcha), quoteIfNotNull(pushChallenge), quoteIfNotNull(pushToken),\n        quoteIfNotNull(pushTokenType));\n  }\n\n  /**\n   * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.VerificationCodeRequest}\n   */\n  private static String requestVerificationCodeJson(final String transport, final String client) {\n    return String.format(\"\"\"\n             {\n               \"transport\": \"%s\",\n               \"client\": \"%s\"\n             }\n        \"\"\", transport, client);\n  }\n\n  /**\n   * Request JSON in the shape of {@link org.whispersystems.textsecuregcm.entities.SubmitVerificationCodeRequest}\n   */\n  private static String submitVerificationCodeJson(final String code) {\n    return String.format(\"\"\"\n        {\n          \"code\": \"%s\"\n        }\n        \"\"\", code);\n  }\n\n  private static String quoteIfNotNull(final String s) {\n    return s == null ? null : StringUtils.join(new String[]{\"\\\"\", \"\\\"\"}, s);\n  }\n\n  /**\n   * Request JSON that cannot be marshalled into\n   * {@link org.whispersystems.textsecuregcm.entities.CreateVerificationSessionRequest}\n   */\n  private static String unprocessableCreateSessionJson(final String number, final String pushToken,\n      final String pushTokenType) {\n    return String.format(\"\"\"\n        {\n          \"number\": %s,\n          \"pushToken\": %s,\n          \"pushTokenType\": %s\n        }\n        \"\"\", number, quoteIfNotNull(pushToken), quoteIfNotNull(pushTokenType));\n  }\n\n  private static String encodeSessionId(final byte[] sessionId) {\n    return Base64.getUrlEncoder().encodeToString(sessionId);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/currency/CoinGeckoClientTest.java",
    "content": "package org.whispersystems.textsecuregcm.currency;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.util.Map;\nimport org.junit.jupiter.api.Test;\n\nclass CoinGeckoClientTest {\n\n  private static final String RESPONSE_JSON = \"\"\"\n          {\n            \"mobilecoin\": {\n              \"usd\": 0.226212\n            }\n          }\n      \"\"\";\n\n  @Test\n  void parseResponse() throws JsonProcessingException {\n    final Map<String, Map<String, BigDecimal>> parsedResponse = CoinGeckoClient.parseResponse(RESPONSE_JSON);\n\n    assertTrue(parsedResponse.containsKey(\"mobilecoin\"));\n\n    assertEquals(1, parsedResponse.get(\"mobilecoin\").size());\n    assertEquals(new BigDecimal(\"0.226212\"), parsedResponse.get(\"mobilecoin\").get(\"usd\"));\n  }\n\n  @Test\n  void extractConversionRate() throws IOException {\n    final Map<String, Map<String, BigDecimal>> parsedResponse = CoinGeckoClient.parseResponse(RESPONSE_JSON);\n\n    assertEquals(new BigDecimal(\"0.226212\"), CoinGeckoClient.extractConversionRate(parsedResponse.get(\"mobilecoin\"), \"usd\"));\n    assertThrows(IOException.class, () -> CoinGeckoClient.extractConversionRate(parsedResponse.get(\"mobilecoin\"), \"CAD\"));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/currency/CurrencyConversionManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.currency;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\n\nclass CurrencyConversionManagerTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();\n\n  @Test\n  void testCurrencyCalculations() throws IOException {\n    FixerClient fixerClient = mock(FixerClient.class);\n    CoinGeckoClient coinGeckoClient   = mock(CoinGeckoClient.class);\n\n    when(coinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"2.35\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"0.822876\"),\n        \"FJD\", new BigDecimal(\"2.0577\"),\n        \"FKP\", new BigDecimal(\"0.743446\")\n    ));\n\n    CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, coinGeckoClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        List.of(\"FOO\"), EXECUTOR, Clock.systemUTC());\n\n    manager.update();\n\n    CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(1);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"2.35\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"EUR\")).isEqualTo(new BigDecimal(\"1.9337586\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FJD\")).isEqualTo(new BigDecimal(\"4.835595\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FKP\")).isEqualTo(new BigDecimal(\"1.7470981\"));\n  }\n\n  @Test\n  void testCurrencyCalculations_noTrailingZeros() throws IOException {\n    FixerClient fixerClient = mock(FixerClient.class);\n    CoinGeckoClient   CoinGeckoClient   = mock(CoinGeckoClient.class);\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"1.00000\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"0.200000\"),\n        \"FJD\", new BigDecimal(\"3.00000\"),\n        \"FKP\", new BigDecimal(\"50.0000\"),\n        \"CAD\", new BigDecimal(\"700.000\")\n    ));\n\n    CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, CoinGeckoClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        List.of(\"FOO\"), EXECUTOR, Clock.systemUTC());\n\n    manager.update();\n\n    CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(1);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(5);\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"1\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"EUR\")).isEqualTo(new BigDecimal(\"0.2\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FJD\")).isEqualTo(new BigDecimal(\"3\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FKP\")).isEqualTo(new BigDecimal(\"50\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"CAD\")).isEqualTo(new BigDecimal(\"700\"));\n  }\n\n  @Test\n  void testCurrencyCalculations_accuracy() throws IOException {\n    FixerClient fixerClient = mock(FixerClient.class);\n    CoinGeckoClient   CoinGeckoClient   = mock(CoinGeckoClient.class);\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"0.999999\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"1.000001\"),\n        \"FJD\", new BigDecimal(\"0.000001\"),\n        \"FKP\", new BigDecimal(\"1\")\n    ));\n\n    CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, CoinGeckoClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        List.of(\"FOO\"), EXECUTOR, Clock.systemUTC());\n\n    manager.update();\n\n    CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(1);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"0.999999\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"EUR\")).isEqualTo(new BigDecimal(\"0.999999999999\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FJD\")).isEqualTo(new BigDecimal(\"0.000000999999\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FKP\")).isEqualTo(new BigDecimal(\"0.999999\"));\n\n  }\n\n  @Test\n  void testCurrencyCalculationsTimeoutNoRun() throws IOException {\n    FixerClient fixerClient = mock(FixerClient.class);\n    CoinGeckoClient   CoinGeckoClient   = mock(CoinGeckoClient.class);\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"2.35\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"0.822876\"),\n        \"FJD\", new BigDecimal(\"2.0577\"),\n        \"FKP\", new BigDecimal(\"0.743446\")\n    ));\n\n    CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, CoinGeckoClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        List.of(\"FOO\"), EXECUTOR, Clock.systemUTC());\n\n    manager.update();\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"3.50\"));\n\n    manager.update();\n\n    CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(1);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"2.35\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"EUR\")).isEqualTo(new BigDecimal(\"1.9337586\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FJD\")).isEqualTo(new BigDecimal(\"4.835595\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FKP\")).isEqualTo(new BigDecimal(\"1.7470981\"));\n  }\n\n  @Test\n  void testCurrencyCalculationsCoinGeckoTimeoutWithRun() throws IOException {\n    FixerClient fixerClient = mock(FixerClient.class);\n    CoinGeckoClient   CoinGeckoClient   = mock(CoinGeckoClient.class);\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"2.35\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"0.822876\"),\n        \"FJD\", new BigDecimal(\"2.0577\"),\n        \"FKP\", new BigDecimal(\"0.743446\")\n    ));\n\n    CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, CoinGeckoClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        List.of(\"FOO\"), EXECUTOR, Clock.systemUTC());\n\n    manager.update();\n\n    REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection ->\n        connection.sync().del(CurrencyConversionManager.COIN_GECKO_SHARED_CACHE_CURRENT_KEY));\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"3.50\"));\n    manager.update();\n\n    CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(1);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"3.5\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"EUR\")).isEqualTo(new BigDecimal(\"2.880066\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FJD\")).isEqualTo(new BigDecimal(\"7.20195\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FKP\")).isEqualTo(new BigDecimal(\"2.602061\"));\n  }\n\n\n  @Test\n  void testCurrencyCalculationsFixerTimeoutWithRun() throws IOException {\n    FixerClient fixerClient = mock(FixerClient.class);\n    CoinGeckoClient   CoinGeckoClient   = mock(CoinGeckoClient.class);\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"2.35\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"0.822876\"),\n        \"FJD\", new BigDecimal(\"2.0577\"),\n        \"FKP\", new BigDecimal(\"0.743446\")\n    ));\n\n    final Instant currentTime = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n\n    final Clock clock = mock(Clock.class);\n    when(clock.instant()).thenReturn(currentTime);\n    when(clock.millis()).thenReturn(currentTime.toEpochMilli());\n\n    CurrencyConversionManager manager = new CurrencyConversionManager(fixerClient, CoinGeckoClient, REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        List.of(\"FOO\"), EXECUTOR, clock);\n\n    manager.update();\n\n    when(CoinGeckoClient.getSpotPrice(eq(\"FOO\"), eq(\"USD\"))).thenReturn(new BigDecimal(\"3.50\"));\n    when(fixerClient.getConversionsForBase(eq(\"USD\"))).thenReturn(Map.of(\n        \"EUR\", new BigDecimal(\"0.922876\"),\n        \"FJD\", new BigDecimal(\"2.0577\"),\n        \"FKP\", new BigDecimal(\"0.743446\")\n    ));\n\n    REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection ->\n        connection.sync().del(CurrencyConversionManager.FIXER_SHARED_CACHE_CURRENT_KEY));\n\n\n    manager.update();\n\n    CurrencyConversionEntityList conversions = manager.getCurrencyConversions().orElseThrow();\n\n    assertThat(conversions.getCurrencies().size()).isEqualTo(1);\n    assertThat(conversions.getCurrencies().get(0).getBase()).isEqualTo(\"FOO\");\n    assertThat(conversions.getCurrencies().get(0).getConversions().size()).isEqualTo(4);\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"USD\")).isEqualTo(new BigDecimal(\"2.35\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"EUR\")).isEqualTo(new BigDecimal(\"2.1687586\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FJD\")).isEqualTo(new BigDecimal(\"4.835595\"));\n    assertThat(conversions.getCurrencies().get(0).getConversions().get(\"FKP\")).isEqualTo(new BigDecimal(\"1.7470981\"));\n  }\n\n  @Test\n  void convertToUsd() {\n    final CurrencyConversionManager currencyConversionManager = new CurrencyConversionManager(mock(FixerClient.class),\n        mock(CoinGeckoClient.class),\n        mock(FaultTolerantRedisClusterClient.class),\n        Collections.emptyList(),\n        EXECUTOR,\n        Clock.systemUTC());\n\n    currencyConversionManager.setCachedFixerValues(Map.of(\"JPY\", BigDecimal.valueOf(154.757008), \"GBP\", BigDecimal.valueOf(0.81196)));\n\n    assertEquals(Optional.of(new BigDecimal(\"17.50\")),\n        currencyConversionManager.convertToUsd(new BigDecimal(\"17.50\"), \"USD\"));\n\n    assertEquals(Optional.of(new BigDecimal(\"17.50\")),\n        currencyConversionManager.convertToUsd(new BigDecimal(\"17.50\"), \"usd\"));\n\n    assertEquals(Optional.empty(),\n        currencyConversionManager.convertToUsd(new BigDecimal(\"10.00\"), \"XYZ\"));\n\n    assertEquals(Optional.of(new BigDecimal(\"12.92\")),\n        currencyConversionManager.convertToUsd(new BigDecimal(\"2000\"), \"JPY\"));\n\n    assertEquals(Optional.of(new BigDecimal(\"12.32\")),\n        currencyConversionManager.convertToUsd(new BigDecimal(\"10\"), \"GBP\"));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/currency/FixerClientTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.currency;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture;\n\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.net.http.HttpResponse.BodyHandler;\nimport java.util.Map;\nimport org.junit.jupiter.api.Test;\n\npublic class FixerClientTest {\n\n  @Test\n  public void testGetConversionsForBase() throws IOException, InterruptedException {\n    HttpResponse<String> httpResponse = mock(HttpResponse.class);\n    when(httpResponse.statusCode()).thenReturn(200);\n    when(httpResponse.body()).thenReturn(jsonFixture(\"fixtures/fixer.res.json\"));\n\n    HttpClient httpClient = mock(HttpClient.class);\n    when(httpClient.send(any(HttpRequest.class), any(BodyHandler.class))).thenReturn(httpResponse);\n\n    FixerClient fixerClient = new FixerClient(httpClient, \"foo\");\n    Map<String, BigDecimal> conversions = fixerClient.getConversionsForBase(\"EUR\");\n    assertThat(conversions.get(\"CAD\")).isEqualTo(new BigDecimal(\"1.560132\"));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/entities/AnswerChallengeRequestTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.exc.InvalidTypeIdException;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\nclass AnswerChallengeRequestTest {\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"captcha\"})\n  void parse(final String type) throws JsonProcessingException {\n    {\n      final String pushChallengeJson = \"\"\"\n          {\n            \"type\": \"rateLimitPushChallenge\",\n            \"challenge\": \"Hello I am a push challenge token\"\n          }\n          \"\"\";\n\n      final AnswerChallengeRequest answerChallengeRequest =\n          SystemMapper.jsonMapper().readValue(pushChallengeJson, AnswerChallengeRequest.class);\n\n      assertTrue(answerChallengeRequest instanceof AnswerPushChallengeRequest);\n      assertEquals(\"Hello I am a push challenge token\",\n          ((AnswerPushChallengeRequest) answerChallengeRequest).getChallenge());\n    }\n\n    {\n      final String captchaChallengeJson = \"\"\"\n          {\n            \"type\": \"%s\",\n            \"token\": \"A server-generated token\",\n            \"captcha\": \"The value of the solved captcha token\"\n          }\n          \"\"\".formatted(type);\n\n      final AnswerChallengeRequest answerChallengeRequest =\n          SystemMapper.jsonMapper().readValue(captchaChallengeJson, AnswerChallengeRequest.class);\n\n      assertTrue(answerChallengeRequest instanceof AnswerCaptchaChallengeRequest);\n\n      assertEquals(\"A server-generated token\",\n          ((AnswerCaptchaChallengeRequest) answerChallengeRequest).getToken());\n\n      assertEquals(\"The value of the solved captcha token\",\n          ((AnswerCaptchaChallengeRequest) answerChallengeRequest).getCaptcha());\n    }\n\n    {\n      final String unrecognizedTypeJson = \"\"\"\n          {\n            \"type\": \"unrecognized\",\n            \"token\": \"A server-generated token\",\n            \"captcha\": \"The value of the solved captcha token\"\n          }\n          \"\"\";\n\n      assertThrows(InvalidTypeIdException.class,\n          () -> SystemMapper.jsonMapper().readValue(unrecognizedTypeJson, AnswerChallengeRequest.class));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/entities/IncomingMessageListTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\nclass IncomingMessageListTest {\n\n  @Test\n  void fromJson() throws JsonProcessingException {\n    {\n      final String incomingMessageListJson = \"\"\"\n          {\n            \"messages\": [],\n            \"timestamp\": 123456789,\n            \"online\": true,\n            \"urgent\": false\n          }\n          \"\"\";\n\n      final IncomingMessageList incomingMessageList =\n          SystemMapper.jsonMapper().readValue(incomingMessageListJson, IncomingMessageList.class);\n\n      assertTrue(incomingMessageList.online());\n      assertFalse(incomingMessageList.urgent());\n    }\n\n    {\n      final String incomingMessageListJson = \"\"\"\n          {\n            \"messages\": [],\n            \"timestamp\": 123456789,\n            \"online\": true\n          }\n          \"\"\";\n\n      final IncomingMessageList incomingMessageList =\n          SystemMapper.jsonMapper().readValue(incomingMessageListJson, IncomingMessageList.class);\n\n      assertTrue(incomingMessageList.online());\n      assertTrue(incomingMessageList.urgent());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/entities/OutgoingMessageEntityTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.time.Clock;\nimport java.util.UUID;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.junitpioneer.jupiter.cartesian.ArgumentSets;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass OutgoingMessageEntityTest {\n\n  @CartesianTest\n  @CartesianTest.MethodFactory(\"roundTripThroughEnvelope\")\n  void roundTripThroughEnvelope(@Nullable final ServiceIdentifier sourceIdentifier,\n      final ServiceIdentifier destinationIdentifier,\n      @Nullable final UUID updatedPni) {\n\n    final byte[] messageContent = TestRandomUtil.nextBytes(16);\n\n    final long messageTimestamp = System.currentTimeMillis();\n    final long serverTimestamp = messageTimestamp + 17;\n\n    byte[] reportSpamToken = {1, 2, 3, 4, 5};\n\n    final OutgoingMessageEntity outgoingMessageEntity = new OutgoingMessageEntity(\n        UUID.randomUUID(),\n        MessageProtos.Envelope.Type.CIPHERTEXT_VALUE,\n        messageTimestamp,\n        sourceIdentifier,\n        sourceIdentifier != null ? (int) Device.PRIMARY_ID : 0,\n        destinationIdentifier,\n        updatedPni,\n        messageContent,\n        serverTimestamp,\n        true,\n        false,\n        reportSpamToken);\n\n    assertEquals(outgoingMessageEntity, OutgoingMessageEntity.fromEnvelope(outgoingMessageEntity.toEnvelope()));\n  }\n\n  @SuppressWarnings(\"unused\")\n  static ArgumentSets roundTripThroughEnvelope() {\n    return ArgumentSets.argumentsForFirstParameter(new AciServiceIdentifier(UUID.randomUUID()),\n            new PniServiceIdentifier(UUID.randomUUID()),\n            null)\n        .argumentsForNextParameter(new AciServiceIdentifier(UUID.randomUUID()),\n            new PniServiceIdentifier(UUID.randomUUID()))\n        .argumentsForNextParameter(UUID.randomUUID(), null);\n  }\n\n  @Test\n  void entityPreservesEnvelope() {\n    final byte[] reportSpamToken = TestRandomUtil.nextBytes(8);\n    final AciServiceIdentifier sourceServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    final IncomingMessage message = new IncomingMessage(1, (byte) 44, 55, TestRandomUtil.nextBytes(4));\n\n    MessageProtos.Envelope baseEnvelope = message.toEnvelope(\n        new AciServiceIdentifier(UUID.randomUUID()),\n        sourceServiceIdentifier,\n        (byte) 123,\n        System.currentTimeMillis(),\n        false,\n        false,\n        true,\n        reportSpamToken,\n        Clock.systemUTC());\n\n    MessageProtos.Envelope envelope = baseEnvelope.toBuilder().setServerGuid(UUID.randomUUID().toString()).build();\n\n    // Note that outgoing message entities don't have an \"ephemeral\"/\"online\" flag\n    assertEquals(envelope.toBuilder().clearEphemeral().build(),\n        OutgoingMessageEntity.fromEnvelope(envelope).toEnvelope());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/entities/PreKeyTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.entities;\n\nimport static org.hamcrest.CoreMatchers.equalTo;\nimport static org.hamcrest.CoreMatchers.is;\nimport static org.hamcrest.MatcherAssert.assertThat;\nimport static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.asJson;\nimport static org.whispersystems.textsecuregcm.tests.util.JsonHelpers.jsonFixture;\n\nimport java.util.Base64;\nimport org.junit.jupiter.api.Test;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\n\nclass PreKeyTest {\n\n  private static final byte[] PUBLIC_KEY = Base64.getDecoder().decode(\"BQ+NbroQtVKyFaCSfqzSw8Wy72Ff22RSa5ERKTv5DIk2\");\n\n  @Test\n  void serializeToJSONV2() throws Exception {\n    ECPreKey preKey = new ECPreKey(1234, new ECPublicKey(PUBLIC_KEY));\n\n    assertThat(\"PreKeyV2 Serialization works\",\n               asJson(preKey),\n               is(equalTo(jsonFixture(\"fixtures/prekey_v2.json\"))));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentEnrollmentManagerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.experiment;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.spy;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Collections;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Random;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicE164ExperimentEnrollmentConfiguration;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\nclass ExperimentEnrollmentManagerTest {\n\n  private DynamicExperimentEnrollmentConfiguration.UuidSelector uuidSelector;\n  private DynamicExperimentEnrollmentConfiguration experimentEnrollmentConfiguration;\n  private DynamicE164ExperimentEnrollmentConfiguration e164ExperimentEnrollmentConfiguration;\n\n  private ExperimentEnrollmentManager experimentEnrollmentManager;\n\n  private Account account;\n  private Random random;\n\n  private static final UUID ACCOUNT_UUID = UUID.randomUUID();\n  private static final UUID EXCLUDED_UUID = UUID.randomUUID();\n  private static final String UUID_EXPERIMENT_NAME = \"uuid_test\";\n  private static final String E164_AND_UUID_EXPERIMENT_NAME = \"e164_uuid_test\";\n\n  private static final String NOT_ENROLLED_164 = \"+632025551212\";\n  private static final String ENROLLED_164 = \"+12025551212\";\n  private static final String EXCLUDED_164 = \"+18005551212\";\n  private static final String E164_EXPERIMENT_NAME = \"e164_test\";\n\n  @BeforeEach\n  void setUp() {\n    @SuppressWarnings(\"unchecked\")\n    final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    random = spy(new Random());\n    experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager, () -> random);\n\n    uuidSelector = mock(DynamicExperimentEnrollmentConfiguration.UuidSelector.class);\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100);\n\n    experimentEnrollmentConfiguration = mock(DynamicExperimentEnrollmentConfiguration.class);\n    when(experimentEnrollmentConfiguration.getUuidSelector()).thenReturn(uuidSelector);\n    e164ExperimentEnrollmentConfiguration = mock(\n        DynamicE164ExperimentEnrollmentConfiguration.class);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getExperimentEnrollmentConfiguration(UUID_EXPERIMENT_NAME))\n        .thenReturn(Optional.of(experimentEnrollmentConfiguration));\n    when(dynamicConfiguration.getE164ExperimentEnrollmentConfiguration(E164_EXPERIMENT_NAME))\n        .thenReturn(Optional.of(e164ExperimentEnrollmentConfiguration));\n    when(dynamicConfiguration.getExperimentEnrollmentConfiguration(E164_AND_UUID_EXPERIMENT_NAME))\n        .thenReturn(Optional.of(experimentEnrollmentConfiguration));\n    when(dynamicConfiguration.getE164ExperimentEnrollmentConfiguration(E164_AND_UUID_EXPERIMENT_NAME))\n        .thenReturn(Optional.of(e164ExperimentEnrollmentConfiguration));\n\n    account = mock(Account.class);\n    when(account.getUuid()).thenReturn(ACCOUNT_UUID);\n  }\n\n  @Test\n  void testIsEnrolled_UuidExperiment() {\n    assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME));\n    assertFalse(\n        experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME + \"-unrelated-experiment\"));\n\n    when(uuidSelector.getUuids()).thenReturn(Set.of(ACCOUNT_UUID));\n    assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME));\n\n    when(uuidSelector.getUuids()).thenReturn(Collections.emptySet());\n    when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(0);\n\n    assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME));\n\n    when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(100);\n    assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME));\n\n    when(experimentEnrollmentConfiguration.getExcludedUuids()).thenReturn(Set.of(EXCLUDED_UUID));\n    when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(100);\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100);\n    when(uuidSelector.getUuids()).thenReturn(Set.of(EXCLUDED_UUID));\n    assertFalse(experimentEnrollmentManager.isEnrolled(EXCLUDED_UUID, UUID_EXPERIMENT_NAME));\n  }\n\n  @Test\n  void testIsEnrolled_UuidExperimentPercentage() {\n    when(uuidSelector.getUuids()).thenReturn(Set.of(ACCOUNT_UUID));\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(0);\n    assertFalse(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME));\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100);\n    assertTrue(experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME));\n\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(75);\n    final Map<Boolean, Long> counts = IntStream.range(0, 100).mapToObj(i -> {\n          when(random.nextInt(100)).thenReturn(i);\n          return experimentEnrollmentManager.isEnrolled(account.getUuid(), UUID_EXPERIMENT_NAME);\n        })\n        .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));\n    assertEquals(25, counts.get(false));\n    assertEquals(75, counts.get(true));\n  }\n\n  @Test\n  void testIsEnrolled_E164AndUuidExperiment() {\n    when(e164ExperimentEnrollmentConfiguration.getIncludedCountryCodes()).thenReturn(Set.of(\"1\"));\n    when(e164ExperimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(0);\n    when(e164ExperimentEnrollmentConfiguration.getEnrolledE164s()).thenReturn(Collections.emptySet());\n    when(e164ExperimentEnrollmentConfiguration.getExcludedE164s()).thenReturn(Collections.emptySet());\n    when(e164ExperimentEnrollmentConfiguration.getExcludedCountryCodes()).thenReturn(Collections.emptySet());\n\n    // test UUID enrollment is prioritized\n    when(uuidSelector.getUuids()).thenReturn(Set.of(ACCOUNT_UUID));\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100);\n    assertTrue(experimentEnrollmentManager.isEnrolled(NOT_ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(0);\n    assertFalse(experimentEnrollmentManager.isEnrolled(NOT_ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n    assertFalse(experimentEnrollmentManager.isEnrolled(ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n\n    // test fallback from UUID enrollment to general enrollment percentage\n    when(uuidSelector.getUuids()).thenReturn(Collections.emptySet());\n    when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(100);\n    assertTrue(experimentEnrollmentManager.isEnrolled(NOT_ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n    assertTrue(experimentEnrollmentManager.isEnrolled(ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n\n    // test fallback from UUID/general enrollment to e164 enrollment\n    when(experimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(0);\n    assertTrue(experimentEnrollmentManager.isEnrolled(ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n    assertFalse(experimentEnrollmentManager.isEnrolled(NOT_ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n    when(e164ExperimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(100);\n    assertTrue(experimentEnrollmentManager.isEnrolled(ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n    assertTrue(experimentEnrollmentManager.isEnrolled(NOT_ENROLLED_164, account.getUuid(), E164_AND_UUID_EXPERIMENT_NAME));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testIsEnrolled_E164Experiment(final String e164, final String experimentName,\n      final Set<String> enrolledE164s, final Set<String> excludedE164s, final Set<String> includedCountryCodes,\n      final Set<String> excludedCountryCodes,\n      final int enrollmentPercentage,\n      final boolean expectEnrolled, final String message) {\n\n    when(e164ExperimentEnrollmentConfiguration.getEnrolledE164s()).thenReturn(enrolledE164s);\n    when(e164ExperimentEnrollmentConfiguration.getExcludedE164s()).thenReturn(excludedE164s);\n    when(e164ExperimentEnrollmentConfiguration.getEnrollmentPercentage()).thenReturn(enrollmentPercentage);\n    when(e164ExperimentEnrollmentConfiguration.getIncludedCountryCodes()).thenReturn(includedCountryCodes);\n    when(e164ExperimentEnrollmentConfiguration.getExcludedCountryCodes()).thenReturn(excludedCountryCodes);\n\n    assertEquals(expectEnrolled, experimentEnrollmentManager.isEnrolled(e164, experimentName), message);\n  }\n\n  @SuppressWarnings(\"unused\")\n  static Stream<Arguments> testIsEnrolled_E164Experiment() {\n    return Stream.of(\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(),\n            Collections.emptySet(), Collections.emptySet(), 0, false, \"default configuration expects no enrollment\"),\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME + \"-unrelated-experiment\", Collections.emptySet(),\n            Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), 0, false,\n            \"unknown experiment expects no enrollment\"),\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Set.of(ENROLLED_164), Set.of(EXCLUDED_164),\n            Collections.emptySet(), Collections.emptySet(), 0, true, \"explicitly enrolled E164 overrides 0% rollout\"),\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Set.of(ENROLLED_164), Set.of(EXCLUDED_164),\n            Collections.emptySet(), Set.of(\"1\"), 0, true, \"explicitly enrolled E164 overrides excluded country code\"),\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(), Set.of(\"1\"),\n            Collections.emptySet(), 0, true, \"included country code overrides 0% rollout\"),\n        Arguments.of(EXCLUDED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Set.of(EXCLUDED_164), Set.of(\"1\"),\n            Collections.emptySet(), 100, false, \"excluded E164 overrides 100% rollout\"),\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(),\n            Collections.emptySet(), Set.of(\"1\"), 100, false, \"excluded country code overrides 100% rollout\"),\n        Arguments.of(ENROLLED_164, E164_EXPERIMENT_NAME, Collections.emptySet(), Collections.emptySet(),\n            Collections.emptySet(), Collections.emptySet(), 100, true, \"enrollment expected for 100% rollout\")\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/experiment/ExperimentTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.experiment;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\n\nimport io.micrometer.core.instrument.MockClock;\nimport io.micrometer.core.instrument.Timer;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.RejectedExecutionException;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nclass ExperimentTest {\n\n  private Timer matchTimer;\n  private Timer errorTimer;\n\n  private Experiment experiment;\n\n  @BeforeEach\n  void setUp() {\n    matchTimer = mock(Timer.class);\n    errorTimer = mock(Timer.class);\n\n    experiment = new Experiment(\"test\", matchTimer, errorTimer, mock(Timer.class), mock(Timer.class),\n        mock(Timer.class));\n  }\n\n  @Test\n  void compareFutureResult() {\n    experiment.compareFutureResult(12, CompletableFuture.completedFuture(12));\n    verify(matchTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @Test\n  void compareFutureResultError() {\n    experiment.compareFutureResult(12, CompletableFuture.failedFuture(new RuntimeException(\"OH NO\")));\n    verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @Test\n  void compareSupplierResultMatch() {\n    experiment.compareSupplierResult(12, () -> 12);\n    verify(matchTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @Test\n  void compareSupplierResultError() {\n    experiment.compareSupplierResult(12, () -> {\n      throw new RuntimeException(\"OH NO\");\n    });\n    verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @Test\n  void compareSupplierResultAsyncMatch() throws InterruptedException {\n    final ExecutorService experimentExecutor = Executors.newSingleThreadExecutor();\n\n    experiment.compareSupplierResultAsync(12, () -> 12, experimentExecutor);\n    experimentExecutor.shutdown();\n    experimentExecutor.awaitTermination(1, TimeUnit.SECONDS);\n\n    verify(matchTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @Test\n  void compareSupplierResultAsyncError() throws InterruptedException {\n    final ExecutorService experimentExecutor = Executors.newSingleThreadExecutor();\n\n    experiment.compareSupplierResultAsync(12, () -> {\n      throw new RuntimeException(\"OH NO\");\n    }, experimentExecutor);\n    experimentExecutor.shutdown();\n    experimentExecutor.awaitTermination(1, TimeUnit.SECONDS);\n\n    verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @Test\n  void compareSupplierResultAsyncRejection() {\n    final ExecutorService executorService = mock(ExecutorService.class);\n    doThrow(new RejectedExecutionException()).when(executorService).execute(any(Runnable.class));\n\n    experiment.compareSupplierResultAsync(12, () -> 12, executorService);\n    verify(errorTimer).record(anyLong(), eq(TimeUnit.NANOSECONDS));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void testRecordResult(final Object expected, final Object actual, final Experiment experiment, final Timer expectedTimer) {\n    reset(expectedTimer);\n\n    final MockClock clock = new MockClock();\n    final Timer.Sample sample = Timer.start(clock);\n\n    final long durationNanos = 123;\n    clock.add(durationNanos, TimeUnit.NANOSECONDS);\n\n    experiment.recordResult(expected, actual, sample);\n    verify(expectedTimer).record(durationNanos, TimeUnit.NANOSECONDS);\n  }\n\n  @SuppressWarnings(\"unused\")\n  private static Stream<Arguments> testRecordResult() {\n    // Hack: parameters are set before the @Before method gets called\n    final Timer matchTimer = mock(Timer.class);\n    final Timer errorTimer = mock(Timer.class);\n    final Timer bothPresentMismatchTimer = mock(Timer.class);\n    final Timer controlNullMismatchTimer = mock(Timer.class);\n    final Timer experimentNullMismatchTimer = mock(Timer.class);\n\n    final Experiment experiment = new Experiment(\"test\", matchTimer, errorTimer, bothPresentMismatchTimer,\n        controlNullMismatchTimer, experimentNullMismatchTimer);\n\n    return Stream.of(\n        Arguments.of(12, 12, experiment, matchTimer),\n        Arguments.of(null, 12, experiment, controlNullMismatchTimer),\n        Arguments.of(12, null, experiment, experimentNullMismatchTimer),\n        Arguments.of(12, 17, experiment, bothPresentMismatchTimer),\n        Arguments.of(Optional.of(12), Optional.of(12), experiment, matchTimer),\n        Arguments.of(Optional.empty(), Optional.of(12), experiment, controlNullMismatchTimer),\n        Arguments.of(Optional.of(12), Optional.empty(), experiment, experimentNullMismatchTimer),\n        Arguments.of(Optional.of(12), Optional.of(17), experiment, bothPresentMismatchTimer)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/experiment/IdleDevicePushNotificationExperimentTest.java",
    "content": "package org.whispersystems.textsecuregcm.experiment;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nabstract class IdleDevicePushNotificationExperimentTest {\n\n  protected static final Instant CURRENT_TIME = Instant.now();\n  \n  protected abstract IdleDevicePushNotificationExperiment getExperiment();\n\n  @ParameterizedTest\n  @MethodSource\n  void hasPushToken(final Device device, final boolean expectHasPushToken) {\n    assertEquals(expectHasPushToken, getExperiment().hasPushToken(device));\n  }\n\n  private static List<Arguments> hasPushToken() {\n    final List<Arguments> arguments = new ArrayList<>();\n\n    {\n      // No token at all\n      final Device device = mock(Device.class);\n\n      arguments.add(Arguments.of(device, false));\n    }\n\n    {\n      // FCM token\n      final Device device = mock(Device.class);\n      when(device.getGcmId()).thenReturn(\"fcm-token\");\n\n      arguments.add(Arguments.of(device, true));\n    }\n\n    {\n      // APNs token\n      final Device device = mock(Device.class);\n      when(device.getApnId()).thenReturn(\"apns-token\");\n\n      arguments.add(Arguments.of(device, true));\n    }\n\n    return arguments;\n  }\n\n  @Test\n  void getState() {\n    final IdleDevicePushNotificationExperiment experiment = getExperiment();\n\n    assertEquals(DeviceLastSeenState.MISSING_DEVICE_STATE, experiment.getState(null, null));\n    assertEquals(DeviceLastSeenState.MISSING_DEVICE_STATE, experiment.getState(mock(Account.class), null));\n\n    final int registrationId = 123;\n\n    {\n      final Device apnsDevice = mock(Device.class);\n      when(apnsDevice.getApnId()).thenReturn(\"apns-token\");\n      when(apnsDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n      when(apnsDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n\n      assertEquals(\n          new DeviceLastSeenState(true, registrationId, true, CURRENT_TIME.toEpochMilli(), DeviceLastSeenState.PushTokenType.APNS),\n          experiment.getState(mock(Account.class), apnsDevice));\n    }\n\n    {\n      final Device fcmDevice = mock(Device.class);\n      when(fcmDevice.getGcmId()).thenReturn(\"fcm-token\");\n      when(fcmDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n      when(fcmDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n\n      assertEquals(\n          new DeviceLastSeenState(true, registrationId, true, CURRENT_TIME.toEpochMilli(), DeviceLastSeenState.PushTokenType.FCM),\n          experiment.getState(mock(Account.class), fcmDevice));\n    }\n\n    {\n      final Device noTokenDevice = mock(Device.class);\n      when(noTokenDevice.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n      when(noTokenDevice.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n\n      assertEquals(\n          new DeviceLastSeenState(true, registrationId, false, CURRENT_TIME.toEpochMilli(), null),\n          experiment.getState(mock(Account.class), noTokenDevice));\n    }\n  }\n  @ParameterizedTest\n  @MethodSource\n  void getPopulation(final boolean inExperimentGroup,\n      final DeviceLastSeenState.PushTokenType tokenType,\n      final IdleDevicePushNotificationExperiment.Population expectedPopulation) {\n\n    final DeviceLastSeenState state = new DeviceLastSeenState(true, 0, true, 0, tokenType);\n    final PushNotificationExperimentSample<DeviceLastSeenState> sample =\n        new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, inExperimentGroup, state, state);\n\n    assertEquals(expectedPopulation, IdleDevicePushNotificationExperiment.getPopulation(sample));\n  }\n\n  private static List<Arguments> getPopulation() {\n    return List.of(\n        Arguments.of(true, DeviceLastSeenState.PushTokenType.APNS,\n            IdleDevicePushNotificationExperiment.Population.APNS_EXPERIMENT),\n\n        Arguments.of(false, DeviceLastSeenState.PushTokenType.APNS,\n            IdleDevicePushNotificationExperiment.Population.APNS_CONTROL),\n\n        Arguments.of(true, DeviceLastSeenState.PushTokenType.FCM,\n            IdleDevicePushNotificationExperiment.Population.FCM_EXPERIMENT),\n\n        Arguments.of(false, DeviceLastSeenState.PushTokenType.FCM,\n            IdleDevicePushNotificationExperiment.Population.FCM_CONTROL)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getOutcome(final DeviceLastSeenState initialState,\n      final DeviceLastSeenState finalState,\n      final IdleDevicePushNotificationExperiment.Outcome expectedOutcome) {\n\n    final PushNotificationExperimentSample<DeviceLastSeenState> sample =\n        new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, true, initialState, finalState);\n\n    assertEquals(expectedOutcome, IdleDevicePushNotificationExperiment.getOutcome(sample));\n  }\n\n  private static List<Arguments> getOutcome() {\n    return List.of(\n        // Device no longer exists\n        Arguments.of(\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            DeviceLastSeenState.MISSING_DEVICE_STATE,\n            IdleDevicePushNotificationExperiment.Outcome.DELETED\n        ),\n\n        // Device re-registered (i.e. registration ID changed)\n        Arguments.of(\n            new DeviceLastSeenState(true, 123, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            new DeviceLastSeenState(true, 1234, true, 1, DeviceLastSeenState.PushTokenType.APNS),\n            IdleDevicePushNotificationExperiment.Outcome.DELETED\n        ),\n\n        // Device has lost push tokens\n        Arguments.of(\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            new DeviceLastSeenState(true, 0, false, 0, DeviceLastSeenState.PushTokenType.APNS),\n            IdleDevicePushNotificationExperiment.Outcome.UNINSTALLED\n        ),\n\n        // Device reactivated\n        Arguments.of(\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            new DeviceLastSeenState(true, 0, true, 1, DeviceLastSeenState.PushTokenType.APNS),\n            IdleDevicePushNotificationExperiment.Outcome.REACTIVATED\n        ),\n\n        // No change\n        Arguments.of(\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            IdleDevicePushNotificationExperiment.Outcome.UNCHANGED\n        )\n    );\n  }\n\n  @Test\n  void analyzeResults() {\n    assertDoesNotThrow(() -> getExperiment().analyzeResults(\n        Flux.just(new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, true,\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS),\n            new DeviceLastSeenState(true, 0, true, 0, DeviceLastSeenState.PushTokenType.APNS)))));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/experiment/PushNotificationExperimentSamplesTest.java",
    "content": "package org.whispersystems.textsecuregcm.experiment;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport java.time.Clock;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.stream.Collectors;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.QueryRequest;\nimport software.amazon.awssdk.services.dynamodb.model.QueryResponse;\nimport software.amazon.awssdk.services.dynamodb.model.Select;\nimport javax.annotation.Nullable;\n\nclass PushNotificationExperimentSamplesTest {\n\n  private PushNotificationExperimentSamples pushNotificationExperimentSamples;\n\n  @RegisterExtension\n  public static final DynamoDbExtension DYNAMO_DB_EXTENSION =\n      new DynamoDbExtension(DynamoDbExtensionSchema.Tables.PUSH_NOTIFICATION_EXPERIMENT_SAMPLES);\n\n  private record TestDeviceState(int bounciness) {\n  }\n\n  @BeforeEach\n  void setUp() {\n    pushNotificationExperimentSamples =\n        new PushNotificationExperimentSamples(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n            DynamoDbExtensionSchema.Tables.PUSH_NOTIFICATION_EXPERIMENT_SAMPLES.tableName(),\n            Clock.systemUTC());\n  }\n\n  @Test\n  void recordInitialState() throws JsonProcessingException {\n    final String experimentName = \"test-experiment\";\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = (byte) ThreadLocalRandom.current().nextInt(Device.MAXIMUM_DEVICE_ID);\n    final boolean inExperimentGroup = ThreadLocalRandom.current().nextBoolean();\n    final int bounciness = ThreadLocalRandom.current().nextInt();\n\n    assertTrue(pushNotificationExperimentSamples.recordInitialState(accountIdentifier,\n            deviceId,\n            experimentName,\n            inExperimentGroup,\n            new TestDeviceState(bounciness))\n        .join(),\n        \"Attempt to record an initial state should succeed for entirely new records\");\n\n    assertEquals(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, new TestDeviceState(bounciness), null),\n        getSample(accountIdentifier, deviceId, experimentName, TestDeviceState.class));\n\n    assertTrue(pushNotificationExperimentSamples.recordInitialState(accountIdentifier,\n                deviceId,\n                experimentName,\n                inExperimentGroup,\n                new TestDeviceState(bounciness))\n            .join(),\n        \"Attempt to re-record an initial state should succeed for existing-but-unchanged records\");\n\n    assertEquals(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, new TestDeviceState(bounciness), null),\n        getSample(accountIdentifier, deviceId, experimentName, TestDeviceState.class),\n        \"Recorded initial state should be unchanged after repeated write\");\n\n    assertFalse(pushNotificationExperimentSamples.recordInitialState(accountIdentifier,\n            deviceId,\n            experimentName,\n            inExperimentGroup,\n            new TestDeviceState(bounciness + 1))\n        .join(),\n        \"Attempt to record a conflicting initial state should fail\");\n\n    assertEquals(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, new TestDeviceState(bounciness), null),\n        getSample(accountIdentifier, deviceId, experimentName, TestDeviceState.class),\n        \"Recorded initial state should be unchanged after unsuccessful write\");\n\n    assertFalse(pushNotificationExperimentSamples.recordInitialState(accountIdentifier,\n            deviceId,\n            experimentName,\n            !inExperimentGroup,\n            new TestDeviceState(bounciness))\n        .join(),\n        \"Attempt to record a new group assignment should fail\");\n\n    assertEquals(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, new TestDeviceState(bounciness), null),\n        getSample(accountIdentifier, deviceId, experimentName, TestDeviceState.class),\n        \"Recorded initial state should be unchanged after unsuccessful write\");\n\n    final int finalBounciness = bounciness + 17;\n\n    pushNotificationExperimentSamples.recordFinalState(accountIdentifier,\n            deviceId,\n            experimentName,\n            new TestDeviceState(finalBounciness))\n        .join();\n\n    assertFalse(pushNotificationExperimentSamples.recordInitialState(accountIdentifier,\n                deviceId,\n                experimentName,\n                inExperimentGroup,\n                new TestDeviceState(bounciness))\n            .join(),\n        \"Attempt to record an initial state should fail for samples with final states\");\n\n    assertEquals(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, new TestDeviceState(bounciness), new TestDeviceState(finalBounciness)),\n        getSample(accountIdentifier, deviceId, experimentName, TestDeviceState.class),\n        \"Recorded initial state should be unchanged after unsuccessful write\");\n  }\n\n  @Test\n  void recordFinalState() throws JsonProcessingException {\n    final String experimentName = \"test-experiment\";\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = (byte) ThreadLocalRandom.current().nextInt(Device.MAXIMUM_DEVICE_ID);\n    final boolean inExperimentGroup = ThreadLocalRandom.current().nextBoolean();\n    final int initialBounciness = ThreadLocalRandom.current().nextInt();\n    final int finalBounciness = initialBounciness + 17;\n\n    {\n      pushNotificationExperimentSamples.recordInitialState(accountIdentifier,\n              deviceId,\n              experimentName,\n              inExperimentGroup,\n              new TestDeviceState(initialBounciness))\n          .join();\n\n      final PushNotificationExperimentSample<TestDeviceState> returnedSample =\n          pushNotificationExperimentSamples.recordFinalState(accountIdentifier,\n                  deviceId,\n                  experimentName,\n                  new TestDeviceState(finalBounciness))\n              .join();\n\n      final PushNotificationExperimentSample<TestDeviceState> expectedSample =\n          new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup,\n          new TestDeviceState(initialBounciness),\n          new TestDeviceState(finalBounciness));\n\n      assertEquals(expectedSample, returnedSample,\n          \"Attempt to update existing sample without final state should succeed\");\n\n      assertEquals(expectedSample, getSample(accountIdentifier, deviceId, experimentName, TestDeviceState.class),\n          \"Attempt to update existing sample without final state should be persisted\");\n    }\n\n    assertThrows(CompletionException.class, () -> pushNotificationExperimentSamples.recordFinalState(accountIdentifier,\n            (byte) (deviceId + 1),\n            experimentName,\n            new TestDeviceState(finalBounciness))\n        .join(),\n        \"Attempts to record a final state without an initial state should fail\");\n  }\n\n  @SuppressWarnings(\"SameParameterValue\")\n  private <T> PushNotificationExperimentSample<T> getSample(final UUID accountIdentifier,\n      final byte deviceId,\n      final String experimentName,\n      final Class<T> stateClass) throws JsonProcessingException {\n\n    final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n        .tableName(DynamoDbExtensionSchema.Tables.PUSH_NOTIFICATION_EXPERIMENT_SAMPLES.tableName())\n        .key(Map.of(\n            PushNotificationExperimentSamples.KEY_EXPERIMENT_NAME, AttributeValue.fromS(experimentName),\n            PushNotificationExperimentSamples.ATTR_ACI_AND_DEVICE_ID, PushNotificationExperimentSamples.buildSortKey(accountIdentifier, deviceId)))\n        .build());\n\n    final boolean inExperimentGroup =\n        response.item().get(PushNotificationExperimentSamples.ATTR_IN_EXPERIMENT_GROUP).bool();\n\n    final T initialState =\n        SystemMapper.jsonMapper().readValue(response.item().get(PushNotificationExperimentSamples.ATTR_INITIAL_STATE).s(), stateClass);\n\n    @Nullable final T finalState = response.item().containsKey(PushNotificationExperimentSamples.ATTR_FINAL_STATE)\n        ? SystemMapper.jsonMapper().readValue(response.item().get(PushNotificationExperimentSamples.ATTR_FINAL_STATE).s(), stateClass)\n        : null;\n\n    return new PushNotificationExperimentSample<>(accountIdentifier, deviceId, inExperimentGroup, initialState, finalState);\n  }\n\n  @Test\n  void getSamples() throws JsonProcessingException {\n    final String experimentName = \"test-experiment\";\n    final UUID initialSampleAccountIdentifier = UUID.randomUUID();\n    final byte initialSampleDeviceId = (byte) ThreadLocalRandom.current().nextInt(Device.MAXIMUM_DEVICE_ID);\n    final boolean initialSampleInExperimentGroup = ThreadLocalRandom.current().nextBoolean();\n\n    final UUID finalSampleAccountIdentifier = UUID.randomUUID();\n    final byte finalSampleDeviceId = (byte) ThreadLocalRandom.current().nextInt(Device.MAXIMUM_DEVICE_ID);\n    final boolean finalSampleInExperimentGroup = ThreadLocalRandom.current().nextBoolean();\n\n    final int initialBounciness = ThreadLocalRandom.current().nextInt();\n    final int finalBounciness = initialBounciness + 17;\n\n    pushNotificationExperimentSamples.recordInitialState(initialSampleAccountIdentifier,\n            initialSampleDeviceId,\n            experimentName,\n            initialSampleInExperimentGroup,\n            new TestDeviceState(initialBounciness))\n        .join();\n\n    pushNotificationExperimentSamples.recordInitialState(finalSampleAccountIdentifier,\n            finalSampleDeviceId,\n            experimentName,\n            finalSampleInExperimentGroup,\n            new TestDeviceState(initialBounciness))\n        .join();\n\n    pushNotificationExperimentSamples.recordFinalState(finalSampleAccountIdentifier,\n            finalSampleDeviceId,\n            experimentName,\n            new TestDeviceState(finalBounciness))\n        .join();\n\n    pushNotificationExperimentSamples.recordInitialState(UUID.randomUUID(),\n            (byte) ThreadLocalRandom.current().nextInt(Device.MAXIMUM_DEVICE_ID),\n            experimentName + \"-different\",\n            ThreadLocalRandom.current().nextBoolean(),\n            new TestDeviceState(ThreadLocalRandom.current().nextInt()))\n        .join();\n\n    final Set<PushNotificationExperimentSample<TestDeviceState>> expectedSamples = Set.of(\n        new PushNotificationExperimentSample<>(initialSampleAccountIdentifier,\n            initialSampleDeviceId,\n            initialSampleInExperimentGroup,\n            new TestDeviceState(initialBounciness),\n            null),\n\n        new PushNotificationExperimentSample<>(finalSampleAccountIdentifier,\n            finalSampleDeviceId,\n            finalSampleInExperimentGroup,\n            new TestDeviceState(initialBounciness),\n            new TestDeviceState(finalBounciness)));\n\n    assertEquals(expectedSamples,\n        pushNotificationExperimentSamples.getSamples(experimentName, TestDeviceState.class).collect(Collectors.toSet()).block());\n  }\n\n  @Test\n  void discardSamples() throws JsonProcessingException {\n    final String discardSamplesExperimentName = \"discard-experiment\";\n    final String retainSamplesExperimentName = \"retain-experiment\";\n    final int sampleCount = 16;\n\n    for (int i = 0; i < sampleCount; i++) {\n      pushNotificationExperimentSamples.recordInitialState(UUID.randomUUID(),\n              Device.PRIMARY_ID,\n              discardSamplesExperimentName,\n              ThreadLocalRandom.current().nextBoolean(),\n              new TestDeviceState(ThreadLocalRandom.current().nextInt()))\n          .join();\n\n      pushNotificationExperimentSamples.recordInitialState(UUID.randomUUID(),\n              Device.PRIMARY_ID,\n              retainSamplesExperimentName,\n              ThreadLocalRandom.current().nextBoolean(),\n              new TestDeviceState(ThreadLocalRandom.current().nextInt()))\n          .join();\n    }\n\n    pushNotificationExperimentSamples.discardSamples(discardSamplesExperimentName, 1).join();\n\n    {\n      final QueryResponse queryResponse = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient().query(QueryRequest.builder()\n              .tableName(DynamoDbExtensionSchema.Tables.PUSH_NOTIFICATION_EXPERIMENT_SAMPLES.tableName())\n              .keyConditionExpression(\"#experiment = :experiment\")\n              .expressionAttributeNames(Map.of(\"#experiment\", PushNotificationExperimentSamples.KEY_EXPERIMENT_NAME))\n              .expressionAttributeValues(Map.of(\":experiment\", AttributeValue.fromS(discardSamplesExperimentName)))\n              .select(Select.COUNT)\n              .build())\n          .join();\n\n      assertEquals(0, queryResponse.count());\n    }\n\n    {\n      final QueryResponse queryResponse = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient().query(QueryRequest.builder()\n              .tableName(DynamoDbExtensionSchema.Tables.PUSH_NOTIFICATION_EXPERIMENT_SAMPLES.tableName())\n              .keyConditionExpression(\"#experiment = :experiment\")\n              .expressionAttributeNames(Map.of(\"#experiment\", PushNotificationExperimentSamples.KEY_EXPERIMENT_NAME))\n              .expressionAttributeValues(Map.of(\":experiment\", AttributeValue.fromS(retainSamplesExperimentName)))\n              .select(Select.COUNT)\n              .build())\n          .join();\n\n      assertEquals(sampleCount, queryResponse.count());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/ExternalRequestFilterTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport com.google.common.net.InetAddresses;\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.DropwizardTestSupport;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.Status;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport jakarta.servlet.DispatcherType;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.client.Client;\nimport jakarta.ws.rs.core.Response;\nimport java.util.Collections;\nimport java.util.EnumSet;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoServiceGrpc;\nimport org.whispersystems.textsecuregcm.grpc.EchoServiceImpl;\nimport org.whispersystems.textsecuregcm.grpc.GrpcTestUtils;\nimport org.whispersystems.textsecuregcm.grpc.MockRequestAttributesInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.RequestAttributes;\nimport org.whispersystems.textsecuregcm.util.InetAddressRange;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass ExternalRequestFilterTest {\n\n  @Nested\n  class Allowed extends TestCase {\n\n    @Override\n    DropwizardTestSupport<TestConfiguration> getTestSupport() {\n      return new DropwizardTestSupport<>(TestApplication.class, getConfiguration());\n    }\n\n    @Override\n    int getExpectedHttpStatus() {\n      return 200;\n    }\n\n    @Override\n    Status getExpectedGrpcStatus() {\n      return Status.OK;\n    }\n\n    @Override\n    TestConfiguration getConfiguration() {\n      return new TestConfiguration() {\n        @Override\n        public Set<InetAddressRange> getPermittedRanges() {\n          return Set.of(new InetAddressRange(\"127.0.0.0/8\"));\n        }\n      };\n    }\n\n  }\n\n  @Nested\n  class Blocked extends TestCase {\n\n    @Override\n    DropwizardTestSupport<TestConfiguration> getTestSupport() {\n      return new DropwizardTestSupport<>(TestApplication.class, getConfiguration());\n    }\n\n    @Override\n    int getExpectedHttpStatus() {\n      return 404;\n    }\n\n    @Override\n    Status getExpectedGrpcStatus() {\n      return Status.NOT_FOUND;\n    }\n\n    @Override\n    TestConfiguration getConfiguration() {\n      return new TestConfiguration() {\n        @Override\n        public Set<InetAddressRange> getPermittedRanges() {\n          return Set.of(new InetAddressRange(\"10.0.0.0/8\"));\n        }\n      };\n    }\n  }\n\n  abstract static class TestCase {\n\n    abstract DropwizardTestSupport<TestConfiguration> getTestSupport();\n\n    abstract TestConfiguration getConfiguration();\n\n    abstract int getExpectedHttpStatus();\n\n    abstract Status getExpectedGrpcStatus();\n\n    private Server testServer;\n    private ManagedChannel channel;\n\n    @Nested\n    class Http {\n\n      private final DropwizardAppExtension<TestConfiguration> DROPWIZARD_APP_EXTENSION =\n          new DropwizardAppExtension<>(getTestSupport());\n\n      @Test\n      void testRestricted() {\n        Client client = DROPWIZARD_APP_EXTENSION.client();\n\n        try (Response response = client.target(\n                \"http://localhost:%s/test/restricted\".formatted(DROPWIZARD_APP_EXTENSION.getLocalPort()))\n            .request()\n            .get()) {\n\n          assertEquals(getExpectedHttpStatus(), response.getStatus());\n        }\n      }\n\n      @Test\n      void testOpen() {\n\n        Client client = DROPWIZARD_APP_EXTENSION.client();\n\n        try (Response response = client.target(\n                \"http://localhost:%s/test/open\".formatted(DROPWIZARD_APP_EXTENSION.getLocalPort()))\n            .request()\n            .get()) {\n\n          assertEquals(200, response.getStatus());\n        }\n\n      }\n    }\n\n    @Nested\n    class Grpc {\n\n      @BeforeEach\n      void setUp() throws Exception {\n        final MockRequestAttributesInterceptor mockRequestAttributesInterceptor = new MockRequestAttributesInterceptor();\n        mockRequestAttributesInterceptor.setRequestAttributes(new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"), null, null));\n\n        testServer = InProcessServerBuilder.forName(\"ExternalRequestFilterTest\")\n            .directExecutor()\n            .addService(new EchoServiceImpl())\n            .intercept(new ExternalRequestFilter(getConfiguration().getPermittedRanges(),\n                Set.of(\"org.signal.chat.rpc.EchoService/echo2\")))\n            .intercept(mockRequestAttributesInterceptor)\n            .build()\n            .start();\n\n        channel = InProcessChannelBuilder.forName(\"ExternalRequestFilterTest\")\n            .directExecutor()\n            .build();\n      }\n\n      @Test\n      void testBlocked() {\n        final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n\n        final String text = \"0123456789\";\n        final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(text)).build();\n\n        final Status expectedGrpcStatus = getExpectedGrpcStatus();\n        if (Status.Code.OK == expectedGrpcStatus.getCode()) {\n          assertEquals(text, client.echo2(req).getPayload().toStringUtf8());\n        } else {\n          GrpcTestUtils.assertStatusException(expectedGrpcStatus, () -> client.echo2(req));\n        }\n      }\n\n      @Test\n      void testOpen() {\n        final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n\n        final String text = \"0123456789\";\n        final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(text)).build();\n\n        assertEquals(text, client.echo(req).getPayload().toStringUtf8());\n      }\n\n      @AfterEach\n      void tearDown() throws Exception {\n\n        testServer.shutdownNow()\n            .awaitTermination(10, TimeUnit.SECONDS);\n      }\n    }\n\n    @Path(\"/test\")\n    public static class Controller {\n\n      @GET\n      @Path(\"/restricted\")\n      public Response restricted() {\n        return Response.ok().build();\n      }\n\n      @GET\n      @Path(\"/open\")\n      public Response open() {\n        return Response.ok().build();\n      }\n    }\n\n    public static class TestApplication extends Application<TestConfiguration> {\n\n      @Override\n      public void run(final TestConfiguration configuration, final Environment environment) throws Exception {\n\n        environment.jersey().register(new Controller());\n        environment.servlets()\n            .addFilter(\"ExternalRequestFilter\",\n                new ExternalRequestFilter(configuration.getPermittedRanges(),\n                    Collections.emptySet()))\n            .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), true, \"/test/restricted\");\n      }\n    }\n\n    public abstract static class TestConfiguration extends Configuration {\n\n      public abstract Set<InetAddressRange> getPermittedRanges();\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteAddressFilterIntegrationTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport jakarta.servlet.DispatcherType;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.client.Client;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.core.Context;\nimport java.io.IOException;\nimport java.net.InetAddress;\nimport java.net.URI;\nimport java.nio.ByteBuffer;\nimport java.security.Principal;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.EnumSet;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Collectors;\nimport javax.security.auth.Subject;\nimport org.eclipse.jetty.util.HostPort;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.WebSocketListener;\nimport org.eclipse.jetty.websocket.client.WebSocketClient;\nimport org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.websocket.WebSocketResourceProviderFactory;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.messages.WebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass RemoteAddressFilterIntegrationTest {\n\n  private static final String WEBSOCKET_PREFIX = \"/websocket\";\n  private static final String REMOTE_ADDRESS_PATH = \"/remoteAddress\";\n  private static final String WS_REQUEST_PATH = \"/wsRequest\";\n\n  // The Grizzly test container does not match the Jetty container used in real deployments, and JettyTestContainerFactory\n  // in jersey-test-framework-provider-jetty doesn’t easily support @Context HttpServletRequest, so this test runs a\n  // full Jetty server in a separate process\n  private static final DropwizardAppExtension<Configuration> EXTENSION = new DropwizardAppExtension<>(\n      TestApplication.class);\n\n  @Nested\n  class Rest {\n\n    @ParameterizedTest\n    @ValueSource(strings = {\"127.0.0.1\", \"0:0:0:0:0:0:0:1\"})\n    void testRemoteAddress(String ip) throws Exception {\n      final Set<String> addresses = Arrays.stream(InetAddress.getAllByName(\"localhost\"))\n          .map(InetAddress::getHostAddress)\n          .collect(Collectors.toSet());\n\n      assumeTrue(addresses.contains(ip), String.format(\"localhost does not resolve to %s\", ip));\n\n      Client client = EXTENSION.client();\n\n      final RemoteAddressFilterIntegrationTest.TestResponse response = client.target(\n              String.format(\"http://%s:%d%s\", HostPort.normalizeHost(ip), EXTENSION.getLocalPort(), REMOTE_ADDRESS_PATH))\n          .request(\"application/json\")\n          .get(RemoteAddressFilterIntegrationTest.TestResponse.class);\n\n      assertEquals(ip, response.remoteAddress());\n    }\n  }\n\n  @Nested\n  class WebSocket {\n\n    private WebSocketClient client;\n\n    @BeforeEach\n    void setUp() throws Exception {\n      client = new WebSocketClient();\n      client.start();\n    }\n\n    @AfterEach\n    void tearDown() throws Exception {\n      client.stop();\n    }\n\n    @ParameterizedTest\n    @ValueSource(strings = {\"127.0.0.1\", \"0:0:0:0:0:0:0:1\"})\n    void testRemoteAddress(String ip) throws Exception {\n      final Set<String> addresses = Arrays.stream(InetAddress.getAllByName(\"localhost\"))\n          .map(InetAddress::getHostAddress)\n          .collect(Collectors.toSet());\n\n      assumeTrue(addresses.contains(ip), String.format(\"localhost does not resolve to %s\", ip));\n\n      final CompletableFuture<byte[]> responseFuture = new CompletableFuture<>();\n      final ClientEndpoint clientEndpoint = new ClientEndpoint(WS_REQUEST_PATH, responseFuture);\n\n      client.connect(clientEndpoint,\n          URI.create(\n              String.format(\"ws://%s:%d%s\", HostPort.normalizeHost(ip), EXTENSION.getLocalPort(),\n                  WEBSOCKET_PREFIX + REMOTE_ADDRESS_PATH)));\n\n      final byte[] responseBytes = responseFuture.get(1, TimeUnit.SECONDS);\n\n      final TestResponse response = SystemMapper.jsonMapper().readValue(responseBytes, TestResponse.class);\n\n      assertEquals(ip, response.remoteAddress());\n    }\n  }\n\n  private static class ClientEndpoint implements WebSocketListener {\n\n    private final String requestPath;\n    private final CompletableFuture<byte[]> responseFuture;\n    private final WebSocketMessageFactory messageFactory;\n\n    ClientEndpoint(String requestPath, CompletableFuture<byte[]> responseFuture) {\n\n      this.requestPath = requestPath;\n      this.responseFuture = responseFuture;\n      this.messageFactory = new ProtobufWebSocketMessageFactory();\n    }\n\n    @Override\n    public void onWebSocketConnect(final Session session) {\n      final byte[] requestBytes = messageFactory.createRequest(Optional.of(1L), \"GET\", requestPath,\n          List.of(\"Accept: application/json\"),\n          Optional.empty()).toByteArray();\n      try {\n        session.getRemote().sendBytes(ByteBuffer.wrap(requestBytes));\n      } catch (IOException e) {\n        throw new RuntimeException(e);\n      }\n    }\n\n    @Override\n    public void onWebSocketBinary(final byte[] payload, final int offset, final int length) {\n\n      try {\n        WebSocketMessage webSocketMessage = messageFactory.parseMessage(payload, offset, length);\n\n        if (Objects.requireNonNull(webSocketMessage.getType()) == WebSocketMessage.Type.RESPONSE_MESSAGE) {\n          assert 200 == webSocketMessage.getResponseMessage().getStatus();\n          responseFuture.complete(webSocketMessage.getResponseMessage().getBody().orElseThrow());\n        } else {\n          throw new RuntimeException(\"Unexpected message type: \" + webSocketMessage.getType());\n        }\n      } catch (final Exception e) {\n        throw new RuntimeException(e);\n      }\n\n    }\n\n  }\n\n  public static abstract class TestController {\n\n    @GET\n    public RemoteAddressFilterIntegrationTest.TestResponse get(@Context ContainerRequestContext context) {\n\n      return new RemoteAddressFilterIntegrationTest.TestResponse(\n          (String) context.getProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME));\n    }\n  }\n\n  @Path(REMOTE_ADDRESS_PATH)\n  public static class TestRemoteAddressController extends TestController {\n\n  }\n\n  @Path(WS_REQUEST_PATH)\n  public static class TestWebSocketController extends TestController {\n\n  }\n\n  public record TestResponse(String remoteAddress) {\n\n  }\n\n  public static class TestApplication extends Application<Configuration> {\n\n    @Override\n    public void run(final Configuration configuration,\n        final Environment environment) throws Exception {\n\n      environment.servlets().addFilter(\"RemoteAddressFilterRemoteAddress\", new RemoteAddressFilter())\n          .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, REMOTE_ADDRESS_PATH,\n              WEBSOCKET_PREFIX + REMOTE_ADDRESS_PATH);\n\n      environment.jersey().register(new TestRemoteAddressController());\n\n      // WebSocket set up\n      final WebSocketConfiguration webSocketConfiguration = new WebSocketConfiguration();\n\n      WebSocketEnvironment<TestPrincipal> webSocketEnvironment = new WebSocketEnvironment<>(environment,\n          webSocketConfiguration, Duration.ofMillis(1000));\n\n      webSocketEnvironment.jersey().register(new TestWebSocketController());\n\n      JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), null);\n\n      WebSocketResourceProviderFactory<TestPrincipal> webSocketServlet = new WebSocketResourceProviderFactory<>(\n          webSocketEnvironment, TestPrincipal.class, webSocketConfiguration,\n          RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n      environment.servlets().addServlet(\"WebSocketRemoteAddress\", webSocketServlet)\n          .addMapping(WEBSOCKET_PREFIX + REMOTE_ADDRESS_PATH);\n\n    }\n  }\n\n  /**\n   * A minimal {@code Principal} implementation, only used to satisfy constructors\n   */\n  public static class TestPrincipal implements Principal {\n\n    // Principal implementation\n\n    @Override\n    public String getName() {\n      return null;\n    }\n\n    @Override\n    public boolean implies(final Subject subject) {\n      return false;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteAddressFilterTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletRequest;\nimport jakarta.servlet.ServletResponse;\nimport jakarta.servlet.http.HttpServletRequest;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nclass RemoteAddressFilterTest {\n\n  @ParameterizedTest\n  @CsvSource({\n      \"127.0.0.1, 127.0.0.1\",\n      \"0:0:0:0:0:0:0:1, 0:0:0:0:0:0:0:1\",\n      \"[0:0:0:0:0:0:0:1], 0:0:0:0:0:0:0:1\"\n  })\n  void testGetRemoteAddress(final String remoteAddr, final String expectedRemoteAddr) throws Exception {\n    final HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);\n    when(httpServletRequest.getRemoteAddr()).thenReturn(remoteAddr);\n\n    final RemoteAddressFilter filter = new RemoteAddressFilter();\n\n    final FilterChain filterChain = mock(FilterChain.class);\n    filter.doFilter(httpServletRequest, mock(ServletResponse.class), filterChain);\n\n    verify(httpServletRequest).setAttribute(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME, expectedRemoteAddr);\n    verify(filterChain).doFilter(any(ServletRequest.class), any(ServletResponse.class));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/RemoteDeprecationFilterTest.java",
    "content": "/*\n * Copyright 2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport com.google.common.net.InetAddresses;\nimport com.google.protobuf.ByteString;\nimport com.google.rpc.ErrorInfo;\nimport com.vdurmont.semver4j.Semver;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.StatusRuntimeException;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport io.grpc.protobuf.StatusProto;\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.ServletException;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport java.io.IOException;\nimport java.util.EnumMap;\nimport java.util.Set;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoServiceGrpc;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicRemoteDeprecationConfiguration;\nimport org.whispersystems.textsecuregcm.grpc.EchoServiceImpl;\nimport org.whispersystems.textsecuregcm.grpc.GrpcExceptions;\nimport org.whispersystems.textsecuregcm.grpc.MockRequestAttributesInterceptor;\nimport org.whispersystems.textsecuregcm.grpc.RequestAttributes;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\nclass RemoteDeprecationFilterTest {\n\n  @Test\n  void testEmptyMap() throws IOException, ServletException {\n    // We're happy as long as there's no exception\n    final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    final DynamicRemoteDeprecationConfiguration emptyConfiguration = new DynamicRemoteDeprecationConfiguration();\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(emptyConfiguration);\n\n    final RemoteDeprecationFilter filter = new RemoteDeprecationFilter(dynamicConfigurationManager);\n\n    final HttpServletRequest servletRequest = mock(HttpServletRequest.class);\n    final HttpServletResponse servletResponse = mock(HttpServletResponse.class);\n    final FilterChain filterChain = mock(FilterChain.class);\n\n    when(servletRequest.getHeader(\"UserAgent\")).thenReturn(\"Signal-Android/4.68.3\");\n\n    filter.doFilter(servletRequest, servletResponse, filterChain);\n\n    verify(filterChain).doFilter(servletRequest, servletResponse);\n    verify(servletResponse, never()).sendError(anyInt());\n  }\n\n  private RemoteDeprecationFilter filterConfiguredForTest() {\n    final EnumMap<ClientPlatform, Semver> minimumVersionsByPlatform = new EnumMap<>(ClientPlatform.class);\n    minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver(\"1.0.0\"));\n    minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver(\"1.0.0\"));\n    minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver(\"1.0.0\"));\n\n    final EnumMap<ClientPlatform, Semver> versionsPendingDeprecationByPlatform = new EnumMap<>(ClientPlatform.class);\n    minimumVersionsByPlatform.put(ClientPlatform.ANDROID, new Semver(\"1.1.0\"));\n    minimumVersionsByPlatform.put(ClientPlatform.IOS, new Semver(\"1.1.0\"));\n    minimumVersionsByPlatform.put(ClientPlatform.DESKTOP, new Semver(\"1.1.0\"));\n\n    final EnumMap<ClientPlatform, Set<Semver>> blockedVersionsByPlatform = new EnumMap<>(ClientPlatform.class);\n    blockedVersionsByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver(\"8.0.0-beta.2\")));\n\n    final EnumMap<ClientPlatform, Set<Semver>> versionsPendingBlockByPlatform = new EnumMap<>(ClientPlatform.class);\n    versionsPendingBlockByPlatform.put(ClientPlatform.DESKTOP, Set.of(new Semver(\"8.0.0-beta.3\")));\n\n    final DynamicRemoteDeprecationConfiguration remoteDeprecationConfiguration = new DynamicRemoteDeprecationConfiguration();\n    remoteDeprecationConfiguration.setMinimumVersions(minimumVersionsByPlatform);\n    remoteDeprecationConfiguration.setVersionsPendingDeprecation(versionsPendingDeprecationByPlatform);\n    remoteDeprecationConfiguration.setBlockedVersions(blockedVersionsByPlatform);\n    remoteDeprecationConfiguration.setVersionsPendingBlock(versionsPendingBlockByPlatform);\n    remoteDeprecationConfiguration.setUnrecognizedUserAgentAllowed(true);\n\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    final DynamicConfigurationManager dynamicConfigurationManager = mock(DynamicConfigurationManager.class);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getRemoteDeprecationConfiguration()).thenReturn(remoteDeprecationConfiguration);\n\n    return new RemoteDeprecationFilter(dynamicConfigurationManager);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testFilter(final String userAgent, final boolean expectDeprecation) throws IOException, ServletException {\n    final HttpServletRequest servletRequest = mock(HttpServletRequest.class);\n    final HttpServletResponse servletResponse = mock(HttpServletResponse.class);\n    final FilterChain filterChain = mock(FilterChain.class);\n\n    when(servletRequest.getHeader(HttpHeaders.USER_AGENT)).thenReturn(userAgent);\n\n    final RemoteDeprecationFilter filter = filterConfiguredForTest();\n    filter.doFilter(servletRequest, servletResponse, filterChain);\n\n    if (expectDeprecation) {\n      verify(filterChain, never()).doFilter(any(), any());\n      verify(servletResponse).sendError(499);\n    } else {\n      verify(filterChain).doFilter(servletRequest, servletResponse);\n      verify(servletResponse, never()).sendError(anyInt());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource(value=\"testFilter\")\n  void testGrpcFilter(final String userAgentString, final boolean expectDeprecation) throws IOException, InterruptedException {\n    final MockRequestAttributesInterceptor mockRequestAttributesInterceptor = new MockRequestAttributesInterceptor();\n    mockRequestAttributesInterceptor.setRequestAttributes(new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"), userAgentString, null));\n\n    final Server testServer = InProcessServerBuilder.forName(\"RemoteDeprecationFilterTest\")\n        .directExecutor()\n        .addService(new EchoServiceImpl())\n        .intercept(filterConfiguredForTest())\n        .intercept(mockRequestAttributesInterceptor)\n        .build()\n        .start();\n\n    final ManagedChannel channel = InProcessChannelBuilder.forName(\"RemoteDeprecationFilterTest\")\n        .directExecutor()\n        .userAgent(userAgentString)\n        .build();\n\n    try {\n      final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n\n      final EchoRequest req = EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(\"cluck cluck, i'm a parrot\")).build();\n      if (expectDeprecation) {\n        final StatusRuntimeException e = assertThrows(\n            StatusRuntimeException.class,\n            () -> client.echo(req));\n        final com.google.rpc.Status status = StatusProto.fromThrowable(e);\n        final ErrorInfo errorInfo = assertDoesNotThrow(() -> status.getDetailsList().stream()\n            .filter(any -> any.is(ErrorInfo.class)).findFirst()\n            .orElseThrow(() -> new AssertionError(\"No error info found\"))\n            .unpack(ErrorInfo.class));\n        assertEquals(GrpcExceptions.DOMAIN, errorInfo.getDomain());\n        assertEquals(io.grpc.Status.Code.INVALID_ARGUMENT.value(), status.getCode());\n        assertEquals(\"UPGRADE_REQUIRED\", errorInfo.getReason());\n      } else {\n        assertEquals(\"cluck cluck, i'm a parrot\", client.echo(req).getPayload().toStringUtf8());\n      }\n    } finally {\n      testServer.shutdownNow();\n      testServer.awaitTermination();\n    }\n  }\n\n  private static Stream<Arguments> testFilter() {\n    return Stream.of(\n        Arguments.of(\"Unrecognized UA\", false),\n        Arguments.of(\"Signal-Android/4.68.3\", false),\n        Arguments.of(\"Signal-iOS/3.9.0\", false),\n        Arguments.of(\"Signal-Desktop/1.2.3\", false),\n        Arguments.of(\"Signal-Android/0.68.3\", true),\n        Arguments.of(\"Signal-iOS/0.9.0\", true),\n        Arguments.of(\"Signal-Desktop/0.2.3\", true),\n        Arguments.of(\"Signal-Desktop/8.0.0-beta.2\", true),\n        Arguments.of(\"Signal-Desktop/8.0.0-beta.1\", false),\n        Arguments.of(\"Signal-iOS/8.0.0-beta.2\", false));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/RequestStatisticsFilterTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.metrics.TrafficSource;\n\nclass RequestStatisticsFilterTest {\n\n  @Test\n  void testFilter() throws Exception {\n\n    final RequestStatisticsFilter requestStatisticsFilter = new RequestStatisticsFilter(TrafficSource.WEBSOCKET);\n\n    final ContainerRequestContext requestContext = mock(ContainerRequestContext.class);\n\n    when(requestContext.getLength()).thenReturn(-1);\n    when(requestContext.getLength()).thenReturn(Integer.MAX_VALUE);\n    when(requestContext.getLength()).thenThrow(RuntimeException.class);\n\n    requestStatisticsFilter.filter(requestContext);\n    requestStatisticsFilter.filter(requestContext);\n    requestStatisticsFilter.filter(requestContext);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/RestDeprecationFilterTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.core.SecurityContext;\nimport java.net.URI;\nimport java.time.Instant;\nimport java.util.Random;\nimport java.util.UUID;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.tests.util.FakeDynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\nclass RestDeprecationFilterTest {\n\n  @Test\n  void testNoConfig() throws Exception {\n    final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        new FakeDynamicConfigurationManager<>(new DynamicConfiguration());\n    final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);\n\n    final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager);\n\n    final SecurityContext securityContext = mock(SecurityContext.class);\n    when(securityContext.getUserPrincipal()).thenReturn(new AuthenticatedDevice(UUID.randomUUID(), Device.PRIMARY_ID, Instant.now()));\n    final ContainerRequest req = new ContainerRequest(null, new URI(\"/some/uri\"), \"GET\", securityContext, null, null);\n    req.getHeaders().add(HttpHeaders.USER_AGENT, \"Signal-Android/100.0.0\");\n\n    filter.filter(req);\n  }\n\n  @Test\n  void testOldClientAuthenticated() throws Exception {\n    final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(\n        \"\"\"\n        restDeprecation:\n          platforms:\n            ANDROID:\n              minimumRestFreeVersion: 200.0.0\n        experiments:\n          restDeprecation:\n            uuidEnrollmentPercentage: 100\n        \"\"\",\n        DynamicConfiguration.class);\n    final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config);\n    final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);\n\n    final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager);\n\n    final SecurityContext securityContext = mock(SecurityContext.class);\n    when(securityContext.getUserPrincipal()).thenReturn(new AuthenticatedDevice(UUID.randomUUID(), Device.PRIMARY_ID, Instant.now()));\n    final ContainerRequest req = new ContainerRequest(null, new URI(\"/some/uri\"), \"GET\", securityContext, null, null);\n    req.getHeaders().add(HttpHeaders.USER_AGENT, \"Signal-Android/100.0.0\");\n\n    filter.filter(req);\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 99})\n  void testOldClientUnauthenticated(int randomRoll) throws Exception {\n    final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(\n        \"\"\"\n        restDeprecation:\n          platforms:\n            ANDROID:\n              minimumRestFreeVersion: 200.0.0\n              universalRolloutPercent: 50\n        experiments:\n          restDeprecation:\n            uuidEnrollmentPercentage: 100\n        \"\"\",\n        DynamicConfiguration.class);\n    final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config);\n    final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);\n    final Random fakeRandom = mock(Random.class);\n    when(fakeRandom.nextInt(anyInt())).thenReturn(randomRoll);\n\n    final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager, () -> fakeRandom);\n\n    final ContainerRequest req = new ContainerRequest(null, new URI(\"/some/uri\"), \"GET\", null, null, null);\n    req.getHeaders().add(HttpHeaders.USER_AGENT, \"Signal-Android/100.0.0\");\n\n    filter.filter(req);\n  }\n\n  @Test\n  void testBlockingAuthenticated() throws Exception {\n    final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(\n        \"\"\"\n        restDeprecation:\n          platforms:\n            ANDROID:\n              minimumRestFreeVersion: 10.10.10\n        experiments:\n          restDeprecation:\n            enrollmentPercentage: 100\n        \"\"\",\n        DynamicConfiguration.class);\n    final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config);\n    final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);\n\n    final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager);\n\n    final SecurityContext securityContext = mock(SecurityContext.class);\n    when(securityContext.getUserPrincipal()).thenReturn(new AuthenticatedDevice(UUID.randomUUID(), Device.PRIMARY_ID, Instant.now()));\n    final ContainerRequest req = new ContainerRequest(null, new URI(\"/some/path\"), \"GET\", securityContext, null, null);\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/10.9.15\");\n    filter.filter(req);\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/10.10.9\");\n    filter.filter(req);\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/10.10.10\");\n    assertThrows(WebApplicationException.class, () -> filter.filter(req));\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/100.0.0\");\n    assertThrows(WebApplicationException.class, () -> filter.filter(req));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 10, 20, 30, 40, 50, 60, 69, 70, 71, 80, 90, 99})\n  void testBlockingUnauthenticated(int randomRoll) throws Exception {\n    final DynamicConfiguration config = SystemMapper.yamlMapper().readValue(\n        \"\"\"\n        restDeprecation:\n          platforms:\n            ANDROID:\n              minimumRestFreeVersion: 10.10.10\n              universalRolloutPercent: 70\n        \"\"\",\n        DynamicConfiguration.class);\n    final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = new FakeDynamicConfigurationManager<>(config);\n    final ExperimentEnrollmentManager experimentEnrollmentManager = new ExperimentEnrollmentManager(dynamicConfigurationManager);\n    final Random fakeRandom = mock(Random.class);\n    when(fakeRandom.nextInt(anyInt())).thenReturn(randomRoll);\n\n    final RestDeprecationFilter filter = new RestDeprecationFilter(dynamicConfigurationManager, experimentEnrollmentManager, () -> fakeRandom);\n\n    final ContainerRequest req = new ContainerRequest(null, new URI(\"/some/path\"), \"GET\", null, null, null);\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/10.9.15\");\n    filter.filter(req);\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/10.10.9\");\n    filter.filter(req);\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/10.10.10\");\n    if (randomRoll < 70) {\n      assertThrows(WebApplicationException.class, () -> filter.filter(req));\n    } else {\n      filter.filter(req);\n    }\n\n    req.getHeaders().putSingle(HttpHeaders.USER_AGENT, \"Signal-Android/100.0.0\");\n    if (randomRoll < 70) {\n      assertThrows(WebApplicationException.class, () -> filter.filter(req));\n    } else {\n      filter.filter(req);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/filters/TimestampResponseFilterTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.filters;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.ArgumentMatchers.matches;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.servlet.FilterChain;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerResponseContext;\nimport jakarta.ws.rs.core.MultivaluedMap;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\n\nclass TimestampResponseFilterTest {\n\n  @Test\n  void testJerseyFilter() {\n    final ContainerRequestContext requestContext = mock(ContainerRequestContext.class);\n    final ContainerResponseContext responseContext = mock(ContainerResponseContext.class);\n    final MultivaluedMap<String, Object> headers = org.glassfish.jersey.message.internal.HeaderUtils.createOutbound();\n    when(responseContext.getHeaders()).thenReturn(headers);\n\n    new TimestampResponseFilter().filter(requestContext, responseContext);\n\n    assertTrue(headers.containsKey(org.whispersystems.textsecuregcm.util.HeaderUtils.TIMESTAMP_HEADER));\n  }\n\n  @Test\n  void testServletFilter() throws Exception {\n    final HttpServletRequest request = mock(HttpServletRequest.class);\n    final HttpServletResponse response = mock(HttpServletResponse.class);\n\n    new TimestampResponseFilter().doFilter(request, response, mock(FilterChain.class));\n\n    verify(response).setHeader(eq(HeaderUtils.TIMESTAMP_HEADER), matches(\"\\\\d{10,}\"));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsAnonymousGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.InetAddresses;\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.Mock;\nimport org.signal.chat.account.AccountsAnonymousGrpc;\nimport org.signal.chat.account.CheckAccountExistenceRequest;\nimport org.signal.chat.account.LookupUsernameHashRequest;\nimport org.signal.chat.account.LookupUsernameHashResponse;\nimport org.signal.chat.account.LookupUsernameLinkRequest;\nimport org.signal.chat.account.LookupUsernameLinkResponse;\nimport org.signal.chat.common.IdentityType;\nimport org.signal.chat.errors.NotFound;\nimport org.signal.chat.common.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport reactor.core.publisher.Mono;\n\nclass AccountsAnonymousGrpcServiceTest extends\n    SimpleBaseGrpcTest<AccountsAnonymousGrpcService, AccountsAnonymousGrpc.AccountsAnonymousBlockingStub> {\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private RateLimiters rateLimiters;\n\n  @Mock\n  private RateLimiter rateLimiter;\n\n  @Override\n  protected AccountsAnonymousGrpcService createServiceBeforeEachTest() {\n    when(accountsManager.getByServiceIdentifier(any()))\n        .thenReturn(Optional.empty());\n\n    when(accountsManager.getByUsernameHash(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(accountsManager.getByUsernameLinkHandle(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(rateLimiters.getCheckAccountExistenceLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getUsernameLookupLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getUsernameLinkLookupLimiter()).thenReturn(rateLimiter);\n\n    when(rateLimiter.validateReactive(anyString())).thenReturn(Mono.empty());\n\n    getMockRequestAttributesInterceptor().setRequestAttributes(\n        new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"), null, null));\n\n    return new AccountsAnonymousGrpcService(accountsManager, rateLimiters);\n  }\n\n  @Test\n  void checkAccountExistence() {\n    final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    when(accountsManager.getByServiceIdentifier(serviceIdentifier))\n        .thenReturn(Optional.of(mock(Account.class)));\n\n    assertTrue(unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder()\n        .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n        .build()).getAccountExists());\n\n    assertFalse(unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder()\n        .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))\n        .build()).getAccountExists());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void checkAccountExistenceIllegalRequest(final CheckAccountExistenceRequest request) {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> unauthenticatedServiceStub().checkAccountExistence(request));\n  }\n\n  private static Stream<Arguments> checkAccountExistenceIllegalRequest() {\n    return Stream.of(\n        // No service identifier\n        Arguments.of(CheckAccountExistenceRequest.newBuilder().build()),\n\n        // Bad service identifier\n        Arguments.of(CheckAccountExistenceRequest.newBuilder()\n            .setServiceIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(new byte[15]))\n                .build())\n            .build())\n    );\n  }\n\n  @Test\n  void checkAccountExistenceRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofSeconds(11);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(anyString());\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> unauthenticatedServiceStub().checkAccountExistence(CheckAccountExistenceRequest.newBuilder()\n            .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))\n            .build()),\n        accountsManager);\n  }\n\n  @Test\n  void lookupUsernameHash() {\n    final UUID accountIdentifier = UUID.randomUUID();\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(accountIdentifier);\n\n    when(accountsManager.getByUsernameHash(usernameHash))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    assertEquals(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(accountIdentifier)),\n        unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()\n                .setUsernameHash(ByteString.copyFrom(usernameHash))\n                .build())\n            .getServiceIdentifier());\n\n    assertEquals(LookupUsernameHashResponse.newBuilder().setNotFound(NotFound.getDefaultInstance()).build(),\n        unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH]))\n            .build()));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void lookupUsernameHashIllegalHash(final LookupUsernameHashRequest request) {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> unauthenticatedServiceStub().lookupUsernameHash(request));\n  }\n\n  private static Stream<Arguments> lookupUsernameHashIllegalHash() {\n    return Stream.of(\n        // No username hash\n        Arguments.of(LookupUsernameHashRequest.newBuilder().build()),\n\n        // Hash too long\n        Arguments.of(LookupUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH + 1]))\n            .build()),\n\n        // Hash too short\n        Arguments.of(LookupUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH - 1]))\n            .build())\n    );\n  }\n\n  @Test\n  void lookupUsernameHashRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofSeconds(13);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(anyString());\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> unauthenticatedServiceStub().lookupUsernameHash(LookupUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH]))\n            .build()),\n        accountsManager);\n  }\n\n  @Test\n  void lookupUsernameLink() {\n    final UUID linkHandle = UUID.randomUUID();\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(32);\n\n    final Account account = mock(Account.class);\n    when(account.getEncryptedUsername()).thenReturn(Optional.of(usernameCiphertext));\n\n    when(accountsManager.getByUsernameLinkHandle(linkHandle))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    assertEquals(ByteString.copyFrom(usernameCiphertext),\n        unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()\n                .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))\n                .build())\n            .getUsernameCiphertext());\n\n    when(account.getEncryptedUsername()).thenReturn(Optional.empty());\n\n    final LookupUsernameLinkResponse notFoundResponse = LookupUsernameLinkResponse.newBuilder()\n        .setNotFound(NotFound.getDefaultInstance())\n        .build();\n\n    assertEquals(notFoundResponse,\n        unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()\n            .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))\n            .build()));\n    assertEquals(notFoundResponse,\n        unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()\n            .setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID()))\n            .build()));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void lookupUsernameLinkIllegalHandle(final LookupUsernameLinkRequest request) {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> unauthenticatedServiceStub().lookupUsernameLink(request));\n  }\n\n  private static Stream<Arguments> lookupUsernameLinkIllegalHandle() {\n    return Stream.of(\n        // No handle\n        Arguments.of(LookupUsernameLinkRequest.newBuilder().build()),\n\n        // Bad handle length\n        Arguments.of(LookupUsernameLinkRequest.newBuilder()\n            .setUsernameLinkHandle(ByteString.copyFrom(new byte[15]))\n            .build())\n    );\n  }\n\n  @Test\n  void lookupUsernameLinkRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofSeconds(17);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(anyString());\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> unauthenticatedServiceStub().lookupUsernameLink(LookupUsernameLinkRequest.newBuilder()\n            .setUsernameLinkHandle(UUIDUtil.toByteString(UUID.randomUUID()))\n            .build()),\n        accountsManager);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/AccountsGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.time.Duration;\nimport java.util.HexFormat;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.Mock;\nimport org.signal.chat.account.AccountsGrpc;\nimport org.signal.chat.account.ClearRegistrationLockRequest;\nimport org.signal.chat.account.ClearRegistrationLockResponse;\nimport org.signal.chat.account.ConfigureUnidentifiedAccessRequest;\nimport org.signal.chat.account.ConfirmUsernameHashRequest;\nimport org.signal.chat.account.ConfirmUsernameHashResponse;\nimport org.signal.chat.account.DeleteAccountRequest;\nimport org.signal.chat.account.DeleteAccountResponse;\nimport org.signal.chat.account.DeleteUsernameHashRequest;\nimport org.signal.chat.account.DeleteUsernameLinkRequest;\nimport org.signal.chat.account.GetAccountIdentityRequest;\nimport org.signal.chat.account.GetAccountIdentityResponse;\nimport org.signal.chat.account.ReserveUsernameHashRequest;\nimport org.signal.chat.account.ReserveUsernameHashResponse;\nimport org.signal.chat.account.SetDiscoverableByPhoneNumberRequest;\nimport org.signal.chat.account.SetRegistrationLockRequest;\nimport org.signal.chat.account.SetRegistrationLockResponse;\nimport org.signal.chat.account.SetRegistrationRecoveryPasswordRequest;\nimport org.signal.chat.account.SetUsernameLinkRequest;\nimport org.signal.chat.account.SetUsernameLinkResponse;\nimport org.signal.chat.account.UsernameNotAvailable;\nimport org.signal.chat.common.AccountIdentifiers;\nimport org.signal.chat.errors.FailedPrecondition;\nimport org.signal.libsignal.usernames.BaseUsernameException;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.controllers.AccountController;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.EncryptedUsername;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.RegistrationRecoveryPasswordsManager;\nimport org.whispersystems.textsecuregcm.storage.UsernameHashNotAvailableException;\nimport org.whispersystems.textsecuregcm.storage.UsernameReservationNotFoundException;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport org.whispersystems.textsecuregcm.util.UsernameHashZkProofVerifier;\n\nclass AccountsGrpcServiceTest extends SimpleBaseGrpcTest<AccountsGrpcService, AccountsGrpc.AccountsBlockingStub> {\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private RateLimiter rateLimiter;\n\n  @Mock\n  private UsernameHashZkProofVerifier usernameHashZkProofVerifier;\n\n  @Mock\n  private RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager;\n\n  @Override\n  protected AccountsGrpcService createServiceBeforeEachTest() {\n    when(accountsManager.update(any(), any()))\n        .thenAnswer(invocation -> {\n          final Account account = invocation.getArgument(0);\n          final Consumer<Account> updater = invocation.getArgument(1);\n\n          updater.accept(account);\n\n          return account;\n        });\n\n    final RateLimiters rateLimiters = mock(RateLimiters.class);\n    when(rateLimiters.getUsernameReserveLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getUsernameSetLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getUsernameLinkOperationLimiter()).thenReturn(rateLimiter);\n\n    when(registrationRecoveryPasswordsManager.store(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    return new AccountsGrpcService(accountsManager,\n        rateLimiters,\n        usernameHashZkProofVerifier,\n        registrationRecoveryPasswordsManager);\n  }\n\n  @Test\n  void getAuthenticatedAccountIdentity() {\n    final UUID phoneNumberIdentifier = UUID.randomUUID();\n    final String e164 = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(32);\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(AUTHENTICATED_ACI);\n    when(account.getPhoneNumberIdentifier()).thenReturn(phoneNumberIdentifier);\n    when(account.getNumber()).thenReturn(e164);\n    when(account.getUsernameHash()).thenReturn(Optional.of(usernameHash));\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final GetAccountIdentityResponse expectedResponse = GetAccountIdentityResponse.newBuilder()\n        .setAccountIdentifiers(AccountIdentifiers.newBuilder()\n            .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(AUTHENTICATED_ACI)))\n            .addServiceIdentifiers(ServiceIdentifierUtil.toGrpcServiceIdentifier(new PniServiceIdentifier(phoneNumberIdentifier)))\n            .setE164(e164)\n            .setUsernameHash(ByteString.copyFrom(usernameHash))\n            .build())\n        .build();\n\n    assertEquals(expectedResponse, authenticatedServiceStub().getAccountIdentity(GetAccountIdentityRequest.newBuilder().build()));\n  }\n\n  @Test\n  void deleteAccount() {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final DeleteAccountResponse ignored =\n        authenticatedServiceStub().deleteAccount(DeleteAccountRequest.newBuilder().build());\n\n    verify(accountsManager).delete(account, AccountsManager.DeletionReason.USER_REQUEST);\n  }\n\n  @Test\n  void deleteAccountLinkedDevice() {\n    getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, \"BAD_AUTHENTICATION\",\n        () -> authenticatedServiceStub().deleteAccount(DeleteAccountRequest.newBuilder().build()));\n\n    verify(accountsManager, never()).delete(any(), any());\n  }\n\n  @Test\n  void setRegistrationLock() {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] registrationLockSecret = TestRandomUtil.nextBytes(32);\n\n    final SetRegistrationLockResponse ignored =\n        authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder()\n            .setRegistrationLock(ByteString.copyFrom(registrationLockSecret))\n            .build());\n\n    final ArgumentCaptor<String> hashCaptor = ArgumentCaptor.forClass(String.class);\n    final ArgumentCaptor<String> saltCaptor = ArgumentCaptor.forClass(String.class);\n\n    verify(account).setRegistrationLock(hashCaptor.capture(), saltCaptor.capture());\n\n    final SaltedTokenHash registrationLock = new SaltedTokenHash(hashCaptor.getValue(), saltCaptor.getValue());\n    assertTrue(registrationLock.verify(HexFormat.of().formatHex(registrationLockSecret)));\n  }\n\n  @Test\n  void setRegistrationLockEmptySecret() {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder()\n            .build()));\n\n    verify(accountsManager, never()).update(any(), any());\n  }\n\n  @Test\n  void setRegistrationLockLinkedDevice() {\n    getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, \"BAD_AUTHENTICATION\",\n        () -> authenticatedServiceStub().setRegistrationLock(SetRegistrationLockRequest.newBuilder()\n                .setRegistrationLock(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .build()));\n\n    verify(accountsManager, never()).update(any(), any());\n  }\n\n  @Test\n  void clearRegistrationLock() {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final ClearRegistrationLockResponse ignored =\n        authenticatedServiceStub().clearRegistrationLock(ClearRegistrationLockRequest.newBuilder().build());\n\n    verify(account).setRegistrationLock(null, null);\n  }\n\n  @Test\n  void clearRegistrationLockLinkedDevice() {\n    getMockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().clearRegistrationLock(ClearRegistrationLockRequest.newBuilder().build()));\n\n    verify(accountsManager, never()).update(any(), any());\n  }\n\n  @Test\n  void reserveUsernameHash() throws UsernameHashNotAvailableException {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    when(accountsManager.reserveUsernameHash(any(), any()))\n        .thenAnswer(invocation -> {\n          final List<byte[]> usernameHashes = invocation.getArgument(1);\n\n          return new AccountsManager.UsernameReservation(invocation.getArgument(0), usernameHashes.getFirst());\n        });\n\n    final ReserveUsernameHashResponse expectedResponse = ReserveUsernameHashResponse.newBuilder()\n        .setUsernameHash(ByteString.copyFrom(usernameHash))\n        .build();\n\n    assertEquals(expectedResponse,\n        authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder()\n            .addUsernameHashes(ByteString.copyFrom(usernameHash))\n            .build()));\n  }\n\n  @Test\n  void reserveUsernameHashNotAvailable() throws UsernameHashNotAvailableException {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    when(accountsManager.reserveUsernameHash(any(), any()))\n        .thenThrow(new UsernameHashNotAvailableException());\n\n    final ReserveUsernameHashResponse expectedResponse = ReserveUsernameHashResponse.newBuilder()\n        .setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())\n        .build();\n\n    assertEquals(expectedResponse,\n        authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder()\n            .addUsernameHashes(ByteString.copyFrom(usernameHash))\n            .build()));\n  }\n\n  @Test\n  void reserveUsernameHashNoHashes() {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder().build()));\n  }\n\n  @Test\n  void reserveUsernameHashTooManyHashes() {\n    final ReserveUsernameHashRequest.Builder requestBuilder = ReserveUsernameHashRequest.newBuilder();\n\n    for (int i = 0; i < AccountController.MAXIMUM_USERNAME_HASHES_LIST_LENGTH + 1; i++) {\n      final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n      requestBuilder.addUsernameHashes(ByteString.copyFrom(usernameHash));\n    }\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().reserveUsernameHash(requestBuilder.build()));\n  }\n\n  @Test\n  void reserveUsernameHashBadHashLength() {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH + 1);\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder()\n            .addUsernameHashes(ByteString.copyFrom(usernameHash))\n            .build()));\n  }\n\n  @Test\n  void reserveUsernameHashRateLimited() throws RateLimitExceededException {\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    final Duration retryAfter = Duration.ofMinutes(3);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(any(UUID.class));\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> authenticatedServiceStub().reserveUsernameHash(ReserveUsernameHashRequest.newBuilder()\n            .addUsernameHashes(ByteString.copyFrom(usernameHash))\n            .build()),\n        accountsManager);\n  }\n\n  @Test\n  void confirmUsernameHash() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(32);\n\n    final byte[] zkProof = TestRandomUtil.nextBytes(32);\n\n    final UUID linkHandle = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    when(accountsManager.confirmReservedUsernameHash(account, usernameHash, usernameCiphertext))\n        .thenAnswer(_ -> {\n          final Account updatedAccount = mock(Account.class);\n\n          when(updatedAccount.getUsernameHash()).thenReturn(Optional.of(usernameHash));\n          when(updatedAccount.getUsernameLinkHandle()).thenReturn(linkHandle);\n\n          return updatedAccount;\n        });\n\n    final ConfirmUsernameHashResponse expectedResponse = ConfirmUsernameHashResponse.newBuilder()\n        .setConfirmedUsernameHash(ConfirmUsernameHashResponse.ConfirmedUsernameHash.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(usernameHash))\n            .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandle))\n            .build())\n        .build();\n\n    assertEquals(expectedResponse,\n        authenticatedServiceStub().confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(usernameHash))\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .setZkProof(ByteString.copyFrom(zkProof))\n            .build()));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void confirmUsernameHashConfirmationException(final Exception confirmationException, final ConfirmUsernameHashResponse expectedResponse)\n      throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(32);\n\n    final byte[] zkProof = TestRandomUtil.nextBytes(32);\n\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    when(accountsManager.confirmReservedUsernameHash(any(), any(), any()))\n        .thenThrow(confirmationException);\n\n    final ConfirmUsernameHashResponse actualResponse = authenticatedServiceStub()\n        .confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(usernameHash))\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .setZkProof(ByteString.copyFrom(zkProof))\n            .build());\n\n    assertEquals(expectedResponse, actualResponse);\n  }\n\n  private static Stream<Arguments> confirmUsernameHashConfirmationException() {\n    return Stream.of(\n        Arguments.of( new UsernameHashNotAvailableException(),\n            ConfirmUsernameHashResponse.newBuilder()\n                .setUsernameNotAvailable(UsernameNotAvailable.getDefaultInstance())\n                .build()),\n        Arguments.of(new UsernameReservationNotFoundException(),\n            ConfirmUsernameHashResponse.newBuilder()\n                .setReservationNotFound(FailedPrecondition.getDefaultInstance())\n                .build())\n    );\n  }\n\n  @Test\n  void confirmUsernameHashInvalidProof() throws BaseUsernameException {\n    final byte[] usernameHash = TestRandomUtil.nextBytes(AccountController.USERNAME_HASH_LENGTH);\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(32);\n\n    final byte[] zkProof = TestRandomUtil.nextBytes(32);\n\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    doThrow(BaseUsernameException.class).when(usernameHashZkProofVerifier).verifyProof(any(), any());\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().confirmUsernameHash(ConfirmUsernameHashRequest.newBuilder()\n            .setUsernameHash(ByteString.copyFrom(usernameHash))\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .setZkProof(ByteString.copyFrom(zkProof))\n            .build()));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void confirmUsernameHashInvalidArgument(final ConfirmUsernameHashRequest request) {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().confirmUsernameHash(request));\n  }\n\n  private static List<ConfirmUsernameHashRequest> confirmUsernameHashInvalidArgument() {\n    final ConfirmUsernameHashRequest prototypeRequest = ConfirmUsernameHashRequest.newBuilder()\n        .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH]))\n        .setUsernameCiphertext(ByteString.copyFrom(new byte[AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH]))\n        .setZkProof(ByteString.copyFrom(new byte[32]))\n        .build();\n\n    return List.of(\n        // No username hash\n        ConfirmUsernameHashRequest.newBuilder(prototypeRequest)\n            .clearUsernameHash()\n            .build(),\n\n        // Incorrect username hash length\n        ConfirmUsernameHashRequest.newBuilder(prototypeRequest)\n            .setUsernameHash(ByteString.copyFrom(new byte[AccountController.USERNAME_HASH_LENGTH + 1]))\n            .build(),\n\n        // No username ciphertext\n        ConfirmUsernameHashRequest.newBuilder(prototypeRequest)\n            .clearUsernameCiphertext()\n            .build(),\n\n        // Excessive username ciphertext length\n        ConfirmUsernameHashRequest.newBuilder(prototypeRequest)\n            .setUsernameCiphertext(ByteString.copyFrom(new byte[AccountController.MAXIMUM_USERNAME_CIPHERTEXT_LENGTH + 1]))\n            .build(),\n\n        // No ZK proof\n        ConfirmUsernameHashRequest.newBuilder(prototypeRequest)\n            .clearZkProof()\n            .build());\n  }\n\n  @Test\n  void deleteUsernameHash() {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    when(accountsManager.clearUsernameHash(account)).thenReturn(account);\n\n    assertDoesNotThrow(() ->\n        authenticatedServiceStub().deleteUsernameHash(DeleteUsernameHashRequest.newBuilder().build()));\n\n    verify(accountsManager).clearUsernameHash(account);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {false, true})\n  void setUsernameLink(final boolean keepLink) {\n    final Account account = mock(Account.class);\n    final UUID oldHandle = UUID.randomUUID();\n    when(account.getUsernameHash()).thenReturn(Optional.of(new byte[AccountController.USERNAME_HASH_LENGTH]));\n    when(account.getUsernameLinkHandle()).thenReturn(oldHandle);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(EncryptedUsername.MAX_SIZE);\n\n    final SetUsernameLinkResponse response =\n        authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder()\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .setKeepLinkHandle(keepLink)\n            .build());\n\n    final ArgumentCaptor<UUID> linkHandleCaptor = ArgumentCaptor.forClass(UUID.class);\n\n    verify(account).setUsernameLinkDetails(linkHandleCaptor.capture(), eq(usernameCiphertext));\n\n    assertEquals(keepLink, oldHandle.equals(linkHandleCaptor.getValue()));\n    final SetUsernameLinkResponse expectedResponse = SetUsernameLinkResponse.newBuilder()\n        .setUsernameLinkHandle(UUIDUtil.toByteString(linkHandleCaptor.getValue()))\n        .build();\n\n    assertEquals(expectedResponse, response);\n  }\n\n  @Test\n  void setUsernameLinkMissingUsernameHash() {\n    final Account account = mock(Account.class);\n    when(account.getUsernameHash()).thenReturn(Optional.empty());\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(EncryptedUsername.MAX_SIZE);\n\n    assertEquals(\n        SetUsernameLinkResponse.newBuilder()\n            .setNoUsernameSet(FailedPrecondition.getDefaultInstance())\n            .build(),\n        authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder()\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .build()));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setUsernameLinkIllegalCiphertext(final SetUsernameLinkRequest request) {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().setUsernameLink(request));\n  }\n\n  private static List<SetUsernameLinkRequest> setUsernameLinkIllegalCiphertext() {\n    return List.of(\n        // No username ciphertext\n        SetUsernameLinkRequest.newBuilder().build(),\n\n        // Excessive username ciphertext\n        SetUsernameLinkRequest.newBuilder()\n            .setUsernameCiphertext(ByteString.copyFrom(new byte[EncryptedUsername.MAX_SIZE + 1]))\n            .build()\n    );\n  }\n\n  @Test\n  void setUsernameLinkRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofSeconds(97);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(any(UUID.class));\n\n    final byte[] usernameCiphertext = TestRandomUtil.nextBytes(EncryptedUsername.MAX_SIZE);\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> authenticatedServiceStub().setUsernameLink(SetUsernameLinkRequest.newBuilder()\n            .setUsernameCiphertext(ByteString.copyFrom(usernameCiphertext))\n            .build()),\n        accountsManager);\n  }\n\n  @Test\n  void deleteUsernameLink() {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    assertDoesNotThrow(\n        () -> authenticatedServiceStub().deleteUsernameLink(DeleteUsernameLinkRequest.newBuilder().build()));\n\n    verify(account).setUsernameLinkDetails(null, null);\n  }\n\n  @Test\n  void deleteUsernameLinkRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofSeconds(11);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(any(UUID.class));\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> authenticatedServiceStub().deleteUsernameLink(DeleteUsernameLinkRequest.newBuilder().build()),\n        accountsManager);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void configureUnidentifiedAccess(final boolean unrestrictedUnidentifiedAccess,\n      final byte[] unidentifiedAccessKey,\n      final byte[] expectedUnidentifiedAccessKey) {\n\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    assertDoesNotThrow(() -> authenticatedServiceStub().configureUnidentifiedAccess(ConfigureUnidentifiedAccessRequest.newBuilder()\n        .setAllowUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess)\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .build()));\n\n    verify(account).setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess);\n    verify(account).setUnidentifiedAccessKey(expectedUnidentifiedAccessKey);\n  }\n\n  private static Stream<Arguments> configureUnidentifiedAccess() {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    return Stream.of(\n        Arguments.of(true, new byte[0], null),\n        Arguments.of(true, unidentifiedAccessKey, null),\n        Arguments.of(false, unidentifiedAccessKey, unidentifiedAccessKey)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void configureUnidentifiedAccessIllegalArguments(final ConfigureUnidentifiedAccessRequest request) {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().configureUnidentifiedAccess(request));\n  }\n\n  private static List<ConfigureUnidentifiedAccessRequest> configureUnidentifiedAccessIllegalArguments() {\n    return List.of(\n        // No key and no unrestricted unidentified access\n        ConfigureUnidentifiedAccessRequest.newBuilder().build(),\n\n        // Key with incorrect length\n        ConfigureUnidentifiedAccessRequest.newBuilder()\n            .setAllowUnrestrictedUnidentifiedAccess(false)\n            .setUnidentifiedAccessKey(ByteString.copyFrom(new byte[15]))\n            .build()\n    );\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void setDiscoverableByPhoneNumber(final boolean discoverableByPhoneNumber) {\n    final Account account = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    assertDoesNotThrow(() ->\n        authenticatedServiceStub().setDiscoverableByPhoneNumber(SetDiscoverableByPhoneNumberRequest.newBuilder()\n            .setDiscoverableByPhoneNumber(discoverableByPhoneNumber)\n            .build()));\n\n    verify(account).setDiscoverableByPhoneNumber(discoverableByPhoneNumber);\n  }\n\n  @Test\n  void setRegistrationRecoveryPassword() {\n    final UUID phoneNumberIdentifier = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.PNI)).thenReturn(phoneNumberIdentifier);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n\n    final byte[] registrationRecoveryPassword = TestRandomUtil.nextBytes(32);\n\n    assertDoesNotThrow(() ->\n        authenticatedServiceStub().setRegistrationRecoveryPassword(SetRegistrationRecoveryPasswordRequest.newBuilder()\n            .setRegistrationRecoveryPassword(ByteString.copyFrom(registrationRecoveryPassword))\n            .build()));\n\n    verify(registrationRecoveryPasswordsManager).store(account.getIdentifier(IdentityType.PNI), registrationRecoveryPassword);\n  }\n\n  @Test\n  void setRegistrationRecoveryPasswordMissingPassword() {\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().setRegistrationRecoveryPassword(\n            SetRegistrationRecoveryPasswordRequest.newBuilder().build()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/AttachmentsGrpcServiceTest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;\n\nimport java.io.IOException;\nimport java.net.MalformedURLException;\nimport java.net.URI;\nimport java.net.URL;\nimport java.net.URLDecoder;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.security.KeyPair;\nimport java.security.KeyPairGenerator;\nimport java.security.NoSuchAlgorithmException;\nimport java.security.spec.InvalidKeySpecException;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.stream.Collectors;\nimport org.assertj.core.api.Assertions;\nimport org.assertj.core.api.InstanceOfAssertFactories;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.Mock;\nimport org.signal.chat.attachments.AttachmentsGrpc;\nimport org.signal.chat.attachments.GetUploadFormRequest;\nimport org.signal.chat.attachments.GetUploadFormResponse;\nimport org.signal.chat.common.UploadForm;\nimport org.whispersystems.textsecuregcm.attachments.GcsAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusAttachmentGenerator;\nimport org.whispersystems.textsecuregcm.attachments.TusConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.attachments.AttachmentUtil;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass AttachmentsGrpcServiceTest extends\n    SimpleBaseGrpcTest<AttachmentsGrpcService, AttachmentsGrpc.AttachmentsBlockingStub> {\n\n  private static final byte[] TUS_SECRET = TestRandomUtil.nextBytes(32);\n  private static final String TUS_URL = \"https://example.com/uploads\";\n\n  @Mock\n  private ExperimentEnrollmentManager experimentEnrollmentManager;\n  @Mock\n  private RateLimiter rateLimiter;\n\n  @Override\n  protected AttachmentsGrpcService createServiceBeforeEachTest() {\n    try {\n      final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(\"RSA\");\n      keyPairGenerator.initialize(1024);\n      final KeyPair keyPair = keyPairGenerator.generateKeyPair();\n\n      final String gcsPrivateKeyPem = \"-----BEGIN PRIVATE KEY-----\\n\" +\n          Base64.getMimeEncoder().encodeToString(keyPair.getPrivate().getEncoded()) + \"\\n\" +\n          \"-----END PRIVATE KEY-----\";\n      final GcsAttachmentGenerator gcsAttachmentGenerator = new GcsAttachmentGenerator(\n          \"some-cdn.signal.org\", \"signal@example.com\", 1000, \"/attach-here\", gcsPrivateKeyPem);\n      final TusAttachmentGenerator tusAttachmentGenerator =\n          new TusAttachmentGenerator(new TusConfiguration(new SecretBytes(TUS_SECRET), TUS_URL));\n\n      return new AttachmentsGrpcService(\n          experimentEnrollmentManager,\n          MockUtils.buildMock(RateLimiters.class, rateLimiters ->\n              when(rateLimiters.getAttachmentLimiter()).thenReturn(rateLimiter)),\n          gcsAttachmentGenerator,\n          tusAttachmentGenerator);\n    } catch (NoSuchAlgorithmException | IOException | InvalidKeyException | InvalidKeySpecException e) {\n      throw new AssertionError(e);\n    }\n  }\n\n  @Test\n  void getUploadFormCdn3() {\n    when(experimentEnrollmentManager.isEnrolled(AUTHENTICATED_ACI, AttachmentUtil.CDN3_EXPERIMENT_NAME))\n        .thenReturn(true);\n\n    final GetUploadFormResponse response = authenticatedServiceStub()\n        .getUploadForm(GetUploadFormRequest.newBuilder().build());\n\n    final UploadForm uploadForm = response.getUploadForm();\n    assertThat(uploadForm.getCdn()).isEqualTo(3);\n    assertThat(uploadForm.getKey()).isNotBlank();\n    assertThat(uploadForm.getSignedUploadLocation()).isEqualTo(TUS_URL + \"/attachments\");\n\n    final String filenameb64 = uploadForm.getHeadersMap().get(\"Upload-Metadata\").split(\" \")[1];\n    final String filename = new String(Base64.getDecoder().decode(filenameb64));\n    assertThat(uploadForm.getKey()).isEqualTo(filename);\n  }\n\n  @Test\n  void getUploadFormCdn2() throws MalformedURLException {\n    when(experimentEnrollmentManager.isEnrolled(AUTHENTICATED_ACI, AttachmentUtil.CDN3_EXPERIMENT_NAME))\n        .thenReturn(false);\n\n    final GetUploadFormResponse response = authenticatedServiceStub()\n        .getUploadForm(GetUploadFormRequest.newBuilder().build());\n\n    final UploadForm uploadForm = response.getUploadForm();\n    assertThat(uploadForm.getCdn()).isEqualTo(2);\n    assertThat(uploadForm.getKey()).isNotBlank();\n    assertThat(uploadForm.getHeadersMap()).containsExactlyInAnyOrderEntriesOf(Map.of(\n        \"host\", \"some-cdn.signal.org\",\n        \"x-goog-resumable\", \"start\",\n        \"x-goog-content-length-range\", \"1,1000\"));\n    assertThat(uploadForm.getSignedUploadLocation()).isNotEmpty();\n\n    final URL signedUploadLocation = URI.create(uploadForm.getSignedUploadLocation()).toURL();\n    assertThat(signedUploadLocation.getHost()).isEqualTo(\"some-cdn.signal.org\");\n    assertThat(signedUploadLocation.getPath()).startsWith(\"/attach-here/\");\n    final Map<String, String> queryParamMap = Arrays.stream(signedUploadLocation.getQuery().split(\"&\"))\n        .map(queryTerm -> queryTerm.split(\"=\", 2))\n        .collect(Collectors.toMap(\n            arr -> URLDecoder.decode(arr[0], StandardCharsets.UTF_8),\n            arr -> URLDecoder.decode(arr[1], StandardCharsets.UTF_8)));\n\n    assertThat(queryParamMap).hasSize(6);\n    assertThat(queryParamMap).containsAllEntriesOf(Map.of(\n        \"X-Goog-Algorithm\", \"GOOG4-RSA-SHA256\",\n        \"X-Goog-Expires\", \"90000\",\n        \"X-Goog-SignedHeaders\", \"host;x-goog-content-length-range;x-goog-resumable\"));\n    assertThat(queryParamMap)\n        .extractingByKey(\"X-Goog-Date\", Assertions.as(InstanceOfAssertFactories.STRING))\n        .isNotEmpty();\n    assertThat(queryParamMap)\n        .extractingByKey(\"X-Goog-Signature\", Assertions.as(InstanceOfAssertFactories.STRING))\n        .isNotEmpty();\n\n    final String credential = queryParamMap.get(\"X-Goog-Credential\");\n    final String[] credentialParts = credential.split(\"/\");\n    assertThat(credentialParts).hasSize(5);\n    assertThat(credentialParts[0]).isEqualTo(\"signal@example.com\");\n    assertThat(credentialParts[2]).isEqualTo(\"auto\");\n    assertThat(credentialParts[3]).isEqualTo(\"storage\");\n    assertThat(credentialParts[4]).isEqualTo(\"goog4_request\");\n  }\n\n  @Test\n  void getUploadFormRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofMinutes(5);\n    doThrow(new RateLimitExceededException(retryAfter)).when(rateLimiter).validate(any(UUID.class));\n\n    assertRateLimitExceeded(retryAfter, () ->\n        authenticatedServiceStub().getUploadForm(GetUploadFormRequest.newBuilder().build()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsAnonymousGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.collect.Iterators;\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport org.apache.commons.collections4.IteratorUtils;\nimport org.junit.jupiter.api.Assertions;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.Mock;\nimport org.signal.chat.backup.BackupsAnonymousGrpc;\nimport org.signal.chat.backup.CopyMediaItem;\nimport org.signal.chat.backup.CopyMediaRequest;\nimport org.signal.chat.backup.CopyMediaResponse;\nimport org.signal.chat.backup.DeleteMediaItem;\nimport org.signal.chat.backup.DeleteMediaRequest;\nimport org.signal.chat.backup.GetBackupInfoRequest;\nimport org.signal.chat.backup.GetCdnCredentialsRequest;\nimport org.signal.chat.backup.GetCdnCredentialsResponse;\nimport org.signal.chat.backup.GetMediaBackupInfoResponse;\nimport org.signal.chat.backup.GetMessageBackupInfoResponse;\nimport org.signal.chat.backup.GetUploadFormRequest;\nimport org.signal.chat.backup.GetUploadFormResponse;\nimport org.signal.chat.backup.ListMediaRequest;\nimport org.signal.chat.backup.ListMediaResponse;\nimport org.signal.chat.backup.SetPublicKeyRequest;\nimport org.signal.chat.backup.SetPublicKeyResponse;\nimport org.signal.chat.backup.SignedPresentation;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialPresentation;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedBackupUser;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;\nimport org.whispersystems.textsecuregcm.backup.BackupException;\nimport org.whispersystems.textsecuregcm.backup.BackupFailedZkAuthenticationException;\nimport org.whispersystems.textsecuregcm.backup.BackupManager;\nimport org.whispersystems.textsecuregcm.backup.BackupUploadDescriptor;\nimport org.whispersystems.textsecuregcm.backup.CopyResult;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.publisher.Flux;\n\nclass BackupsAnonymousGrpcServiceTest extends\n    SimpleBaseGrpcTest<BackupsAnonymousGrpcService, BackupsAnonymousGrpc.BackupsAnonymousBlockingStub> {\n\n  private final UUID aci = UUID.randomUUID();\n  private final byte[] messagesBackupKey = TestRandomUtil.nextBytes(32);\n  private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(Clock.systemUTC());\n  private final BackupAuthCredentialPresentation presentation =\n      presentation(backupAuthTestUtil, messagesBackupKey, aci);\n\n  @Mock\n  private BackupManager backupManager;\n\n  @Override\n  protected BackupsAnonymousGrpcService createServiceBeforeEachTest() {\n    return new BackupsAnonymousGrpcService(backupManager, new BackupMetrics());\n  }\n\n  @BeforeEach\n  void setup() {\n    try {\n      when(backupManager.authenticateBackupUser(any(), any(), any()))\n          .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MESSAGES, BackupLevel.PAID));\n    } catch (BackupFailedZkAuthenticationException e) {\n      Assertions.fail(e);\n    }\n  }\n\n  @Test\n  void setPublicKey() {\n    assertThat(unauthenticatedServiceStub().setPublicKey(SetPublicKeyRequest.newBuilder()\n        .setPublicKey(ByteString.copyFrom(ECKeyPair.generate().getPublicKey().serialize()))\n        .setSignedPresentation(signedPresentation(presentation))\n        .build())\n        .getOutcomeCase()).isEqualTo(SetPublicKeyResponse.OutcomeCase.SUCCESS);\n  }\n\n  @Test\n  void setBadPublicKey() {\n    assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() ->\n            unauthenticatedServiceStub().setPublicKey(SetPublicKeyRequest.newBuilder()\n                .setPublicKey(ByteString.copyFromUtf8(\"aaaaa\")) // Invalid public key\n                .setSignedPresentation(signedPresentation(presentation))\n                .build()))\n        .extracting(ex -> ex.getStatus().getCode())\n        .isEqualTo(Status.Code.INVALID_ARGUMENT);\n  }\n\n  @Test\n  void setMissingPublicKey() {\n    assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() ->\n            unauthenticatedServiceStub().setPublicKey(SetPublicKeyRequest.newBuilder()\n                // Missing public key\n                .setSignedPresentation(signedPresentation(presentation))\n                .build()))\n        .extracting(ex -> ex.getStatus().getCode())\n        .isEqualTo(Status.Code.INVALID_ARGUMENT);\n  }\n\n\n  @Test\n  void putMediaBatchSuccess() {\n    final byte[][] mediaIds = {TestRandomUtil.nextBytes(15), TestRandomUtil.nextBytes(15)};\n    when(backupManager.copyToBackup(any()))\n        .thenReturn(Flux.just(\n            new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[0], 1),\n            new CopyResult(CopyResult.Outcome.SUCCESS, mediaIds[1], 1)));\n\n    final CopyMediaRequest request = CopyMediaRequest.newBuilder()\n        .setSignedPresentation(signedPresentation(presentation))\n        .addItems(CopyMediaItem.newBuilder()\n            .setSourceAttachmentCdn(3)\n            .setSourceKey(\"abc\")\n            .setObjectLength(100)\n            .setMediaId(ByteString.copyFrom(mediaIds[0]))\n            .setHmacKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .setEncryptionKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .build())\n        .addItems(CopyMediaItem.newBuilder()\n            .setSourceAttachmentCdn(3)\n            .setSourceKey(\"def\")\n            .setObjectLength(200)\n            .setMediaId(ByteString.copyFrom(mediaIds[1]))\n            .setHmacKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .setEncryptionKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .build())\n        .build();\n\n    final Iterator<CopyMediaResponse> it = unauthenticatedServiceStub().copyMedia(request);\n\n    for (int i = 0; i < 2; i++) {\n      final CopyMediaResponse response = it.next();\n      assertThat(response.getSuccess().getCdn()).isEqualTo(1);\n      assertThat(response.getMediaId().toByteArray()).isEqualTo(mediaIds[i]);\n    }\n    assertThat(it.hasNext()).isFalse();\n  }\n\n  @Test\n  void putMediaBatchPartialFailure() {\n    // Copy four different mediaIds, with a variety of success/failure outcomes\n    final byte[][] mediaIds = IntStream.range(0, 4).mapToObj(i -> TestRandomUtil.nextBytes(15)).toArray(byte[][]::new);\n    final CopyResult.Outcome[] outcomes = new CopyResult.Outcome[]{\n        CopyResult.Outcome.SUCCESS,\n        CopyResult.Outcome.SOURCE_NOT_FOUND,\n        CopyResult.Outcome.SOURCE_WRONG_LENGTH,\n        CopyResult.Outcome.OUT_OF_QUOTA\n    };\n    when(backupManager.copyToBackup(any()))\n        .thenReturn(Flux.fromStream(IntStream.range(0, 4)\n            .mapToObj(i -> new CopyResult(\n                outcomes[i],\n                mediaIds[i],\n                outcomes[i] == CopyResult.Outcome.SUCCESS ? 1 : null))));\n\n    final CopyMediaRequest request = CopyMediaRequest.newBuilder()\n        .setSignedPresentation(signedPresentation(presentation))\n        .addAllItems(Arrays.stream(mediaIds)\n            .map(mediaId -> CopyMediaItem.newBuilder()\n                .setSourceAttachmentCdn(3)\n                .setSourceKey(\"abc\")\n                .setObjectLength(100)\n                .setMediaId(ByteString.copyFrom(mediaId))\n                .setHmacKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n                .setEncryptionKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n                .build())\n            .collect(Collectors.toList()))\n        .build();\n\n    final Iterator<CopyMediaResponse> responses = unauthenticatedServiceStub().copyMedia(request);\n\n    // Verify that we get the expected response for each mediaId\n    for (int i = 0; i < mediaIds.length; i++) {\n      final CopyMediaResponse response = responses.next();\n      switch (outcomes[i]) {\n        case SUCCESS -> assertThat(response.getSuccess().getCdn()).isEqualTo(1);\n        case SOURCE_WRONG_LENGTH -> assertThat(response.getWrongSourceLength()).isNotNull();\n        case OUT_OF_QUOTA -> assertThat(response.getOutOfSpace()).isNotNull();\n        case SOURCE_NOT_FOUND -> assertThat(response.getSourceNotFound()).isNotNull();\n      }\n      assertThat(response.getMediaId().toByteArray()).isEqualTo(mediaIds[i]);\n    }\n  }\n\n  @Test\n  void getMessageBackupInfo() throws BackupException {\n    when(backupManager.backupInfo(any()))\n        .thenReturn(new BackupManager.BackupInfo(1, \"myBackupDir\", \"myMediaDir\", \"filename\", Optional.empty()));\n\n    final GetMessageBackupInfoResponse response = unauthenticatedServiceStub().getMessageBackupInfo(GetBackupInfoRequest.newBuilder()\n        .setSignedPresentation(signedPresentation(presentation))\n        .build());\n    assertThat(response.getBackupInfo().getBackupDir()).isEqualTo(\"myBackupDir\");\n    assertThat(response.getBackupInfo().getBackupName()).isEqualTo(\"filename\");\n    assertThat(response.getBackupInfo().getCdn()).isEqualTo(1);\n  }\n\n  @Test\n  void getMediaBackupInfo() throws BackupException {\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), BackupCredentialType.MEDIA, BackupLevel.PAID));\n    when(backupManager.backupInfo(any()))\n        .thenReturn(new BackupManager.BackupInfo(1, \"myBackupDir\", \"myMediaDir\", \"filename\", Optional.of(123L)));\n\n    final GetMediaBackupInfoResponse response = unauthenticatedServiceStub().getMediaBackupInfo(GetBackupInfoRequest.newBuilder()\n        .setSignedPresentation(signedPresentation(presentation))\n        .build());\n    assertThat(response.getBackupInfo().getBackupDir()).isEqualTo(\"myBackupDir\");\n    assertThat(response.getBackupInfo().getMediaDir()).isEqualTo(\"myMediaDir\");\n    assertThat(response.getBackupInfo().getUsedSpace()).isEqualTo(123);\n  }\n\n  @ParameterizedTest\n  @EnumSource(BackupCredentialType.class)\n  void getBackupInfoWrongCredentialType(BackupCredentialType credentialType)\n      throws BackupFailedZkAuthenticationException {\n    when(backupManager.authenticateBackupUser(any(), any(), any()))\n        .thenReturn(backupUser(presentation.getBackupId(), credentialType, BackupLevel.PAID));\n    assertThatExceptionOfType(StatusRuntimeException.class).isThrownBy(() -> {\n          switch (credentialType) {\n            case MEDIA -> unauthenticatedServiceStub().getMessageBackupInfo(GetBackupInfoRequest.newBuilder()\n                .setSignedPresentation(signedPresentation(presentation))\n                .build());\n            case MESSAGES -> unauthenticatedServiceStub().getMediaBackupInfo(GetBackupInfoRequest.newBuilder()\n                .setSignedPresentation(signedPresentation(presentation))\n                .build());\n          }\n        })\n        .matches(ex -> ex.getStatus().getCode() == Status.Code.INVALID_ARGUMENT)\n        .matches(ex -> GrpcTestUtils.extractErrorInfo(ex).getReason().equals(\"BAD_AUTHENTICATION\"));\n  }\n\n\n  @CartesianTest\n  void list(\n      @CartesianTest.Values(booleans = {true, false}) final boolean cursorProvided,\n      @CartesianTest.Values(booleans = {true, false}) final boolean cursorReturned)\n      throws VerificationFailedException, BackupException {\n\n    final byte[] mediaId = TestRandomUtil.nextBytes(15);\n    final Optional<String> expectedCursor = cursorProvided ? Optional.of(\"myCursor\") : Optional.empty();\n    final Optional<String> returnedCursor = cursorReturned ? Optional.of(\"newCursor\") : Optional.empty();\n\n    final int limit = 17;\n\n    when(backupManager.list(any(), eq(expectedCursor), eq(limit)))\n        .thenReturn(new BackupManager.ListMediaResult(\n            List.of(new BackupManager.StorageDescriptorWithLength(1, mediaId, 100)),\n            returnedCursor));\n\n    final ListMediaRequest.Builder request = ListMediaRequest.newBuilder()\n        .setSignedPresentation(signedPresentation(presentation))\n        .setLimit(limit);\n    if (cursorProvided) {\n      request.setCursor(\"myCursor\");\n    }\n\n    final ListMediaResponse response = unauthenticatedServiceStub().listMedia(request.build());\n    assertThat(response.getListResult().getPageCount()).isEqualTo(1);\n    assertThat(response.getListResult().getPage(0).getLength()).isEqualTo(100);\n    assertThat(response.getListResult().getPage(0).getMediaId().toByteArray()).isEqualTo(mediaId);\n    assertThat(response.getListResult().hasCursor() ? response.getListResult().getCursor() : null)\n        .isEqualTo(returnedCursor.orElse(null));\n\n  }\n\n  @Test\n  void delete() throws BackupException {\n    final DeleteMediaRequest request = DeleteMediaRequest.newBuilder()\n        .setSignedPresentation(signedPresentation(presentation))\n        .addAllItems(IntStream.range(0, 100).mapToObj(i ->\n                DeleteMediaItem.newBuilder()\n                    .setCdn(3)\n                    .setMediaId(ByteString.copyFrom(TestRandomUtil.nextBytes(15)))\n                    .build())\n            .toList()).build();\n\n    when(backupManager.deleteMedia(any(), any()))\n        .thenReturn(Flux.fromStream(request.getItemsList().stream()\n            .map(m -> new BackupManager.StorageDescriptor(m.getCdn(), m.getMediaId().toByteArray()))));\n\n    final AtomicInteger count = new AtomicInteger(0);\n    unauthenticatedServiceStub().deleteMedia(request).forEachRemaining(i -> count.getAndIncrement());\n    assertThat(count.get()).isEqualTo(100);\n  }\n\n  @Test\n  void mediaUploadForm() throws RateLimitExceededException, BackupException {\n    when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"abc\", Map.of(\"k\", \"v\"), \"example.org\"));\n    final GetUploadFormRequest request = GetUploadFormRequest.newBuilder()\n        .setMedia(GetUploadFormRequest.MediaUploadType.getDefaultInstance())\n        .setSignedPresentation(signedPresentation(presentation))\n        .build();\n\n    final GetUploadFormResponse uploadForm = unauthenticatedServiceStub().getUploadForm(request);\n    assertThat(uploadForm.getUploadForm().getCdn()).isEqualTo(3);\n    assertThat(uploadForm.getUploadForm().getKey()).isEqualTo(\"abc\");\n    assertThat(uploadForm.getUploadForm().getHeadersMap()).containsExactlyEntriesOf(Map.of(\"k\", \"v\"));\n    assertThat(uploadForm.getUploadForm().getSignedUploadLocation()).isEqualTo(\"example.org\");\n\n    // rate limit\n    Duration duration = Duration.ofSeconds(10);\n    when(backupManager.createTemporaryAttachmentUploadDescriptor(any()))\n        .thenThrow(new RateLimitExceededException(duration));\n    GrpcTestUtils.assertRateLimitExceeded(duration, () -> unauthenticatedServiceStub().getUploadForm(request));\n  }\n\n  static Stream<Arguments> messagesUploadForm() {\n    return Stream.of(\n        Arguments.of(Optional.empty(), true),\n        Arguments.of(Optional.of(BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE), true),\n        Arguments.of(Optional.of(BackupManager.MAX_MESSAGE_BACKUP_OBJECT_SIZE + 1), false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void messagesUploadForm(Optional<Long> uploadLength, boolean allowedSize) throws BackupException {\n    when(backupManager.createMessageBackupUploadDescriptor(any()))\n        .thenReturn(new BackupUploadDescriptor(3, \"abc\", Map.of(\"k\", \"v\"), \"example.org\"));\n    final GetUploadFormRequest.MessagesUploadType.Builder builder = GetUploadFormRequest.MessagesUploadType.newBuilder();\n    uploadLength.ifPresent(builder::setUploadLength);\n    final GetUploadFormRequest request = GetUploadFormRequest.newBuilder()\n        .setMessages(builder.build())\n        .setSignedPresentation(signedPresentation(presentation))\n        .build();\n    final GetUploadFormResponse response = unauthenticatedServiceStub().getUploadForm(request);\n    if (allowedSize) {\n      assertThat(response.getUploadForm().getCdn()).isEqualTo(3);\n      assertThat(response.getUploadForm().getKey()).isEqualTo(\"abc\");\n      assertThat(response.getUploadForm().getHeadersMap()).containsExactlyEntriesOf(Map.of(\"k\", \"v\"));\n      assertThat(response.getUploadForm().getSignedUploadLocation()).isEqualTo(\"example.org\");\n    } else {\n      assertThat(response.hasExceedsMaxUploadLength()).isTrue();\n    }\n  }\n\n\n  @Test\n  void readAuth() throws BackupException {\n    when(backupManager.generateReadAuth(any(), eq(3))).thenReturn(Map.of(\"key\", \"value\"));\n\n    final GetCdnCredentialsResponse response = unauthenticatedServiceStub().getCdnCredentials(\n        GetCdnCredentialsRequest.newBuilder()\n            .setCdn(3)\n            .setSignedPresentation(signedPresentation(presentation))\n            .build());\n    assertThat(response.getCdnCredentials().getHeadersMap()).containsExactlyEntriesOf(Map.of(\"key\", \"value\"));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 1001})\n  void copyMediaInvalidRequest(final int count) {\n    final SignedPresentation sp = SignedPresentation.newBuilder()\n        .setPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(10)))\n        .setPresentationSignature(ByteString.copyFromUtf8(\"aaa\")).build();\n\n    final CopyMediaItem validItem = CopyMediaItem.newBuilder()\n        .setSourceAttachmentCdn(3)\n        .setSourceKey(\"abc\")\n        .setObjectLength(100)\n        .setMediaId(ByteString.copyFrom(TestRandomUtil.nextBytes(15)))\n        .setHmacKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n        .setEncryptionKey(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n        .build();\n\n    GrpcTestUtils.assertStatusInvalidArgument(() -> IteratorUtils.toList(unauthenticatedServiceStub().copyMedia(\n        CopyMediaRequest.newBuilder()\n            .setSignedPresentation(sp)\n            .addAllItems(IntStream.range(0, count).mapToObj(_ -> validItem).toList())\n            .build())));\n  }\n\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 1001})\n  void deleteMediaInvalidRequest(final int count) {\n    final SignedPresentation sp = SignedPresentation.newBuilder()\n        .setPresentation(ByteString.copyFrom(TestRandomUtil.nextBytes(10)))\n        .setPresentationSignature(ByteString.copyFromUtf8(\"aaa\")).build();\n\n    final DeleteMediaItem validItem = DeleteMediaItem.newBuilder()\n        .setCdn(3)\n        .setMediaId(ByteString.copyFrom(TestRandomUtil.nextBytes(15)))\n        .build();\n\n    GrpcTestUtils.assertStatusInvalidArgument(() -> IteratorUtils.toList(unauthenticatedServiceStub().deleteMedia(\n        DeleteMediaRequest.newBuilder()\n            .setSignedPresentation(sp)\n            .addAllItems(IntStream.range(0, count).mapToObj(_ -> validItem).toList())\n            .build())));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 10001})\n  void listMediaInvalidLimit(int count) {\n    GrpcTestUtils.assertStatusInvalidArgument(() -> unauthenticatedServiceStub().listMedia(\n        ListMediaRequest.newBuilder()\n            .setSignedPresentation(signedPresentation(presentation))\n            .setLimit(count)\n            .build()));\n  }\n\n\n  private static AuthenticatedBackupUser backupUser(final byte[] backupId, final BackupCredentialType credentialType,\n      final BackupLevel backupLevel) {\n    return new AuthenticatedBackupUser(backupId, credentialType, backupLevel, \"myBackupDir\", \"myMediaDir\", null);\n  }\n\n  private static BackupAuthCredentialPresentation presentation(BackupAuthTestUtil backupAuthTestUtil,\n      byte[] messagesBackupKey, UUID aci) {\n    try {\n      return backupAuthTestUtil.getPresentation(BackupLevel.PAID, messagesBackupKey, aci);\n    } catch (VerificationFailedException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private static SignedPresentation signedPresentation(BackupAuthCredentialPresentation presentation) {\n    return SignedPresentation.newBuilder()\n        .setPresentation(ByteString.copyFrom(presentation.serialize()))\n        .setPresentationSignature(ByteString.copyFromUtf8(\"aaa\")).build();\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/BackupsGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.Mock;\nimport org.signal.chat.backup.BackupsGrpc;\nimport org.signal.chat.backup.GetBackupAuthCredentialsRequest;\nimport org.signal.chat.backup.GetBackupAuthCredentialsResponse;\nimport org.signal.chat.backup.RedeemReceiptRequest;\nimport org.signal.chat.backup.RedeemReceiptResponse;\nimport org.signal.chat.backup.SetBackupIdRequest;\nimport org.signal.chat.common.ZkCredential;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.backups.BackupAuthCredentialRequest;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.signal.libsignal.zkgroup.backups.BackupLevel;\nimport org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredential;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.signal.libsignal.zkgroup.receipts.ServerZkReceiptOperations;\nimport org.whispersystems.textsecuregcm.auth.RedemptionRange;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthManager;\nimport org.whispersystems.textsecuregcm.backup.BackupAuthTestUtil;\nimport org.whispersystems.textsecuregcm.backup.BackupBadReceiptException;\nimport org.whispersystems.textsecuregcm.backup.BackupException;\nimport org.whispersystems.textsecuregcm.backup.BackupInvalidArgumentException;\nimport org.whispersystems.textsecuregcm.backup.BackupMissingIdCommitmentException;\nimport org.whispersystems.textsecuregcm.backup.BackupNotFoundException;\nimport org.whispersystems.textsecuregcm.backup.BackupPermissionException;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.metrics.BackupMetrics;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.EnumMapUtil;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport javax.annotation.Nullable;\n\nclass BackupsGrpcServiceTest extends SimpleBaseGrpcTest<BackupsGrpcService, BackupsGrpc.BackupsBlockingStub> {\n\n  private final byte[] messagesBackupKey = TestRandomUtil.nextBytes(32);\n  private final byte[] mediaBackupKey = TestRandomUtil.nextBytes(32);\n  private final BackupAuthTestUtil backupAuthTestUtil = new BackupAuthTestUtil(Clock.systemUTC());\n  final BackupAuthCredentialRequest mediaAuthCredRequest =\n      backupAuthTestUtil.getRequest(mediaBackupKey, AUTHENTICATED_ACI);\n  final BackupAuthCredentialRequest messagesAuthCredRequest =\n      backupAuthTestUtil.getRequest(messagesBackupKey, AUTHENTICATED_ACI);\n  private Account account;\n  private Device device;\n\n  @Mock\n  private BackupAuthManager backupAuthManager;\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Override\n  protected BackupsGrpcService createServiceBeforeEachTest() {\n    return new BackupsGrpcService(accountsManager, backupAuthManager, new BackupMetrics());\n  }\n\n  @BeforeEach\n  void setup() {\n    account = mock(Account.class);\n    device = mock(Device.class);\n    when(device.isPrimary()).thenReturn(true);\n    when(accountsManager.getByAccountIdentifierAsync(AUTHENTICATED_ACI))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(account));\n    when(account.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(device));\n  }\n\n\n  @Test\n  void setBackupId() throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    authenticatedServiceStub().setBackupId(\n        SetBackupIdRequest.newBuilder()\n            .setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()))\n            .setMessagesBackupAuthCredentialRequest(ByteString.copyFrom(messagesAuthCredRequest.serialize()))\n            .build());\n\n    verify(backupAuthManager)\n        .commitBackupId(account, device, Optional.of(messagesAuthCredRequest), Optional.of(mediaAuthCredRequest));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {false, true})\n  void setBackupIdPartial(boolean media)\n      throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    final SetBackupIdRequest.Builder builder = SetBackupIdRequest.newBuilder();\n    if (media) {\n      builder.setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()));\n    } else {\n      builder.setMessagesBackupAuthCredentialRequest(ByteString.copyFrom(messagesAuthCredRequest.serialize()));\n    }\n    authenticatedServiceStub().setBackupId(builder.build());\n    verify(backupAuthManager)\n        .commitBackupId(account, device,\n            Optional.ofNullable(media ? null : messagesAuthCredRequest),\n            Optional.ofNullable(media ? mediaAuthCredRequest: null));\n  }\n\n  @Test\n  void setBackupIdInvalid() {\n    // invalid serialization\n    GrpcTestUtils.assertStatusException(\n        Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setBackupId(\n            SetBackupIdRequest.newBuilder()\n                .setMessagesBackupAuthCredentialRequest(ByteString.fromHex(\"FF\"))\n                .setMediaBackupAuthCredentialRequest(ByteString.fromHex(\"FF\"))\n                .build())\n    );\n\n  }\n\n  public static Stream<Arguments> setBackupIdException() {\n    return Stream.of(\n        Arguments.of(new RateLimitExceededException(null), Status.RESOURCE_EXHAUSTED),\n        Arguments.of(new BackupPermissionException(\"test\"), Status.INVALID_ARGUMENT),\n        Arguments.of(new BackupInvalidArgumentException(\"test\"), Status.INVALID_ARGUMENT));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setBackupIdException(final Exception ex, final Status expected)\n      throws RateLimitExceededException, BackupInvalidArgumentException, BackupPermissionException {\n    doThrow(ex).when(backupAuthManager).commitBackupId(any(), any(), any(), any());\n\n    GrpcTestUtils.assertStatusException(\n        expected, () -> authenticatedServiceStub().setBackupId(SetBackupIdRequest.newBuilder()\n            .setMediaBackupAuthCredentialRequest(ByteString.copyFrom(mediaAuthCredRequest.serialize()))\n            .setMessagesBackupAuthCredentialRequest(ByteString.copyFrom(messagesAuthCredRequest.serialize()))\n            .build())\n    );\n  }\n\n  public static Stream<Arguments> redeemReceipt() {\n    return Stream.of(\n        Arguments.of(null, RedeemReceiptResponse.OutcomeCase.SUCCESS),\n        Arguments.of(new BackupBadReceiptException(\"test\"), RedeemReceiptResponse.OutcomeCase.INVALID_RECEIPT),\n        Arguments.of(new BackupMissingIdCommitmentException(), RedeemReceiptResponse.OutcomeCase.ACCOUNT_MISSING_COMMITMENT));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void redeemReceipt(@Nullable final BackupException exception, final RedeemReceiptResponse.OutcomeCase expectedOutcome)\n      throws InvalidInputException, VerificationFailedException, BackupInvalidArgumentException, BackupMissingIdCommitmentException, BackupBadReceiptException {\n\n    final ServerSecretParams params = ServerSecretParams.generate();\n    final ServerZkReceiptOperations serverOps = new ServerZkReceiptOperations(params);\n    final ClientZkReceiptOperations clientOps = new ClientZkReceiptOperations(params.getPublicParams());\n    final ReceiptCredentialRequestContext rcrc = clientOps\n        .createReceiptCredentialRequestContext(new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE)));\n    final ReceiptCredentialResponse rcr = serverOps.issueReceiptCredential(rcrc.getRequest(), 0L, 3L);\n    final ReceiptCredential receiptCredential = clientOps.receiveReceiptCredential(rcrc, rcr);\n    final ReceiptCredentialPresentation presentation = clientOps.createReceiptCredentialPresentation(receiptCredential);\n\n    if (exception != null) {\n      doThrow(exception).when(backupAuthManager).redeemReceipt(any(), any());\n    }\n\n    final RedeemReceiptResponse redeemReceiptResponse = authenticatedServiceStub().redeemReceipt(\n        RedeemReceiptRequest.newBuilder()\n            .setPresentation(ByteString.copyFrom(presentation.serialize()))\n            .build());\n    assertThat(redeemReceiptResponse.getOutcomeCase()).isEqualTo(expectedOutcome);\n\n    verify(backupAuthManager).redeemReceipt(account, presentation);\n  }\n\n\n  @Test\n  void getCredentials() throws BackupNotFoundException {\n    final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    final Instant end = start.plus(Duration.ofDays(1));\n    final RedemptionRange expectedRange = RedemptionRange.inclusive(Clock.systemUTC(), start, end);\n\n    final Map<BackupCredentialType, List<BackupAuthManager.Credential>> expectedCredentialsByType =\n        EnumMapUtil.toEnumMap(BackupCredentialType.class, credentialType -> backupAuthTestUtil.getCredentials(\n            BackupLevel.PAID, backupAuthTestUtil.getRequest(messagesBackupKey, AUTHENTICATED_ACI), credentialType,\n            start, end));\n\n    when(backupAuthManager.getBackupAuthCredentials(any(), eq(expectedRange)))\n        .thenReturn(expectedCredentialsByType);\n\n    final GetBackupAuthCredentialsResponse credentialResponse = authenticatedServiceStub().getBackupAuthCredentials(\n        GetBackupAuthCredentialsRequest.newBuilder()\n            .setRedemptionStart(start.getEpochSecond()).setRedemptionStop(end.getEpochSecond())\n            .build());\n\n    expectedCredentialsByType.forEach((credentialType, expectedCredentials) -> {\n\n      final Map<Long, ZkCredential> creds = switch (credentialType) {\n        case MESSAGES -> credentialResponse.getCredentials().getMessageCredentialsMap();\n        case MEDIA -> credentialResponse.getCredentials().getMediaCredentialsMap();\n      };\n      assertThat(creds).hasSize(expectedCredentials.size()).containsKey(start.getEpochSecond());\n\n      for (BackupAuthManager.Credential expectedCred : expectedCredentials) {\n        assertThat(creds)\n            .extractingByKey(expectedCred.redemptionTime().getEpochSecond())\n            .isNotNull()\n            .extracting(ZkCredential::getCredential)\n            .extracting(ByteString::toByteArray)\n            .isEqualTo(expectedCred.credential().serialize());\n      }\n    });\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"true, false\",\n      \"false, true\",\n      \"true, true\"\n  })\n  void getCredentialsBadInput(final boolean missingStart, final boolean missingEnd) {\n    final Instant start = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    final Instant end = start.plus(Duration.ofDays(1));\n\n    final GetBackupAuthCredentialsRequest.Builder builder = GetBackupAuthCredentialsRequest.newBuilder();\n    if (!missingStart) {\n      builder.setRedemptionStart(start.getEpochSecond());\n    }\n    if (!missingEnd) {\n      builder.setRedemptionStop(end.getEpochSecond());\n    }\n\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().getBackupAuthCredentials(builder.build()));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/CallQualitySurveyGrpcServiceTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.InetAddresses;\nimport java.time.Duration;\nimport io.grpc.Status;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.Mock;\nimport org.signal.chat.calling.quality.CallQualityGrpc;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.metrics.CallQualityInvalidArgumentsException;\nimport org.whispersystems.textsecuregcm.metrics.CallQualitySurveyManager;\n\nclass CallQualitySurveyGrpcServiceTest extends SimpleBaseGrpcTest<CallQualitySurveyGrpcService, CallQualityGrpc.CallQualityBlockingStub> {\n\n  @Mock\n  private CallQualitySurveyManager callQualitySurveyManager;\n\n  @Mock\n  private RateLimiter rateLimiter;\n\n  private static final String USER_AGENT = \"Signal-iOS/7.78.0.1041 iOS/18.3.2 libsignal/0.80.3\";\n  private static final String REMOTE_ADDRESS = \"127.0.0.1\";\n\n  @BeforeEach\n  void setUp() {\n    getMockRequestAttributesInterceptor()\n        .setRequestAttributes(new RequestAttributes(InetAddresses.forString(REMOTE_ADDRESS), USER_AGENT, null));\n  }\n\n  @Override\n  protected CallQualitySurveyGrpcService createServiceBeforeEachTest() {\n    final RateLimiters rateLimiters = mock(RateLimiters.class);\n    when(rateLimiters.getSubmitCallQualitySurveyLimiter()).thenReturn(rateLimiter);\n\n    return new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters);\n  }\n\n  @Test\n  void submitCallQualitySurvey() throws CallQualityInvalidArgumentsException {\n    final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance();\n    assertDoesNotThrow(() -> unauthenticatedServiceStub().submitCallQualitySurvey(request));\n\n    verify(callQualitySurveyManager).submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT);\n  }\n\n  @Test\n  void submitCallQualitySurveyRateLimited() throws RateLimitExceededException {\n    final Duration retryAfter = Duration.ofMinutes(17);\n\n    doThrow(new RateLimitExceededException(retryAfter))\n        .when(rateLimiter).validate(REMOTE_ADDRESS);\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertRateLimitExceeded(retryAfter,\n        () -> unauthenticatedServiceStub().submitCallQualitySurvey(SubmitCallQualitySurveyRequest.getDefaultInstance()));\n  }\n\n  @Test\n  void submitCallQualitySurveyInvalidArgument() throws CallQualityInvalidArgumentsException {\n    final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.getDefaultInstance();\n\n    doThrow(new CallQualityInvalidArgumentsException(\"test\"))\n        .when(callQualitySurveyManager).submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT);\n\n    //noinspection ResultOfMethodCallIgnored\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n        () -> unauthenticatedServiceStub().submitCallQualitySurvey(request));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/DevicesGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.Mock;\nimport org.signal.chat.device.ClearPushTokenRequest;\nimport org.signal.chat.device.ClearPushTokenResponse;\nimport org.signal.chat.device.DevicesGrpc;\nimport org.signal.chat.device.GetDevicesRequest;\nimport org.signal.chat.device.GetDevicesResponse;\nimport org.signal.chat.device.RemoveDeviceRequest;\nimport org.signal.chat.device.RemoveDeviceResponse;\nimport org.signal.chat.device.SetCapabilitiesRequest;\nimport org.signal.chat.device.SetCapabilitiesResponse;\nimport org.signal.chat.device.SetDeviceNameRequest;\nimport org.signal.chat.device.SetDeviceNameResponse;\nimport org.signal.chat.device.SetPushTokenRequest;\nimport org.signal.chat.device.SetPushTokenResponse;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass DevicesGrpcServiceTest extends SimpleBaseGrpcTest<DevicesGrpcService, DevicesGrpc.DevicesBlockingStub> {\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private Account authenticatedAccount;\n\n  @Override\n  protected DevicesGrpcService createServiceBeforeEachTest() {\n    when(authenticatedAccount.getUuid()).thenReturn(AUTHENTICATED_ACI);\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI))\n        .thenReturn(Optional.of(authenticatedAccount));\n\n    when(accountsManager.removeDevice(any(), anyByte()))\n        .thenReturn(authenticatedAccount);\n\n    when(accountsManager.update(any(), any()))\n        .thenAnswer(invocation -> {\n          final Account account = invocation.getArgument(0);\n          final Consumer<Account> updater = invocation.getArgument(1);\n\n          updater.accept(account);\n\n          return account;\n        });\n\n    when(accountsManager.updateDevice(any(), anyByte(), any()))\n        .thenAnswer(invocation -> {\n          final Account account = invocation.getArgument(0);\n          final Device device = account.getDevice(invocation.getArgument(1)).orElseThrow();\n          final Consumer<Device> updater = invocation.getArgument(2);\n\n          updater.accept(device);\n\n          return account;\n        });\n\n    return new DevicesGrpcService(accountsManager);\n  }\n\n  @Test\n  void getDevices() {\n    final Instant primaryDeviceCreated = Instant.now().minus(Duration.ofDays(7)).truncatedTo(ChronoUnit.MILLIS);\n    final Instant primaryDeviceLastSeen = primaryDeviceCreated.plus(Duration.ofHours(6));\n    final Instant linkedDeviceCreated = Instant.now().minus(Duration.ofDays(1)).truncatedTo(ChronoUnit.MILLIS);\n    final Instant linkedDeviceLastSeen = linkedDeviceCreated.plus(Duration.ofHours(7));\n    final int primaryRegistrationId = 1234;\n    final int linkedRegistrationId = 1235;\n    final byte[] primaryCreatedAtCiphertext = \"primary_timestamp_ciphertext\".getBytes(StandardCharsets.UTF_8);\n    final byte[] linkedCreatedAtCiphertext = \"linked_timestamp_ciphertext\".getBytes(StandardCharsets.UTF_8);\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(primaryDevice.getCreated()).thenReturn(primaryDeviceCreated.toEpochMilli());\n    when(primaryDevice.getLastSeen()).thenReturn(primaryDeviceLastSeen.toEpochMilli());\n    when(primaryDevice.getRegistrationId(IdentityType.ACI)).thenReturn(primaryRegistrationId);\n    when(primaryDevice.getCreatedAtCiphertext()).thenReturn(primaryCreatedAtCiphertext);\n\n    final String linkedDeviceName = \"A linked device\";\n\n    final Device linkedDevice = mock(Device.class);\n    when(linkedDevice.getId()).thenReturn((byte) (Device.PRIMARY_ID + 1));\n    when(linkedDevice.getCreated()).thenReturn(linkedDeviceCreated.toEpochMilli());\n    when(linkedDevice.getLastSeen()).thenReturn(linkedDeviceLastSeen.toEpochMilli());\n    when(linkedDevice.getName()).thenReturn(linkedDeviceName.getBytes(StandardCharsets.UTF_8));\n    when(linkedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(linkedRegistrationId);\n    when(linkedDevice.getCreatedAtCiphertext()).thenReturn(linkedCreatedAtCiphertext);\n\n    when(authenticatedAccount.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n\n    final GetDevicesResponse expectedResponse = GetDevicesResponse.newBuilder()\n        .addDevices(GetDevicesResponse.LinkedDevice.newBuilder()\n            .setId(Device.PRIMARY_ID)\n            .setLastSeen(primaryDeviceLastSeen.toEpochMilli())\n            .setRegistrationId(primaryRegistrationId)\n            .setCreatedAtCiphertext(ByteString.copyFrom(primaryCreatedAtCiphertext))\n            .build())\n        .addDevices(GetDevicesResponse.LinkedDevice.newBuilder()\n            .setId(Device.PRIMARY_ID + 1)\n            .setLastSeen(linkedDeviceLastSeen.toEpochMilli())\n            .setName(ByteString.copyFrom(linkedDeviceName.getBytes(StandardCharsets.UTF_8)))\n            .setRegistrationId(linkedRegistrationId)\n            .setCreatedAtCiphertext(ByteString.copyFrom(linkedCreatedAtCiphertext))\n            .build())\n        .build();\n\n    assertEquals(expectedResponse, authenticatedServiceStub().getDevices(GetDevicesRequest.newBuilder().build()));\n  }\n\n  @Test\n  void removeDevice() {\n    final byte deviceId = 17;\n\n    final RemoveDeviceResponse ignored = authenticatedServiceStub().removeDevice(RemoveDeviceRequest.newBuilder()\n        .setId(deviceId)\n        .build());\n\n    verify(accountsManager).removeDevice(authenticatedAccount, deviceId);\n  }\n\n  @Test\n  void removeDevicePrimary() {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().removeDevice(RemoveDeviceRequest.newBuilder()\n        .setId(1)\n        .build()));\n\n    verify(accountsManager, never()).removeDevice(any(), anyByte());\n  }\n\n  @Test\n  void removeDeviceNonPrimaryMatchAuthenticated() {\n    final byte deviceId = Device.PRIMARY_ID + 1;\n\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId);\n\n    final RemoveDeviceResponse ignored = authenticatedServiceStub().removeDevice(RemoveDeviceRequest.newBuilder()\n        .setId(deviceId)\n        .build());\n\n    verify(accountsManager).removeDevice(authenticatedAccount, deviceId);\n  }\n\n  @Test\n  void removeDeviceNonPrimaryMismatchAuthenticated() {\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().removeDevice(RemoveDeviceRequest.newBuilder()\n        .setId(17)\n        .build()));\n\n    verify(accountsManager, never()).removeDevice(any(), anyByte());\n  }\n\n  @ParameterizedTest\n  @ValueSource(bytes = {Device.PRIMARY_ID, Device.PRIMARY_ID + 1})\n  void setDeviceName(final byte deviceId) {\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId);\n\n    final Device device = mock(Device.class);\n    when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    final byte[] deviceName = TestRandomUtil.nextBytes(128);\n\n    assertTrue(authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()\n        .setId(deviceId)\n        .setName(ByteString.copyFrom(deviceName))\n        .build()).hasSuccess());\n\n    verify(device).setName(deviceName);\n  }\n\n  @Test\n  void setLinkedDeviceNameFromPrimary() {\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, Device.PRIMARY_ID);\n\n    final byte deviceId = Device.PRIMARY_ID + 1;\n\n    final Device device = mock(Device.class);\n    when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    final byte[] deviceName = TestRandomUtil.nextBytes(128);\n\n    final SetDeviceNameResponse ignored = authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()\n        .setId(deviceId)\n        .setName(ByteString.copyFrom(deviceName))\n        .build());\n\n    verify(device).setName(deviceName);\n  }\n\n  @Test\n  void setPrimaryDeviceNameFromLinkedDevice() {\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, (byte) (Device.PRIMARY_ID + 1));\n\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    final byte[] deviceName = TestRandomUtil.nextBytes(128);\n\n    assertStatusException(Status.INVALID_ARGUMENT,\n        () -> authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()\n            .setId(deviceId)\n            .setName(ByteString.copyFrom(deviceName))\n            .build()));\n\n    verify(device, never()).setName(deviceName);\n  }\n\n  @Test\n  void setDeviceNameNotFound() {\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, Device.PRIMARY_ID);\n    when(authenticatedAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n\n    final byte[] deviceName = TestRandomUtil.nextBytes(128);\n\n    assertTrue(authenticatedServiceStub().setDeviceName(SetDeviceNameRequest.newBuilder()\n        .setId(Device.PRIMARY_ID + 1)\n        .setName(ByteString.copyFrom(deviceName))\n        .build()).hasTargetDeviceNotFound());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setDeviceNameIllegalArgument(final SetDeviceNameRequest request) {\n    when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(mock(Device.class)));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setDeviceName(request));\n  }\n\n  private static Stream<Arguments> setDeviceNameIllegalArgument() {\n    return Stream.of(\n        // No device name\n        Arguments.of(SetDeviceNameRequest.newBuilder()\n            .setId(Device.PRIMARY_ID)\n            .build()),\n\n        // Excessively-long device name\n        Arguments.of(SetDeviceNameRequest.newBuilder()\n            .setId(Device.PRIMARY_ID)\n            .setName(ByteString.copyFrom(TestRandomUtil.nextBytes(1024)))\n            .build()),\n\n        // No device ID\n        Arguments.of(SetDeviceNameRequest.newBuilder()\n            .setName(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .build()),\n\n        // Out-of-bounds device ID\n        Arguments.of(SetDeviceNameRequest.newBuilder()\n            .setId(Device.MAXIMUM_DEVICE_ID + 1)\n            .setName(ByteString.copyFrom(TestRandomUtil.nextBytes(32)))\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setPushToken(final byte deviceId,\n      final SetPushTokenRequest request,\n      @Nullable final String expectedApnsToken,\n      @Nullable final String expectedFcmToken) {\n\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId);\n\n    final Device device = mock(Device.class);\n    when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    final SetPushTokenResponse ignored = authenticatedServiceStub().setPushToken(request);\n\n    verify(device).setApnId(expectedApnsToken);\n    verify(device).setGcmId(expectedFcmToken);\n    verify(device).setFetchesMessages(false);\n  }\n\n  private static Stream<Arguments> setPushToken() {\n    final String apnsToken = \"apns-token\";\n    final String fcmToken = \"fcm-token\";\n\n    final Stream.Builder<Arguments> streamBuilder = Stream.builder();\n\n    for (final byte deviceId : new byte[]{Device.PRIMARY_ID, Device.PRIMARY_ID + 1}) {\n      streamBuilder.add(Arguments.of(deviceId,\n          SetPushTokenRequest.newBuilder()\n              .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder()\n                  .setApnsToken(apnsToken)\n                  .build())\n              .build(),\n          apnsToken, null));\n\n      streamBuilder.add(Arguments.of(deviceId,\n          SetPushTokenRequest.newBuilder()\n              .setFcmTokenRequest(SetPushTokenRequest.FcmTokenRequest.newBuilder()\n                  .setFcmToken(fcmToken)\n                  .build())\n              .build(),\n          null, fcmToken));\n    }\n\n    return streamBuilder.build();\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setPushTokenUnchanged(final SetPushTokenRequest request,\n      @Nullable final String apnsToken,\n      @Nullable final String fcmToken) {\n\n    final Device device = mock(Device.class);\n    when(device.getApnId()).thenReturn(apnsToken);\n    when(device.getGcmId()).thenReturn(fcmToken);\n\n    when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(device));\n\n    final SetPushTokenResponse ignored = authenticatedServiceStub().setPushToken(request);\n\n    verify(accountsManager, never()).updateDevice(any(), anyByte(), any());\n  }\n\n  private static Stream<Arguments> setPushTokenUnchanged() {\n    final String apnsToken = \"apns-token\";\n    final String fcmToken = \"fcm-token\";\n\n    return Stream.of(\n        Arguments.of(SetPushTokenRequest.newBuilder()\n                .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder()\n                    .setApnsToken(apnsToken)\n                    .build())\n                .build(),\n            apnsToken, null, false),\n\n        Arguments.of(SetPushTokenRequest.newBuilder()\n                .setFcmTokenRequest(SetPushTokenRequest.FcmTokenRequest.newBuilder()\n                    .setFcmToken(fcmToken)\n                    .build())\n                .build(),\n            null, fcmToken, false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setPushTokenIllegalArgument(final SetPushTokenRequest request) {\n    final Device device = mock(Device.class);\n    when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(device));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setPushToken(request));\n    verify(accountsManager, never()).updateDevice(any(), anyByte(), any());\n  }\n\n  private static Stream<Arguments> setPushTokenIllegalArgument() {\n    return Stream.of(\n        Arguments.of(SetPushTokenRequest.newBuilder().build()),\n\n        Arguments.of(SetPushTokenRequest.newBuilder()\n                .setApnsTokenRequest(SetPushTokenRequest.ApnsTokenRequest.newBuilder().build())\n            .build()),\n\n        Arguments.of(SetPushTokenRequest.newBuilder()\n            .setFcmTokenRequest(SetPushTokenRequest.FcmTokenRequest.newBuilder().build())\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void clearPushToken(final byte deviceId,\n      @Nullable final String apnsToken,\n      @Nullable final String fcmToken,\n      @Nullable final String expectedUserAgent) {\n\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId);\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.isPrimary()).thenReturn(deviceId == Device.PRIMARY_ID);\n    when(device.getApnId()).thenReturn(apnsToken);\n    when(device.getGcmId()).thenReturn(fcmToken);\n    when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    final ClearPushTokenResponse ignored = authenticatedServiceStub().clearPushToken(ClearPushTokenRequest.newBuilder().build());\n\n    verify(device).setApnId(null);\n    verify(device).setGcmId(null);\n    verify(device).setFetchesMessages(true);\n\n    if (expectedUserAgent != null) {\n      verify(device).setUserAgent(expectedUserAgent);\n    } else {\n      verify(device, never()).setUserAgent(any());\n    }\n  }\n\n  private static Stream<Arguments> clearPushToken() {\n    return Stream.of(\n        Arguments.of(Device.PRIMARY_ID, \"apns-token\", null, \"OWI\"),\n        Arguments.of(Device.PRIMARY_ID, null, \"fcm-token\", \"OWA\"),\n        Arguments.of(Device.PRIMARY_ID, null, null, null),\n        Arguments.of((byte) (Device.PRIMARY_ID + 1), \"apns-token\", null, \"OWP\"),\n        Arguments.of((byte) (Device.PRIMARY_ID + 1), null, \"fcm-token\", \"OWA\"),\n        Arguments.of((byte) (Device.PRIMARY_ID + 1), null, null, null)\n    );\n  }\n\n  @CartesianTest\n  void setCapabilities(\n      @CartesianTest.Values(bytes = {Device.PRIMARY_ID, Device.PRIMARY_ID + 1}) final byte deviceId,\n      @CartesianTest.Values(booleans = {true, false}) final boolean storage,\n      @CartesianTest.Values(booleans = {true, false}) final boolean transfer,\n      @CartesianTest.Values(booleans = {true, false}) final boolean deleteSync,\n      @CartesianTest.Values(booleans = {true, false}) final boolean attachmentBackfill,\n      @CartesianTest.Values(booleans = {true, false}) final boolean spqr) {\n\n    mockAuthenticationInterceptor().setAuthenticatedDevice(AUTHENTICATED_ACI, deviceId);\n\n    final Device device = mock(Device.class);\n    when(authenticatedAccount.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    final SetCapabilitiesRequest.Builder requestBuilder = SetCapabilitiesRequest.newBuilder();\n\n    if (storage) {\n      requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_STORAGE);\n    }\n\n    if (transfer) {\n      requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_TRANSFER);\n    }\n\n    if (attachmentBackfill) {\n      requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_ATTACHMENT_BACKFILL);\n    }\n\n    if (spqr) {\n      requestBuilder.addCapabilities(org.signal.chat.common.DeviceCapability.DEVICE_CAPABILITY_SPARSE_POST_QUANTUM_RATCHET);\n    }\n\n    final SetCapabilitiesResponse ignored = authenticatedServiceStub().setCapabilities(requestBuilder.build());\n\n    final Set<DeviceCapability> expectedCapabilities = new HashSet<>();\n\n    if (storage) {\n      expectedCapabilities.add(DeviceCapability.STORAGE);\n    }\n\n    if (transfer) {\n      expectedCapabilities.add(DeviceCapability.TRANSFER);\n    }\n\n\n    if (attachmentBackfill) {\n      expectedCapabilities.add(DeviceCapability.ATTACHMENT_BACKFILL);\n    }\n\n    if (spqr) {\n      expectedCapabilities.add(DeviceCapability.SPARSE_POST_QUANTUM_RATCHET);\n    }\n\n    verify(device).setCapabilities(expectedCapabilities);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/EchoServiceImpl.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.stub.StreamObserver;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoResponse;\nimport org.signal.chat.rpc.EchoServiceGrpc;\n\npublic class EchoServiceImpl extends EchoServiceGrpc.EchoServiceImplBase {\n  @Override\n  public void echo(final EchoRequest echoRequest, final StreamObserver<EchoResponse> responseObserver) {\n    responseObserver.onNext(buildResponse(echoRequest));\n    responseObserver.onCompleted();\n  }\n\n  @Override\n  public void echo2(final EchoRequest echoRequest, final StreamObserver<EchoResponse> responseObserver) {\n    responseObserver.onNext(buildResponse(echoRequest));\n    responseObserver.onCompleted();\n  }\n\n  @Override\n  public StreamObserver<EchoRequest> echoStream(final StreamObserver<EchoResponse> responseObserver) {\n    return new StreamObserver<>() {\n      @Override\n      public void onNext(final EchoRequest echoRequest) {\n        responseObserver.onNext(buildResponse(echoRequest));\n      }\n\n      @Override\n      public void onError(final Throwable throwable) {\n        responseObserver.onError(throwable);\n      }\n\n      @Override\n      public void onCompleted() {\n        responseObserver.onCompleted();\n      }\n    };\n  }\n\n  private static EchoResponse buildResponse(final EchoRequest echoRequest) {\n    return EchoResponse.newBuilder().setPayload(echoRequest.getPayload()).build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/ErrorMappingInterceptorTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.Any;\nimport com.google.rpc.ErrorInfo;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport io.grpc.protobuf.StatusProto;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoResponse;\nimport org.signal.chat.rpc.EchoServiceGrpc;\nimport org.signal.chat.rpc.SimpleEchoServiceGrpc;\n\nclass ErrorMappingInterceptorTest {\n\n  private Server server;\n  private ManagedChannel channel;\n\n\n  @BeforeEach\n  void setUp() {\n    channel = InProcessChannelBuilder.forName(\"ErrorMappingInterceptorTest\")\n        .directExecutor()\n        .build();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    server.shutdownNow();\n    channel.shutdownNow();\n    server.awaitTermination(1, TimeUnit.SECONDS);\n    channel.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  public void includeDetailsSimpleGrpc() throws Exception {\n    final StatusRuntimeException e = StatusProto.toStatusRuntimeException(com.google.rpc.Status.newBuilder()\n        .setCode(Status.Code.INVALID_ARGUMENT.value())\n        .addDetails(Any.pack(ErrorInfo.newBuilder()\n            .setDomain(\"test\")\n            .setReason(\"TEST\")\n            .build()))\n        .build());\n\n    server = InProcessServerBuilder.forName(\"ErrorMappingInterceptorTest\")\n        .directExecutor()\n        .addService(new SimpleEchoServiceErrorImpl(e))\n        .intercept(new ErrorMappingInterceptor())\n        .build()\n        .start();\n\n    final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n    GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, \"TEST\", () ->\n        client.echo(EchoRequest.getDefaultInstance()));\n  }\n\n  @Test\n  public void mapIOExceptionsSimple() throws Exception {\n    server = InProcessServerBuilder.forName(\"ErrorMappingInterceptorTest\")\n        .directExecutor()\n        .addService(new SimpleEchoServiceErrorImpl(new UncheckedIOException(new IOException(\"test\"))))\n        .intercept(new ErrorMappingInterceptor())\n        .build()\n        .start();\n\n    final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n    GrpcTestUtils.assertStatusException(Status.UNAVAILABLE, \"UNAVAILABLE\", () ->\n        client.echo(EchoRequest.getDefaultInstance()));\n  }\n\n  @Test\n  public void mapWrappedIOExceptionsSimple() throws Exception {\n    server = InProcessServerBuilder.forName(\"ErrorMappingInterceptorTest\")\n        .directExecutor()\n        .addService(new SimpleEchoServiceErrorImpl(new CompletionException(new UncheckedIOException(new IOException(\"test\")))))\n        .intercept(new ErrorMappingInterceptor())\n        .build()\n        .start();\n\n    final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n    GrpcTestUtils.assertStatusException(Status.UNAVAILABLE, \"UNAVAILABLE\", () ->\n        client.echo(EchoRequest.getDefaultInstance()));\n  }\n\n  static class SimpleEchoServiceErrorImpl extends SimpleEchoServiceGrpc.EchoServiceImplBase {\n\n    private final RuntimeException exception;\n\n    SimpleEchoServiceErrorImpl(final RuntimeException exception) {\n      this.exception = exception;\n    }\n\n    @Override\n    public EchoResponse echo(final EchoRequest echoRequest) {\n      throw exception;\n    }\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsAnonymousGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.Mock;\nimport org.mockito.Mockito;\nimport org.signal.chat.credentials.AuthCheckResult;\nimport org.signal.chat.credentials.CheckSvrCredentialsRequest;\nimport org.signal.chat.credentials.CheckSvrCredentialsResponse;\nimport org.signal.chat.credentials.ExternalServiceCredentialsAnonymousGrpc;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass ExternalServiceCredentialsAnonymousGrpcServiceTest extends\n    SimpleBaseGrpcTest<ExternalServiceCredentialsAnonymousGrpcService, ExternalServiceCredentialsAnonymousGrpc.ExternalServiceCredentialsAnonymousBlockingStub> {\n\n  private static final UUID USER_UUID = UUID.randomUUID();\n\n  private static final String USER_E164 = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n      PhoneNumberUtil.PhoneNumberFormat.E164\n  );\n\n  private static final MutableClock CLOCK = MockUtils.mutableClock(0);\n\n  private static final ExternalServiceCredentialsGenerator SVR_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator\n      .builder(TestRandomUtil.nextBytes(32))\n      .withUserDerivationKey(TestRandomUtil.nextBytes(32))\n      .prependUsername(false)\n      .withDerivedUsernameTruncateLength(16)\n      .withClock(CLOCK)\n      .build());\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Override\n  protected ExternalServiceCredentialsAnonymousGrpcService createServiceBeforeEachTest() {\n    return new ExternalServiceCredentialsAnonymousGrpcService(accountsManager, SVR_CREDENTIALS_GENERATOR);\n  }\n\n  @BeforeEach\n  public void setup() {\n    Mockito.when(accountsManager.getByE164(USER_E164)).thenReturn(Optional.of(account(USER_UUID)));\n  }\n\n  @Test\n  public void testOneMatch() throws Exception {\n    final UUID user2 = UUID.randomUUID();\n    final UUID user3 = UUID.randomUUID();\n    assertExpectedCredentialCheckResponse(Map.of(\n        token(USER_UUID, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,\n        token(user2, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,\n        token(user3, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH\n    ), day(2));\n  }\n\n  @Test\n  public void testNoMatch() throws Exception {\n    final UUID user2 = UUID.randomUUID();\n    final UUID user3 = UUID.randomUUID();\n    assertExpectedCredentialCheckResponse(Map.of(\n        token(user2, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,\n        token(user3, day(1)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH\n    ), day(2));\n  }\n\n  @Test\n  public void testSomeInvalid() throws Exception {\n    final UUID user2 = UUID.randomUUID();\n    final UUID user3 = UUID.randomUUID();\n    final ExternalServiceCredentials user1Cred = credentials(USER_UUID, day(1));\n    final ExternalServiceCredentials user2Cred = credentials(user2, day(1));\n    final ExternalServiceCredentials user3Cred = credentials(user3, day(1));\n\n    final String fakeToken = token(new ExternalServiceCredentials(user2Cred.username(), user3Cred.password()));\n    assertExpectedCredentialCheckResponse(Map.of(\n        token(user1Cred), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,\n        token(user2Cred), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,\n        fakeToken, AuthCheckResult.AUTH_CHECK_RESULT_INVALID\n    ), day(2));\n  }\n\n  @Test\n  public void testSomeExpired() throws Exception {\n    final UUID user2 = UUID.randomUUID();\n    final UUID user3 = UUID.randomUUID();\n    assertExpectedCredentialCheckResponse(Map.of(\n        token(USER_UUID, day(100)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,\n        token(user2, day(100)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,\n        token(user3, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID,\n        token(user3, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID\n    ), day(110));\n  }\n\n  @Test\n  public void testSomeHaveNewerVersions() throws Exception {\n    final UUID user2 = UUID.randomUUID();\n    final UUID user3 = UUID.randomUUID();\n    assertExpectedCredentialCheckResponse(Map.of(\n        token(USER_UUID, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID,\n        token(USER_UUID, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_MATCH,\n        token(user2, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,\n        token(user3, day(20)), AuthCheckResult.AUTH_CHECK_RESULT_NO_MATCH,\n        token(user3, day(10)), AuthCheckResult.AUTH_CHECK_RESULT_INVALID\n    ), day(25));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 11})\n  public void testInvalidPasswordCount(int count) {\n    final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder()\n        .setNumber(USER_E164)\n        .addAllPasswords(IntStream.range(0, count).mapToObj(i -> token(UUID.randomUUID(), day(10))).toList())\n        .build();\n    final StatusRuntimeException status = assertThrows(StatusRuntimeException.class,\n        () -> unauthenticatedServiceStub().checkSvrCredentials(request));\n    assertEquals(Status.INVALID_ARGUMENT.getCode(), status.getStatus().getCode());\n  }\n\n  private void assertExpectedCredentialCheckResponse(\n      final Map<String, AuthCheckResult> expected,\n      final long nowMillis) throws Exception {\n    CLOCK.setTimeMillis(nowMillis);\n    final CheckSvrCredentialsRequest request = CheckSvrCredentialsRequest.newBuilder()\n        .setNumber(USER_E164)\n        .addAllPasswords(expected.keySet())\n        .build();\n    final CheckSvrCredentialsResponse response = unauthenticatedServiceStub().checkSvrCredentials(request);\n    final Map<String, AuthCheckResult> matchesMap = response.getMatchesMap();\n    assertEquals(expected, matchesMap);\n  }\n\n  private static String token(final UUID uuid, final long timeMillis) {\n    return token(credentials(uuid, timeMillis));\n  }\n\n  private static String token(final ExternalServiceCredentials credentials) {\n    return credentials.username() + \":\" + credentials.password();\n  }\n\n  private static ExternalServiceCredentials credentials(final UUID uuid, final long timeMillis) {\n    CLOCK.setTimeMillis(timeMillis);\n    return SVR_CREDENTIALS_GENERATOR.generateForUuid(uuid);\n  }\n\n  private static long day(final int n) {\n    return Duration.ofDays(n).toMillis();\n  }\n\n  private static Account account(final UUID uuid) {\n    final Account a = new Account();\n    a.setUuid(uuid);\n    return a;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/ExternalServiceCredentialsGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.mock;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusUnauthenticated;\n\nimport io.grpc.Status;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.Mock;\nimport org.mockito.Mockito;\nimport org.signal.chat.credentials.ExternalServiceCredentialsGrpc;\nimport org.signal.chat.credentials.ExternalServiceType;\nimport org.signal.chat.credentials.GetExternalServiceCredentialsRequest;\nimport org.signal.chat.credentials.GetExternalServiceCredentialsResponse;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.publisher.Mono;\n\npublic class ExternalServiceCredentialsGrpcServiceTest\n    extends SimpleBaseGrpcTest<ExternalServiceCredentialsGrpcService, ExternalServiceCredentialsGrpc.ExternalServiceCredentialsBlockingStub> {\n\n  private static final ExternalServiceCredentialsGenerator DIRECTORY_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator\n      .builder(TestRandomUtil.nextBytes(32))\n      .withUserDerivationKey(TestRandomUtil.nextBytes(32))\n      .prependUsername(false)\n      .truncateSignature(false)\n      .build());\n\n  private static final ExternalServiceCredentialsGenerator PAYMENTS_CREDENTIALS_GENERATOR = Mockito.spy(ExternalServiceCredentialsGenerator\n      .builder(TestRandomUtil.nextBytes(32))\n      .prependUsername(true)\n      .build());\n\n  @Mock\n  private RateLimiters rateLimiters;\n\n\n  @Override\n  protected ExternalServiceCredentialsGrpcService createServiceBeforeEachTest() {\n    return new ExternalServiceCredentialsGrpcService(Map.of(\n        ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR,\n        ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR\n    ), rateLimiters);\n  }\n\n  static Stream<Arguments> testSuccess() {\n    return Stream.of(\n        Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY, DIRECTORY_CREDENTIALS_GENERATOR),\n        Arguments.of(ExternalServiceType.EXTERNAL_SERVICE_TYPE_PAYMENTS, PAYMENTS_CREDENTIALS_GENERATOR)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void testSuccess(\n      final ExternalServiceType externalServiceType,\n      final ExternalServiceCredentialsGenerator credentialsGenerator) throws Exception {\n    final RateLimiter limiter = mock(RateLimiter.class);\n    doReturn(limiter).when(rateLimiters).forDescriptor(eq(RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS));\n    doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(limiter).validateReactive(eq(AUTHENTICATED_ACI));\n    final GetExternalServiceCredentialsResponse artResponse = authenticatedServiceStub().getExternalServiceCredentials(\n        GetExternalServiceCredentialsRequest.newBuilder()\n            .setExternalService(externalServiceType)\n            .build());\n    final Optional<Long> artValidation = credentialsGenerator.validateAndGetTimestamp(\n        new ExternalServiceCredentials(artResponse.getUsername(), artResponse.getPassword()));\n    assertTrue(artValidation.isPresent());\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = { -1, 0, 1000 })\n  public void testUnrecognizedService(final int externalServiceTypeValue) throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials(\n        GetExternalServiceCredentialsRequest.newBuilder()\n            .setExternalServiceValue(externalServiceTypeValue)\n            .build()));\n  }\n\n  @Test\n  public void testInvalidRequest() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExternalServiceCredentials(\n        GetExternalServiceCredentialsRequest.newBuilder()\n            .build()));\n  }\n\n  @Test\n  public void testRateLimitExceeded() throws Exception {\n    final Duration retryAfter = MockUtils.updateRateLimiterResponseToFail(\n        rateLimiters, RateLimiters.For.EXTERNAL_SERVICE_CREDENTIALS, AUTHENTICATED_ACI, Duration.ofSeconds(100));\n    Mockito.reset(DIRECTORY_CREDENTIALS_GENERATOR);\n    assertRateLimitExceeded(\n        retryAfter,\n        () -> authenticatedServiceStub().getExternalServiceCredentials(\n            GetExternalServiceCredentialsRequest.newBuilder()\n                .setExternalService(ExternalServiceType.EXTERNAL_SERVICE_TYPE_DIRECTORY)\n                .build()),\n        DIRECTORY_CREDENTIALS_GENERATOR\n    );\n  }\n\n  /**\n   * `ExternalServiceDefinitions` enum is supposed to have entries for all values in `ExternalServiceType`,\n   * except for the `EXTERNAL_SERVICE_TYPE_UNSPECIFIED` and `UNRECOGNIZED`.\n   * This test makes sure that is the case.\n   */\n  @ParameterizedTest\n  @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = { \"UNRECOGNIZED\", \"EXTERNAL_SERVICE_TYPE_UNSPECIFIED\" })\n  public void testHaveExternalServiceDefinitionForServiceTypes(final ExternalServiceType externalServiceType) throws Exception {\n    assertTrue(\n        Arrays.stream(ExternalServiceDefinitions.values()).anyMatch(v -> v.externalService() == externalServiceType),\n        \"`ExternalServiceDefinitions` enum entry is missing for the `%s` value of `ExternalServiceType`\".formatted(externalServiceType)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcAllowListInterceptorTest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.Status;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.Set;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoResponse;\nimport org.signal.chat.rpc.EchoServiceGrpc;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicGrpcAllowListConfiguration;\nimport org.whispersystems.textsecuregcm.tests.util.FakeDynamicConfigurationManager;\n\n\nclass GrpcAllowListInterceptorTest {\n  private Server server;\n  private ManagedChannel channel;\n\n  @BeforeEach\n  void setUp() {\n    channel = InProcessChannelBuilder.forName(\"GrpcAllowListInterceptorTest\")\n        .directExecutor()\n        .build();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    server.shutdownNow();\n    channel.shutdownNow();\n    server.awaitTermination(1, TimeUnit.SECONDS);\n    channel.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  public void disableAll() throws Exception {\n    final EchoServiceGrpc.EchoServiceBlockingStub client =\n        setup(false, Collections.emptySet(), Collections.emptySet());\n    GrpcTestUtils.assertStatusException(Status.UNIMPLEMENTED, () ->\n        client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()));\n  }\n\n  @Test\n  public void enableAll() throws Exception {\n    final EchoServiceGrpc.EchoServiceBlockingStub client =\n        setup(true, Collections.emptySet(), Collections.emptySet());\n    final EchoResponse echo = client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build());\n    assertThat(echo.getPayload()).isEqualTo(ByteString.empty());\n  }\n\n  @Test\n  public void enableByMethod() throws Exception {\n    final EchoServiceGrpc.EchoServiceBlockingStub client =\n        setup(false, Collections.emptySet(), Set.of(\"org.signal.chat.rpc.EchoService/echo\"));\n\n    final EchoResponse echo = client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build());\n    assertThat(echo.getPayload()).isEqualTo(ByteString.empty());\n\n    GrpcTestUtils.assertStatusException(Status.UNIMPLEMENTED, () ->\n        client.echo2(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()));\n  }\n\n  @Test\n  public void enableByService() throws Exception {\n    final EchoServiceGrpc.EchoServiceBlockingStub client =\n        setup(false, Set.of(\"org.signal.chat.rpc.EchoService\"), Collections.emptySet());\n\n    final EchoResponse echo = client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build());\n    assertThat(echo.getPayload()).isEqualTo(ByteString.empty());\n\n    final EchoResponse echo2 = client.echo2(EchoRequest.newBuilder().setPayload(ByteString.empty()).build());\n    assertThat(echo2.getPayload()).isEqualTo(ByteString.empty());\n  }\n\n  @Test\n  public void enableByServiceWrongService() throws Exception {\n    final EchoServiceGrpc.EchoServiceBlockingStub client =\n        setup(false, Set.of(\"org.signal.chat.rpc.NotEchoService\"), Collections.emptySet());\n\n    GrpcTestUtils.assertStatusException(Status.UNIMPLEMENTED, () ->\n        client.echo(EchoRequest.newBuilder().setPayload(ByteString.empty()).build()));\n  }\n\n  private EchoServiceGrpc.EchoServiceBlockingStub setup(\n      boolean enableAll,\n      Set<String> enabledServices,\n      Set<String> enabledMethods)\n      throws IOException {\n    if (server != null) {\n      server.shutdownNow();\n    }\n    final DynamicConfiguration configuration = mock(DynamicConfiguration.class);\n    when(configuration.getGrpcAllowList())\n        .thenReturn(new DynamicGrpcAllowListConfiguration(enableAll, enabledServices, enabledMethods));\n    server = InProcessServerBuilder.forName(\"GrpcAllowListInterceptorTest\")\n        .directExecutor()\n        .addService(new EchoServiceImpl())\n        .intercept(new GrpcAllowListInterceptor(new FakeDynamicConfigurationManager<>(configuration)))\n        .build()\n        .start();\n\n    return EchoServiceGrpc.newBlockingStub(channel);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcServerExtension.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.BindableService;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.ServerServiceDefinition;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport io.grpc.util.MutableHandlerRegistry;\nimport org.junit.jupiter.api.extension.AfterEachCallback;\nimport org.junit.jupiter.api.extension.BeforeEachCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport java.util.UUID;\nimport java.util.concurrent.TimeUnit;\n\n// This is mostly a direct port of\n// https://github.com/grpc/grpc-java/blob/master/testing/src/main/java/io/grpc/testing/GrpcServerRule.java, but for\n// JUnit 5.\npublic class GrpcServerExtension implements BeforeEachCallback, AfterEachCallback {\n\n  private ManagedChannel channel;\n  private Server server;\n  private String serverName;\n  private MutableHandlerRegistry serviceRegistry;\n  private boolean useDirectExecutor;\n\n  /**\n   * Returns {@code this} configured to use a direct executor for the {@link ManagedChannel} and\n   * {@link Server}. This can only be called at the rule instantiation.\n   */\n  public final GrpcServerExtension directExecutor() {\n    if (serverName != null) {\n      throw new IllegalStateException(\"directExecutor() can only be called at the rule instantiation\");\n    }\n\n    useDirectExecutor = true;\n    return this;\n  }\n\n  /**\n   * Returns a {@link ManagedChannel} connected to this service.\n   */\n  public final ManagedChannel getChannel() {\n    return channel;\n  }\n\n  /**\n   * Returns the underlying gRPC {@link Server} for this service.\n   */\n  public final Server getServer() {\n    return server;\n  }\n\n  /**\n   * Returns the randomly generated server name for this service.\n   */\n  public final String getServerName() {\n    return serverName;\n  }\n\n  /**\n   * Returns the service registry for this service. The registry is used to add service instances\n   * (e.g. {@link BindableService} or {@link ServerServiceDefinition} to the server.\n   */\n  public final MutableHandlerRegistry getServiceRegistry() {\n    return serviceRegistry;\n  }\n\n  @Override\n  public void beforeEach(final ExtensionContext extensionContext) throws Exception {\n    serverName = UUID.randomUUID().toString();\n    serviceRegistry = new MutableHandlerRegistry();\n\n    final InProcessServerBuilder serverBuilder = InProcessServerBuilder.forName(serverName)\n        .fallbackHandlerRegistry(serviceRegistry);\n\n    if (useDirectExecutor) {\n      serverBuilder.directExecutor();\n    }\n\n    server = serverBuilder.build().start();\n\n    final InProcessChannelBuilder channelBuilder = InProcessChannelBuilder.forName(serverName);\n\n    if (useDirectExecutor) {\n      channelBuilder.directExecutor();\n    }\n\n    channel = channelBuilder.build();\n  }\n\n  @Override\n  public void afterEach(final ExtensionContext extensionContext) throws Exception {\n    serverName = null;\n    serviceRegistry = null;\n\n    channel.shutdown();\n    server.shutdown();\n\n    try {\n      channel.awaitTermination(1, TimeUnit.MINUTES);\n      server.awaitTermination(1, TimeUnit.MINUTES);\n    } catch (final InterruptedException e) {\n      Thread.currentThread().interrupt();\n      throw new RuntimeException(e);\n    } finally {\n      channel.shutdownNow();\n      channel = null;\n\n      server.shutdownNow();\n      server = null;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/GrpcTestUtils.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.mockito.Mockito.verifyNoInteractions;\n\nimport com.google.protobuf.Any;\nimport com.google.protobuf.Message;\nimport com.google.rpc.ErrorInfo;\nimport com.google.rpc.RetryInfo;\nimport io.grpc.BindableService;\nimport io.grpc.ServerInterceptors;\nimport io.grpc.Status;\nimport io.grpc.StatusException;\nimport io.grpc.StatusRuntimeException;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.UUID;\nimport io.grpc.protobuf.StatusProto;\nimport org.junit.jupiter.api.Assertions;\nimport org.junit.jupiter.api.function.Executable;\nimport org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\n\npublic final class GrpcTestUtils {\n\n  private GrpcTestUtils() {\n    // noop\n  }\n\n  public static void assertStatusException(final Status expected, final Executable serviceCall) {\n    final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);\n    assertEquals(expected.getCode(), exception.getStatus().getCode());\n  }\n\n  public static void assertStatusException(final Status expected, final String expectedReason, final Executable serviceCall) {\n    final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);\n    assertEquals(expected.getCode(), exception.getStatus().getCode());\n    assertEquals(expectedReason, extractErrorInfo(exception).getReason());\n  }\n\n  public static void assertStatusInvalidArgument(final Executable serviceCall) {\n    assertStatusException(Status.INVALID_ARGUMENT, serviceCall);\n  }\n\n  public static void assertStatusUnauthenticated(final Executable serviceCall) {\n    assertStatusException(Status.UNAUTHENTICATED, serviceCall);\n  }\n\n  public static void assertStatusPermissionDenied(final Executable serviceCall) {\n    assertStatusException(Status.PERMISSION_DENIED, serviceCall);\n  }\n\n  public static void assertRateLimitExceeded(\n      final Duration expectedRetryAfter,\n      final Executable serviceCall,\n      final Object... mocksToCheckForNoInteraction) {\n    final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);\n    assertEquals(Status.RESOURCE_EXHAUSTED.getCode(), exception.getStatus().getCode());\n    assertNotNull(exception.getTrailers());\n\n    final ErrorInfo errorInfo = extractErrorInfo(exception);\n    final RetryInfo retryInfo = extractDetail(RetryInfo.class, exception);\n    final Duration actual = Duration.ofSeconds(retryInfo.getRetryDelay().getSeconds(), retryInfo.getRetryDelay().getNanos());\n    assertEquals(errorInfo.getDomain(), GrpcExceptions.DOMAIN);\n    assertEquals(errorInfo.getReason(), \"RESOURCE_EXHAUSTED\");\n    assertEquals(expectedRetryAfter, actual);\n\n    for (final Object mock: mocksToCheckForNoInteraction) {\n      verifyNoInteractions(mock);\n    }\n  }\n\n  public static ErrorInfo extractErrorInfo(final StatusRuntimeException exception) {\n    return extractDetail(ErrorInfo.class, exception);\n  }\n\n  public static <T extends Message> T extractDetail(final Class<T> detailCls, final StatusRuntimeException exception) {\n    final com.google.rpc.Status status = StatusProto.fromThrowable(exception);\n\n    return assertDoesNotThrow(() -> status.getDetailsList().stream()\n        .filter(any -> any.is(detailCls)).findFirst()\n        .orElseThrow(() -> new AssertionError(\"No error info found\"))\n        .unpack(detailCls));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeyTransparencyGrpcServiceTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Channel;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.Mock;\nimport org.mockito.Mockito;\nimport org.signal.keytransparency.client.AciMonitorRequest;\nimport org.signal.keytransparency.client.ConsistencyParameters;\nimport org.signal.keytransparency.client.DistinguishedRequest;\nimport org.signal.keytransparency.client.DistinguishedResponse;\nimport org.signal.keytransparency.client.E164MonitorRequest;\nimport org.signal.keytransparency.client.E164SearchRequest;\nimport org.signal.keytransparency.client.KeyTransparencyQueryServiceGrpc;\nimport org.signal.keytransparency.client.MonitorRequest;\nimport org.signal.keytransparency.client.MonitorResponse;\nimport org.signal.keytransparency.client.SearchRequest;\nimport org.signal.keytransparency.client.SearchResponse;\nimport org.signal.keytransparency.client.UsernameHashMonitorRequest;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.keytransparency.KeyTransparencyServiceClient;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport reactor.core.publisher.Mono;\n\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.controllers.KeyTransparencyControllerTest.ACI;\nimport static org.whispersystems.textsecuregcm.controllers.KeyTransparencyControllerTest.ACI_IDENTITY_KEY;\nimport static org.whispersystems.textsecuregcm.controllers.KeyTransparencyControllerTest.NUMBER;\nimport static org.whispersystems.textsecuregcm.controllers.KeyTransparencyControllerTest.UNIDENTIFIED_ACCESS_KEY;\nimport static org.whispersystems.textsecuregcm.controllers.KeyTransparencyControllerTest.USERNAME_HASH;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\nimport static org.whispersystems.textsecuregcm.grpc.KeyTransparencyGrpcService.COMMITMENT_INDEX_LENGTH;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic class KeyTransparencyGrpcServiceTest extends SimpleBaseGrpcTest<KeyTransparencyGrpcService, KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub>{\n  @Mock\n  private KeyTransparencyServiceClient keyTransparencyServiceClient;\n  @Mock\n  private RateLimiter rateLimiter;\n\n  @Override\n  protected KeyTransparencyGrpcService createServiceBeforeEachTest() {\n    final RateLimiters rateLimiters = mock(RateLimiters.class);\n    when(rateLimiters.getKeyTransparencySearchLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getKeyTransparencyDistinguishedLimiter()).thenReturn(rateLimiter);\n    when(rateLimiters.getKeyTransparencyMonitorLimiter()).thenReturn(rateLimiter);\n\n    return new KeyTransparencyGrpcService(rateLimiters, keyTransparencyServiceClient);\n  }\n\n  @Override\n  protected KeyTransparencyQueryServiceGrpc.KeyTransparencyQueryServiceBlockingStub createStub(final Channel channel) {\n    return KeyTransparencyQueryServiceGrpc.newBlockingStub(channel);\n  }\n\n  @Test\n  void searchSuccess() throws RateLimitExceededException {\n    when(keyTransparencyServiceClient.search(any())).thenReturn(SearchResponse.getDefaultInstance());\n    Mockito.doNothing().when(rateLimiter).validate(any(String.class));\n    final SearchRequest request = SearchRequest.newBuilder()\n        .setAci(ByteString.copyFrom(ACI.toCompactByteArray()))\n        .setAciIdentityKey(ByteString.copyFrom(ACI_IDENTITY_KEY.serialize()))\n        .setConsistency(ConsistencyParameters.newBuilder()\n            .setDistinguished(10)\n            .build())\n        .build();\n\n    assertDoesNotThrow(() -> unauthenticatedServiceStub().search(request));\n    verify(keyTransparencyServiceClient, times(1)).search(eq(request));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void searchInvalidRequest(final Optional<byte[]> aciServiceIdentifier,\n      final Optional<IdentityKey> aciIdentityKey,\n      final Optional<String> e164,\n      final Optional<byte[]> unidentifiedAccessKey,\n      final Optional<byte[]> usernameHash,\n      final Optional<Long> lastTreeHeadSize,\n      final Optional<Long> distinguishedTreeHeadSize) {\n\n    final SearchRequest.Builder requestBuilder = SearchRequest.newBuilder();\n\n    aciServiceIdentifier.ifPresent(v -> requestBuilder.setAci(ByteString.copyFrom(v)));\n    aciIdentityKey.ifPresent(v -> requestBuilder.setAciIdentityKey(ByteString.copyFrom(v.serialize())));\n    usernameHash.ifPresent(v -> requestBuilder.setUsernameHash(ByteString.copyFrom(v)));\n\n    final E164SearchRequest.Builder e164RequestBuilder = E164SearchRequest.newBuilder();\n\n    e164.ifPresent(e164RequestBuilder::setE164);\n    unidentifiedAccessKey.ifPresent(v -> e164RequestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(v)));\n    requestBuilder.setE164SearchRequest(e164RequestBuilder.build());\n\n    final ConsistencyParameters.Builder consistencyBuilder = ConsistencyParameters.newBuilder();\n    distinguishedTreeHeadSize.ifPresent(consistencyBuilder::setDistinguished);\n    lastTreeHeadSize.ifPresent(consistencyBuilder::setLast);\n    requestBuilder.setConsistency(consistencyBuilder.build());\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().search(requestBuilder.build()));\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  private static Stream<Arguments> searchInvalidRequest() {\n    byte[] aciBytes = ACI.toCompactByteArray();\n    return Stream.of(\n        Arguments.argumentSet(\"Empty ACI\", Optional.empty(), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),\n        Arguments.argumentSet(\"Null ACI identity key\", Optional.of(aciBytes), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid ACI\", Optional.of(new byte[15]), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),\n        Arguments.argumentSet(\"Non-positive consistency.last\", Optional.of(aciBytes), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(0L), Optional.of(4L)),\n        Arguments.argumentSet(\"consistency.distinguished not provided\",Optional.of(aciBytes), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()),\n        Arguments.argumentSet(\"Non-positive consistency.distinguished\",Optional.of(aciBytes), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(0L)),\n        Arguments.argumentSet(\"E164 can't be provided without an unidentified access key\", Optional.of(aciBytes), Optional.of(ACI_IDENTITY_KEY), Optional.of(NUMBER), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L)),\n        Arguments.argumentSet(\"Unidentified access key can't be provided without E164\", Optional.of(aciBytes), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.of(UNIDENTIFIED_ACCESS_KEY), Optional.empty(), Optional.empty(), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid username hash\", Optional.of(aciBytes), Optional.of(ACI_IDENTITY_KEY), Optional.empty(), Optional.empty(), Optional.of(new byte[19]), Optional.empty(), Optional.of(4L))\n    );\n  }\n\n  @Test\n  void searchRatelimited() throws RateLimitExceededException {\n    final Duration retryAfterDuration = Duration.ofMinutes(7);\n    Mockito.doThrow(new RateLimitExceededException(retryAfterDuration)).when(rateLimiter).validate(any(String.class));\n\n    final SearchRequest request = SearchRequest.newBuilder()\n        .setAci(ByteString.copyFrom(ACI.toCompactByteArray()))\n        .setAciIdentityKey(ByteString.copyFrom(ACI_IDENTITY_KEY.serialize()))\n        .setConsistency(ConsistencyParameters.newBuilder()\n            .setDistinguished(10)\n            .build())\n        .build();\n    assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().search(request));\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  @Test\n  void monitorSuccess() {\n    when(keyTransparencyServiceClient.monitor(any())).thenReturn(MonitorResponse.getDefaultInstance());\n    when(rateLimiter.validateReactive(any(String.class)))\n        .thenReturn(Mono.empty());\n    final AciMonitorRequest aciMonitorRequest = AciMonitorRequest.newBuilder()\n        .setAci(ByteString.copyFrom(ACI.toCompactByteArray()))\n        .setCommitmentIndex(ByteString.copyFrom(new byte[COMMITMENT_INDEX_LENGTH]))\n        .setEntryPosition(10)\n        .build();\n\n    final MonitorRequest request = MonitorRequest.newBuilder()\n        .setAci(aciMonitorRequest)\n        .setConsistency(ConsistencyParameters.newBuilder()\n            .setDistinguished(10)\n            .setLast(10)\n            .build())\n        .build();\n\n    assertDoesNotThrow(() -> unauthenticatedServiceStub().monitor(request));\n    verify(keyTransparencyServiceClient, times(1)).monitor(eq(request));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void monitorInvalidRequest(final Optional<AciMonitorRequest> aciMonitorRequest,\n      final Optional<E164MonitorRequest> e164MonitorRequest,\n      final Optional<UsernameHashMonitorRequest> usernameHashMonitorRequest,\n      final Optional<Long> lastTreeHeadSize,\n      final Optional<Long> distinguishedTreeHeadSize) {\n\n    final MonitorRequest.Builder requestBuilder = MonitorRequest.newBuilder();\n\n    aciMonitorRequest.ifPresent(requestBuilder::setAci);\n    e164MonitorRequest.ifPresent(requestBuilder::setE164);\n    usernameHashMonitorRequest.ifPresent(requestBuilder::setUsernameHash);\n\n    final ConsistencyParameters.Builder consistencyBuilder = ConsistencyParameters.newBuilder();\n    lastTreeHeadSize.ifPresent(consistencyBuilder::setLast);\n    distinguishedTreeHeadSize.ifPresent(consistencyBuilder::setDistinguished);\n\n    requestBuilder.setConsistency(consistencyBuilder.build());\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().monitor(requestBuilder.build()));\n  }\n\n  private static Stream<Arguments> monitorInvalidRequest() {\n    final Optional<AciMonitorRequest> validAciMonitorRequest = Optional.of(constructAciMonitorRequest(ACI.toCompactByteArray(), new byte[32], 10));\n    return Stream.of(\n        Arguments.argumentSet(\"ACI monitor request can't be unset\", Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"ACI can't be empty\",Optional.of(AciMonitorRequest.newBuilder().build()), Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Empty ACI on ACI monitor request\",Optional.of(constructAciMonitorRequest(new byte[0], new byte[32], 10)), Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid ACI\", Optional.of(constructAciMonitorRequest(new byte[15], new byte[32], 10)), Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid commitment index on ACI monitor request\", Optional.of(constructAciMonitorRequest(ACI.toCompactByteArray(), new byte[31], 10)), Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid entry position on ACI monitor request\", Optional.of(constructAciMonitorRequest(ACI.toCompactByteArray(), new byte[32], 0)), Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"E164 can't be blank\", validAciMonitorRequest, Optional.of(constructE164MonitorRequest(\"\", new byte[32], 10)), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid commitment index on E164 monitor request\", validAciMonitorRequest, Optional.of(constructE164MonitorRequest(NUMBER, new byte[31], 10)), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid entry position on E164 monitor request\", validAciMonitorRequest, Optional.of(constructE164MonitorRequest(NUMBER, new byte[32], 0)), Optional.empty(), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Username hash can't be empty\", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(new byte[0], new byte[32], 10)), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid username hash length\", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(new byte[31], new byte[32], 10)), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid commitment index on username hash monitor request\", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[31], 10)), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"Invalid entry position on username hash monitor request\", validAciMonitorRequest, Optional.empty(), Optional.of(constructUsernameHashMonitorRequest(USERNAME_HASH, new byte[32], 0)), Optional.of(4L), Optional.of(4L)),\n        Arguments.argumentSet(\"consistency.last must be provided\", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(4L),\n        Arguments.argumentSet(\"consistency.last must be positive\", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(0L), Optional.of(4L)),\n        Arguments.argumentSet(\"consistency.distinguished must be provided\", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L)), Optional.empty()),\n        Arguments.argumentSet(\"consistency.distinguished must be positive\", validAciMonitorRequest, Optional.empty(), Optional.empty(), Optional.of(4L), Optional.of(0L))\n    );\n  }\n\n  @Test\n  void monitorRatelimited() throws RateLimitExceededException {\n    final Duration retryAfterDuration = Duration.ofMinutes(7);\n    Mockito.doThrow(new RateLimitExceededException(retryAfterDuration)).when(rateLimiter).validate(any(String.class));\n\n    final AciMonitorRequest aciMonitorRequest = AciMonitorRequest.newBuilder()\n        .setAci(ByteString.copyFrom(ACI.toCompactByteArray()))\n        .setCommitmentIndex(ByteString.copyFrom(new byte[COMMITMENT_INDEX_LENGTH]))\n        .setEntryPosition(10)\n        .build();\n\n    final MonitorRequest request = MonitorRequest.newBuilder()\n        .setAci(aciMonitorRequest)\n        .setConsistency(ConsistencyParameters.newBuilder()\n            .setDistinguished(10)\n            .setLast(10)\n            .build())\n        .build();\n    assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().monitor(request));\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  @Test\n  void distinguishedSuccess() {\n    when(keyTransparencyServiceClient.distinguished(any())).thenReturn(DistinguishedResponse.getDefaultInstance());\n    when(rateLimiter.validateReactive(any(String.class)))\n        .thenReturn(Mono.empty());\n    final DistinguishedRequest request = DistinguishedRequest.newBuilder().build();\n\n    assertDoesNotThrow(() -> unauthenticatedServiceStub().distinguished(request));\n    verify(keyTransparencyServiceClient, times(1)).distinguished(eq(request));\n  }\n\n  @Test\n  void distinguishedInvalidRequest() {\n    final DistinguishedRequest request = DistinguishedRequest.newBuilder()\n        .setLast(0)\n        .build();\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().distinguished(request));\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  @Test\n  void distinguishedRatelimited() throws RateLimitExceededException {\n    final Duration retryAfterDuration = Duration.ofMinutes(7);\n    Mockito.doThrow(new RateLimitExceededException(retryAfterDuration)).when(rateLimiter).validate(any(String.class));\n\n    final DistinguishedRequest request = DistinguishedRequest.newBuilder()\n        .setLast(10)\n        .build();\n\n    assertRateLimitExceeded(retryAfterDuration, () -> unauthenticatedServiceStub().distinguished(request));\n    verifyNoInteractions(keyTransparencyServiceClient);\n  }\n\n  private static AciMonitorRequest constructAciMonitorRequest(final byte[] aci, final byte[] commitmentIndex, final long entryPosition) {\n    return AciMonitorRequest.newBuilder()\n        .setAci(ByteString.copyFrom(aci))\n        .setCommitmentIndex(ByteString.copyFrom(commitmentIndex))\n        .setEntryPosition(entryPosition)\n        .build();\n  }\n\n  private static E164MonitorRequest constructE164MonitorRequest(final String e164, final byte[] commitmentIndex, final long entryPosition) {\n    return E164MonitorRequest.newBuilder()\n        .setE164(e164)\n        .setCommitmentIndex(ByteString.copyFrom(commitmentIndex))\n        .setEntryPosition(entryPosition)\n        .build();\n  }\n\n  private static UsernameHashMonitorRequest constructUsernameHashMonitorRequest(final byte[] usernameHash, final byte[] commitmentIndex, final long entryPosition) {\n    return UsernameHashMonitorRequest.newBuilder()\n        .setUsernameHash(ByteString.copyFrom(usernameHash))\n        .setCommitmentIndex(ByteString.copyFrom(commitmentIndex))\n        .setEntryPosition(entryPosition)\n        .build();\n  }\n\n  @Override\n  protected List<ServerInterceptor> customizeInterceptors(List<ServerInterceptor> serverInterceptors) {\n    return serverInterceptors.stream()\n        // For now, don't validate conformance of KeyTransparency errors since they are forwarded directly from a\n        // backing service\n        .filter(interceptor -> !(interceptor instanceof ErrorConformanceInterceptor))\n        .toList();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysAnonymousGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.junit.jupiter.api.Assertions.fail;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport io.grpc.Status;\nimport io.grpc.stub.StreamObserver;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.Mock;\nimport org.signal.chat.common.EcPreKey;\nimport org.signal.chat.common.EcSignedPreKey;\nimport org.signal.chat.common.KemSignedPreKey;\nimport org.signal.chat.common.ServiceIdentifier;\nimport org.signal.chat.keys.AccountPreKeyBundles;\nimport org.signal.chat.keys.CheckIdentityKeyRequest;\nimport org.signal.chat.keys.CheckIdentityKeyResponse;\nimport org.signal.chat.keys.DevicePreKeyBundle;\nimport org.signal.chat.keys.GetPreKeysAnonymousRequest;\nimport org.signal.chat.keys.GetPreKeysAnonymousResponse;\nimport org.signal.chat.keys.GetPreKeysRequest;\nimport org.signal.chat.keys.KeysAnonymousGrpc;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.KeyIdUtil;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\n\nclass KeysAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<KeysAnonymousGrpcService, KeysAnonymousGrpc.KeysAnonymousBlockingStub> {\n\n  private static final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();\n  private static final TestClock CLOCK = TestClock.now();\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private KeysManager keysManager;\n\n  @Override\n  protected KeysAnonymousGrpcService createServiceBeforeEachTest() {\n    return new KeysAnonymousGrpcService(accountsManager, keysManager, SERVER_SECRET_PARAMS, CLOCK);\n  }\n\n  @Test\n  void getPreKeysUnidentifiedAccessKey() {\n    final Account targetAccount = mock(Account.class);\n\n    final Device targetDevice = DevicesHelper.createDevice(Device.PRIMARY_ID);\n    when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(targetDevice));\n\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final UUID uuid = UUID.randomUUID();\n    final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey);\n    when(accountsManager.getByServiceIdentifier(identifier))\n        .thenReturn(Optional.of(targetAccount));\n\n    final ECPreKey ecPreKey = new ECPreKey(1, ECKeyPair.generate().getPublicKey());\n    final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair);\n    final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair);\n    final KeysManager.DevicePreKeys devicePreKeys =\n        new KeysManager.DevicePreKeys(ecSignedPreKey, Optional.of(ecPreKey), kemSignedPreKey);\n\n    when(keysManager.takeDevicePreKeys(eq(Device.PRIMARY_ID), eq(identifier), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(devicePreKeys)));\n\n    final GetPreKeysAnonymousResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetPreKeysRequest.newBuilder()\n            .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))\n            .setDeviceId(Device.PRIMARY_ID))\n        .build());\n\n    final GetPreKeysAnonymousResponse expectedResponse = GetPreKeysAnonymousResponse.newBuilder()\n        .setPreKeys(AccountPreKeyBundles.newBuilder()\n            .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n            .putDevicePreKeys(Device.PRIMARY_ID, DevicePreKeyBundle.newBuilder()\n                .setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey))\n                .setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey))\n                .setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey))\n                .build()))\n        .build();\n\n    assertEquals(expectedResponse, response);\n  }\n\n  @Test\n  void getPreKeysGroupSendEndorsement() throws Exception {\n    final Account targetAccount = mock(Account.class);\n\n    final Device targetDevice = DevicesHelper.createDevice(Device.PRIMARY_ID);\n    when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(targetDevice));\n\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final UUID uuid = UUID.randomUUID();\n    final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey);\n    when(accountsManager.getByServiceIdentifier(identifier))\n        .thenReturn(Optional.of(targetAccount));\n\n    final ECPreKey ecPreKey = new ECPreKey(1, ECKeyPair.generate().getPublicKey());\n    final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair);\n    final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair);\n    final KeysManager.DevicePreKeys devicePreKeys =\n        new KeysManager.DevicePreKeys(ecSignedPreKey, Optional.of(ecPreKey), kemSignedPreKey);\n\n    when(keysManager.takeDevicePreKeys(eq(Device.PRIMARY_ID), eq(identifier), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(devicePreKeys)));\n\n    // Expirations must be on day boundaries or libsignal will refuse to create or verify the token\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(identifier), expiration);\n\n    final GetPreKeysAnonymousResponse response = unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()\n        .setGroupSendToken(ByteString.copyFrom(token))\n        .setRequest(GetPreKeysRequest.newBuilder()\n            .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))\n            .setDeviceId(Device.PRIMARY_ID))\n        .build());\n\n    final GetPreKeysAnonymousResponse expectedResponse = GetPreKeysAnonymousResponse.newBuilder()\n        .setPreKeys(AccountPreKeyBundles.newBuilder()\n            .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n            .putDevicePreKeys(Device.PRIMARY_ID, DevicePreKeyBundle.newBuilder()\n                .setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey))\n                .setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey))\n                .setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey))\n                .build()))\n        .build();\n\n    assertEquals(expectedResponse, response);\n  }\n\n  @CartesianTest\n  void getPreKeysUnrestricted(@CartesianTest.Values(booleans = {true, false}) boolean includeUak) {\n    final Account targetAccount = mock(Account.class);\n\n    final Device targetDevice = DevicesHelper.createDevice(Device.PRIMARY_ID);\n    when(targetAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(targetDevice));\n\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final UUID uuid = UUID.randomUUID();\n    final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(targetAccount.isUnrestrictedUnidentifiedAccess()).thenReturn(true);\n    when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(targetAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n    when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(identityKey);\n    when(accountsManager.getByServiceIdentifier(identifier))\n        .thenReturn(Optional.of(targetAccount));\n\n    final ECPreKey ecPreKey = new ECPreKey(1, ECKeyPair.generate().getPublicKey());\n    final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(2, identityKeyPair);\n    final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(3, identityKeyPair);\n    final KeysManager.DevicePreKeys devicePreKeys =\n        new KeysManager.DevicePreKeys(ecSignedPreKey, Optional.of(ecPreKey), kemSignedPreKey);\n\n    when(keysManager.takeDevicePreKeys(eq(Device.PRIMARY_ID), eq(identifier), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(devicePreKeys)));\n    final GetPreKeysAnonymousRequest.Builder request = GetPreKeysAnonymousRequest.newBuilder()\n        .setRequest(GetPreKeysRequest.newBuilder()\n            .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))\n            .setDeviceId(Device.PRIMARY_ID));\n\n    if (includeUak) {\n      request.setUnidentifiedAccessKey(ByteString.copyFrom(TestRandomUtil.nextBytes(16)));\n    } else {\n      request.setUnrestrictedAccess(Empty.getDefaultInstance());\n    }\n\n    final GetPreKeysAnonymousResponse response = unauthenticatedServiceStub().getPreKeys(request.build());\n    final GetPreKeysAnonymousResponse expectedResponse = GetPreKeysAnonymousResponse.newBuilder()\n        .setPreKeys(AccountPreKeyBundles.newBuilder()\n            .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n            .putDevicePreKeys(Device.PRIMARY_ID, DevicePreKeyBundle.newBuilder()\n                .setEcOneTimePreKey(toGrpcEcPreKey(ecPreKey))\n                .setEcSignedPreKey(toGrpcEcSignedPreKey(ecSignedPreKey))\n                .setKemOneTimePreKey(toGrpcKemSignedPreKey(kemSignedPreKey))\n                .build()))\n        .build();\n\n    assertEquals(expectedResponse, response);\n  }\n\n\n  @Test\n  void getPreKeysNoAuth() {\n    assertGetKeysFailure(Status.INVALID_ARGUMENT, GetPreKeysAnonymousRequest.newBuilder()\n        .setRequest(GetPreKeysRequest.newBuilder()\n            .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID())))\n            .setDeviceId(Device.PRIMARY_ID))\n        .build());\n\n    verifyNoInteractions(accountsManager);\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void getPreKeysIncorrectUnidentifiedAccessKey() {\n    final Account targetAccount = mock(Account.class);\n\n    final UUID uuid = UUID.randomUUID();\n    final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(accountsManager.getByServiceIdentifier(identifier))\n        .thenReturn(Optional.of(targetAccount));\n\n    final GetPreKeysAnonymousResponse response = unauthenticatedServiceStub().getPreKeys(\n        GetPreKeysAnonymousRequest.newBuilder()\n            .setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID()))\n            .setRequest(GetPreKeysRequest.newBuilder()\n                .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))\n                .setDeviceId(Device.PRIMARY_ID))\n            .build());\n\n    assertTrue(response.hasFailedUnidentifiedAuthorization());\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void getPreKeysExpiredGroupSendEndorsement() throws Exception {\n    final UUID uuid = UUID.randomUUID();\n    final AciServiceIdentifier identifier = new AciServiceIdentifier(uuid);\n\n    // Expirations must be on day boundaries or libsignal will refuse to create or verify the token\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    CLOCK.pin(expiration.plus(Duration.ofHours(1))); // set time so our token is already expired\n\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(identifier), expiration);\n\n    final GetPreKeysAnonymousResponse preKeysResponse =\n        unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()\n            .setGroupSendToken(ByteString.copyFrom(token))\n            .setRequest(GetPreKeysRequest.newBuilder()\n                .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(identifier))\n                .setDeviceId(Device.PRIMARY_ID))\n            .build());\n    assertTrue(preKeysResponse.hasFailedUnidentifiedAuthorization());\n\n    verifyNoInteractions(accountsManager);\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void getPreKeysIncorrectGroupSendEndorsement() throws Exception {\n    final AciServiceIdentifier authorizedIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final AciServiceIdentifier targetIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    // Expirations must be on day boundaries or libsignal will refuse to create or verify the token\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet\n\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(authorizedIdentifier), expiration);\n\n    final GetPreKeysAnonymousResponse response = unauthenticatedServiceStub().getPreKeys(\n        GetPreKeysAnonymousRequest.newBuilder()\n            .setGroupSendToken(ByteString.copyFrom(token))\n            .setRequest(GetPreKeysRequest.newBuilder()\n                .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(targetIdentifier))\n                .setDeviceId(Device.PRIMARY_ID))\n            .build());\n    assertTrue(response.hasFailedUnidentifiedAuthorization());\n    verifyNoInteractions(accountsManager);\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void getPreKeysAccountNotFoundUnidentifiedAccessKey() {\n    final AciServiceIdentifier nonexistentAci = new AciServiceIdentifier(UUID.randomUUID());\n    when(accountsManager.getByServiceIdentifier(nonexistentAci))\n        .thenReturn(Optional.empty());\n\n    final GetPreKeysAnonymousResponse preKeysResponse =\n        unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()\n            .setUnidentifiedAccessKey(UUIDUtil.toByteString(UUID.randomUUID()))\n            .setRequest(GetPreKeysRequest.newBuilder()\n                .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci)))\n            .build());\n    assertTrue(preKeysResponse.hasFailedUnidentifiedAuthorization());\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void getPreKeysAccountNotFoundGroupSendEndorsement() throws Exception {\n    final AciServiceIdentifier nonexistentAci = new AciServiceIdentifier(UUID.randomUUID());\n\n    // Expirations must be on day boundaries or libsignal will refuse to create or verify the token\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    CLOCK.pin(expiration.minus(Duration.ofHours(1))); // set time so the credential isn't expired yet\n\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(nonexistentAci), expiration);\n\n    when(accountsManager.getByServiceIdentifier(nonexistentAci))\n        .thenReturn(Optional.empty());\n\n    final GetPreKeysAnonymousResponse preKeysResponse =\n        unauthenticatedServiceStub().getPreKeys(GetPreKeysAnonymousRequest.newBuilder()\n            .setGroupSendToken(ByteString.copyFrom(token))\n            .setRequest(GetPreKeysRequest.newBuilder()\n                .setTargetIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(nonexistentAci)))\n        .build());\n    assertTrue(preKeysResponse.hasTargetNotFound());\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void getPreKeysDeviceNotFound() {\n    final UUID accountIdentifier = UUID.randomUUID();\n\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    final Account targetAccount = mock(Account.class);\n    when(targetAccount.getUuid()).thenReturn(accountIdentifier);\n    when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(ECKeyPair.generate().getPublicKey()));\n    when(targetAccount.getDevices()).thenReturn(Collections.emptyList());\n    when(targetAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(targetAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(accountIdentifier)))\n        .thenReturn(Optional.of(targetAccount));\n\n    final GetPreKeysAnonymousResponse response = unauthenticatedServiceStub().getPreKeys(\n        GetPreKeysAnonymousRequest.newBuilder()\n            .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n            .setRequest(GetPreKeysRequest.newBuilder()\n                .setTargetIdentifier(ServiceIdentifier.newBuilder()\n                    .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n                    .setUuid(UUIDUtil.toByteString(accountIdentifier)))\n                .setDeviceId(Device.PRIMARY_ID))\n            .build());\n\n    assertTrue(response.hasFailedUnidentifiedAuthorization());\n  }\n\n  @Test\n  void checkIdentityKeys() throws InterruptedException {\n    final KeysAnonymousGrpc.KeysAnonymousStub keysAnonymousStub =\n        KeysAnonymousGrpc.newStub(SimpleBaseGrpcTest.GRPC_SERVER_EXTENSION_UNAUTHENTICATED.getChannel());\n\n    when(accountsManager.getByServiceIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    final Account mismatchedAciFingerprintAccount = mock(Account.class);\n    final UUID mismatchedAciFingerprintAccountIdentifier = UUID.randomUUID();\n    final IdentityKey mismatchedAciFingerprintAccountIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n    final Account matchingAciFingerprintAccount = mock(Account.class);\n    final UUID matchingAciFingerprintAccountIdentifier = UUID.randomUUID();\n    final IdentityKey matchingAciFingerprintAccountIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n    final Account mismatchedPniFingerprintAccount = mock(Account.class);\n    final UUID mismatchedPniFingerprintAccountIdentifier = UUID.randomUUID();\n    final IdentityKey mismatchedPniFingerpringAccountIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n    when(mismatchedAciFingerprintAccount.getIdentityKey(IdentityType.ACI)).thenReturn(mismatchedAciFingerprintAccountIdentityKey);\n    when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(mismatchedAciFingerprintAccountIdentifier)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(mismatchedAciFingerprintAccount)));\n\n    when(matchingAciFingerprintAccount.getIdentityKey(IdentityType.ACI)).thenReturn(matchingAciFingerprintAccountIdentityKey);\n    when(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(matchingAciFingerprintAccountIdentifier)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(matchingAciFingerprintAccount)));\n\n    when(mismatchedPniFingerprintAccount.getIdentityKey(IdentityType.PNI)).thenReturn(mismatchedPniFingerpringAccountIdentityKey);\n    when(accountsManager.getByServiceIdentifierAsync(new PniServiceIdentifier(mismatchedPniFingerprintAccountIdentifier)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(mismatchedPniFingerprintAccount)));\n\n    final Map<UUID, IdentityKey> expectedResponses = Map.of(\n        mismatchedAciFingerprintAccountIdentifier, mismatchedAciFingerprintAccountIdentityKey,\n        mismatchedPniFingerprintAccountIdentifier, mismatchedPniFingerpringAccountIdentityKey);\n\n    final Map<UUID, IdentityKey> responses = new ConcurrentHashMap<>();\n    final CountDownLatch completedLatch = new CountDownLatch(1);\n    final AtomicReference<Throwable> error = new AtomicReference<>();\n\n    final StreamObserver<CheckIdentityKeyRequest> requestStreamObserver =\n        keysAnonymousStub.checkIdentityKeys(new StreamObserver<>() {\n          @Override\n          public void onNext(final CheckIdentityKeyResponse checkIdentityKeyResponse) {\n            try {\n              responses.put(\n                  ServiceIdentifierUtil.fromGrpcServiceIdentifier(checkIdentityKeyResponse.getTargetIdentifier()).uuid(),\n                  new IdentityKey(checkIdentityKeyResponse.getIdentityKey().toByteArray()));\n            } catch (final InvalidKeyException e) {\n              throw new RuntimeException(e);\n            }\n          }\n\n          @Override\n          public void onError(final Throwable throwable) {\n            error.set(throwable);\n            completedLatch.countDown();\n          }\n\n          @Override\n          public void onCompleted() {\n            completedLatch.countDown();\n          }\n        });\n\n    requestStreamObserver.onNext(buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI, mismatchedAciFingerprintAccountIdentifier,\n        new IdentityKey(ECKeyPair.generate().getPublicKey())));\n\n    requestStreamObserver.onNext(buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI, matchingAciFingerprintAccountIdentifier,\n        matchingAciFingerprintAccountIdentityKey));\n\n    requestStreamObserver.onNext(buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI, UUID.randomUUID(),\n        new IdentityKey(ECKeyPair.generate().getPublicKey())));\n\n    requestStreamObserver.onNext(buildCheckIdentityKeyRequest(org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI, mismatchedPniFingerprintAccountIdentifier,\n        new IdentityKey(ECKeyPair.generate().getPublicKey())));\n\n    requestStreamObserver.onCompleted();\n\n    if (!completedLatch.await(5, TimeUnit.SECONDS)) {\n      fail(\"Timed out waiting for countdown latch\");\n    }\n\n    assertNull(error.get());\n    assertEquals(expectedResponses, responses);\n  }\n\n  private static CheckIdentityKeyRequest buildCheckIdentityKeyRequest(final org.signal.chat.common.IdentityType identityType,\n      final UUID uuid, final IdentityKey identityKey) {\n    return CheckIdentityKeyRequest.newBuilder()\n        .setTargetIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(identityType)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(uuid))))\n        .setFingerprint(ByteString.copyFrom(getFingerprint(identityKey)))\n        .build();\n  }\n\n  private static byte[] getFingerprint(final IdentityKey publicKey) {\n    try {\n      return Util.truncate(MessageDigest.getInstance(\"SHA-256\").digest(publicKey.serialize()), 4);\n    } catch (final NoSuchAlgorithmException e) {\n      throw new AssertionError(\"All Java implementations must support SHA-256 MessageDigest algorithm\", e);\n    }\n  }\n\n  private void assertGetKeysFailure(Status code, GetPreKeysAnonymousRequest request) {\n    assertStatusException(code, () -> unauthenticatedServiceStub().getPreKeys(request));\n  }\n\n  private static EcPreKey toGrpcEcPreKey(final ECPreKey preKey) {\n    return EcPreKey.newBuilder()\n        .setKeyId(KeyIdUtil.toUnsignedInt(preKey.keyId()))\n        .setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize()))\n        .build();\n  }\n\n  private static EcSignedPreKey toGrpcEcSignedPreKey(final ECSignedPreKey preKey) {\n    return EcSignedPreKey.newBuilder()\n        .setKeyId(KeyIdUtil.toUnsignedInt(preKey.keyId()))\n        .setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize()))\n        .setSignature(ByteString.copyFrom(preKey.signature()))\n        .build();\n  }\n\n  private static KemSignedPreKey toGrpcKemSignedPreKey(final KEMSignedPreKey preKey) {\n    return KemSignedPreKey.newBuilder()\n        .setKeyId(KeyIdUtil.toUnsignedInt(preKey.keyId()))\n        .setPublicKey(ByteString.copyFrom(preKey.publicKey().serialize()))\n        .setSignature(ByteString.copyFrom(preKey.signature()))\n        .build();\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/KeysGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\n\nimport com.google.protobuf.ByteString;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.Mock;\nimport org.signal.chat.common.EcPreKey;\nimport org.signal.chat.common.EcSignedPreKey;\nimport org.signal.chat.common.KemSignedPreKey;\nimport org.signal.chat.common.ServiceIdentifier;\nimport org.signal.chat.keys.AccountPreKeyBundles;\nimport org.signal.chat.keys.DevicePreKeyBundle;\nimport org.signal.chat.keys.GetPreKeyCountRequest;\nimport org.signal.chat.keys.GetPreKeyCountResponse;\nimport org.signal.chat.keys.GetPreKeysRequest;\nimport org.signal.chat.keys.GetPreKeysResponse;\nimport org.signal.chat.keys.KeysGrpc;\nimport org.signal.chat.keys.SetEcSignedPreKeyRequest;\nimport org.signal.chat.keys.SetKemLastResortPreKeyRequest;\nimport org.signal.chat.keys.SetOneTimeEcPreKeysRequest;\nimport org.signal.chat.keys.SetOneTimeKemSignedPreKeysRequest;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.KeyIdUtil;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\nimport reactor.core.publisher.Mono;\n\nclass KeysGrpcServiceTest extends SimpleBaseGrpcTest<KeysGrpcService, KeysGrpc.KeysBlockingStub> {\n\n  private static final ECKeyPair ACI_IDENTITY_KEY_PAIR = ECKeyPair.generate();\n\n  private static final ECKeyPair PNI_IDENTITY_KEY_PAIR = ECKeyPair.generate();\n\n  protected static final UUID AUTHENTICATED_PNI = UUID.randomUUID();\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private KeysManager keysManager;\n\n  @Mock\n  private RateLimiter preKeysRateLimiter;\n\n  @Mock\n  private Device authenticatedDevice;\n\n\n  @Override\n  protected KeysGrpcService createServiceBeforeEachTest() {\n    final RateLimiters rateLimiters = mock(RateLimiters.class);\n    when(rateLimiters.getPreKeysLimiter()).thenReturn(preKeysRateLimiter);\n\n    when(preKeysRateLimiter.validateReactive(anyString())).thenReturn(Mono.empty());\n\n    when(authenticatedDevice.getId()).thenReturn(AUTHENTICATED_DEVICE_ID);\n\n    final Account authenticatedAccount = mock(Account.class);\n    when(authenticatedAccount.getUuid()).thenReturn(AUTHENTICATED_ACI);\n    when(authenticatedAccount.getPhoneNumberIdentifier()).thenReturn(AUTHENTICATED_PNI);\n    when(authenticatedAccount.getIdentifier(IdentityType.ACI)).thenReturn(AUTHENTICATED_ACI);\n    when(authenticatedAccount.getIdentifier(IdentityType.PNI)).thenReturn(AUTHENTICATED_PNI);\n    when(authenticatedAccount.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(ACI_IDENTITY_KEY_PAIR.getPublicKey()));\n    when(authenticatedAccount.getIdentityKey(IdentityType.PNI)).thenReturn(new IdentityKey(PNI_IDENTITY_KEY_PAIR.getPublicKey()));\n    when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(authenticatedDevice));\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(authenticatedAccount));\n    when(accountsManager.getByPhoneNumberIdentifier(AUTHENTICATED_PNI)).thenReturn(Optional.of(authenticatedAccount));\n\n    when(accountsManager.getByAccountIdentifier(AUTHENTICATED_ACI)).thenReturn(Optional.of(authenticatedAccount));\n    when(accountsManager.getByPhoneNumberIdentifier(AUTHENTICATED_PNI)).thenReturn(Optional.of(authenticatedAccount));\n\n    return new KeysGrpcService(accountsManager, keysManager, rateLimiters);\n  }\n\n  @Test\n  void getPreKeyCount() {\n    when(keysManager.getEcCount(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID))\n        .thenReturn(CompletableFuture.completedFuture(1));\n\n    when(keysManager.getPqCount(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID))\n        .thenReturn(CompletableFuture.completedFuture(2));\n\n    when(keysManager.getEcCount(AUTHENTICATED_PNI, AUTHENTICATED_DEVICE_ID))\n        .thenReturn(CompletableFuture.completedFuture(3));\n\n    when(keysManager.getPqCount(AUTHENTICATED_PNI, AUTHENTICATED_DEVICE_ID))\n        .thenReturn(CompletableFuture.completedFuture(4));\n\n    assertEquals(GetPreKeyCountResponse.newBuilder()\n            .setAciEcPreKeyCount(1)\n            .setAciKemPreKeyCount(2)\n            .setPniEcPreKeyCount(3)\n            .setPniKemPreKeyCount(4)\n            .build(),\n        authenticatedServiceStub().getPreKeyCount(GetPreKeyCountRequest.newBuilder().build()));\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void setOneTimeEcPreKeys(final org.signal.chat.common.IdentityType identityType) {\n    final List<ECPreKey> preKeys = new ArrayList<>();\n\n    for (int keyId = 1; keyId <= 100; keyId++) {\n      preKeys.add(new ECPreKey(keyId, ECKeyPair.generate().getPublicKey()));\n    }\n\n    when(keysManager.storeEcOneTimePreKeys(any(), anyByte(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    //noinspection ResultOfMethodCallIgnored\n    authenticatedServiceStub().setOneTimeEcPreKeys(SetOneTimeEcPreKeysRequest.newBuilder()\n        .setIdentityType(identityType)\n        .addAllPreKeys(preKeys.stream()\n            .map(preKey -> EcPreKey.newBuilder()\n                .setKeyId(KeyIdUtil.toUnsignedInt(preKey.keyId()))\n                .setPublicKey(ByteString.copyFrom(preKey.serializedPublicKey()))\n                .build())\n            .toList())\n        .build());\n\n    final UUID expectedIdentifier = switch (IdentityTypeUtil.fromGrpcIdentityType(identityType)) {\n      case ACI -> AUTHENTICATED_ACI;\n      case PNI -> AUTHENTICATED_PNI;\n    };\n\n    verify(keysManager).storeEcOneTimePreKeys(expectedIdentifier, AUTHENTICATED_DEVICE_ID, preKeys);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setOneTimeEcPreKeysWithError(final SetOneTimeEcPreKeysRequest request) {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setOneTimeEcPreKeys(request));\n  }\n\n  private static Stream<Arguments> setOneTimeEcPreKeysWithError() {\n    final SetOneTimeEcPreKeysRequest prototypeRequest = SetOneTimeEcPreKeysRequest.newBuilder()\n        .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n        .addPreKeys(EcPreKey.newBuilder()\n            .setKeyId(1)\n            .setPublicKey(ByteString.copyFrom(ECKeyPair.generate().getPublicKey().serialize()))\n            .build())\n        .build();\n\n    return Stream.of(\n        // Missing identity type\n        Arguments.of(SetOneTimeEcPreKeysRequest.newBuilder(prototypeRequest)\n            .clearIdentityType()\n            .build()),\n\n        // Invalid public key\n        Arguments.of(SetOneTimeEcPreKeysRequest.newBuilder(prototypeRequest)\n            .setPreKeys(0, EcPreKey.newBuilder(prototypeRequest.getPreKeys(0))\n                .clearPublicKey()\n                .build())\n            .build()),\n\n        // No keys\n        Arguments.of(SetOneTimeEcPreKeysRequest.newBuilder(prototypeRequest)\n            .clearPreKeys()\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void setOneTimeKemSignedPreKeys(final org.signal.chat.common.IdentityType identityType) {\n    final ECKeyPair identityKeyPair = switch (IdentityTypeUtil.fromGrpcIdentityType(identityType)) {\n      case ACI -> ACI_IDENTITY_KEY_PAIR;\n      case PNI -> PNI_IDENTITY_KEY_PAIR;\n    };\n\n    final List<KEMSignedPreKey> preKeys = new ArrayList<>();\n\n    for (int keyId = 1; keyId <= 100; keyId++) {\n      preKeys.add(KeysHelper.signedKEMPreKey(keyId, identityKeyPair));\n    }\n\n    when(keysManager.storeKemOneTimePreKeys(any(), anyByte(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    //noinspection ResultOfMethodCallIgnored\n    authenticatedServiceStub().setOneTimeKemSignedPreKeys(\n        SetOneTimeKemSignedPreKeysRequest.newBuilder()\n            .setIdentityType(identityType)\n            .addAllPreKeys(preKeys.stream()\n                .map(preKey -> KemSignedPreKey.newBuilder()\n                    .setKeyId(KeyIdUtil.toUnsignedInt(preKey.keyId()))\n                    .setPublicKey(ByteString.copyFrom(preKey.serializedPublicKey()))\n                    .setSignature(ByteString.copyFrom(preKey.signature()))\n                    .build())\n                .toList())\n            .build());\n\n    final UUID expectedIdentifier = switch (IdentityTypeUtil.fromGrpcIdentityType(identityType)) {\n      case ACI -> AUTHENTICATED_ACI;\n      case PNI -> AUTHENTICATED_PNI;\n    };\n\n    verify(keysManager).storeKemOneTimePreKeys(expectedIdentifier, AUTHENTICATED_DEVICE_ID, preKeys);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setOneTimeKemSignedPreKeysWithError(final SetOneTimeKemSignedPreKeysRequest request) {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setOneTimeKemSignedPreKeys(request));\n  }\n\n  private static Stream<Arguments> setOneTimeKemSignedPreKeysWithError() {\n    final KEMSignedPreKey signedPreKey = KeysHelper.signedKEMPreKey(1, ACI_IDENTITY_KEY_PAIR);\n\n    final SetOneTimeKemSignedPreKeysRequest prototypeRequest = SetOneTimeKemSignedPreKeysRequest.newBuilder()\n        .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n        .addPreKeys(KemSignedPreKey.newBuilder()\n            .setKeyId(1)\n            .setPublicKey(ByteString.copyFrom(signedPreKey.serializedPublicKey()))\n            .setSignature(ByteString.copyFrom(signedPreKey.signature()))\n            .build())\n        .build();\n\n    return Stream.of(\n        // Missing identity type\n        Arguments.of(SetOneTimeKemSignedPreKeysRequest.newBuilder(prototypeRequest)\n            .clearIdentityType()\n            .build()),\n\n        // Invalid public key\n        Arguments.of(SetOneTimeKemSignedPreKeysRequest.newBuilder(prototypeRequest)\n            .setPreKeys(0, KemSignedPreKey.newBuilder(prototypeRequest.getPreKeys(0))\n                .clearPublicKey()\n                .build())\n            .build()),\n\n        // Invalid signature\n        Arguments.of(SetOneTimeKemSignedPreKeysRequest.newBuilder(prototypeRequest)\n            .setPreKeys(0, KemSignedPreKey.newBuilder(prototypeRequest.getPreKeys(0))\n                .clearSignature()\n                .build())\n            .build()),\n\n        // No keys\n        Arguments.of(SetOneTimeKemSignedPreKeysRequest.newBuilder(prototypeRequest)\n            .clearPreKeys()\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void setSignedPreKey(final org.signal.chat.common.IdentityType identityType) {\n    when(keysManager.storeEcSignedPreKeys(any(), anyByte(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final ECKeyPair identityKeyPair = switch (IdentityTypeUtil.fromGrpcIdentityType(identityType)) {\n      case ACI -> ACI_IDENTITY_KEY_PAIR;\n      case PNI -> PNI_IDENTITY_KEY_PAIR;\n    };\n\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(17, identityKeyPair);\n\n    //noinspection ResultOfMethodCallIgnored\n    authenticatedServiceStub().setEcSignedPreKey(SetEcSignedPreKeyRequest.newBuilder()\n            .setIdentityType(identityType)\n            .setSignedPreKey(EcSignedPreKey.newBuilder()\n                .setKeyId(KeyIdUtil.toUnsignedInt(signedPreKey.keyId()))\n                .setPublicKey(ByteString.copyFrom(signedPreKey.serializedPublicKey()))\n                .setSignature(ByteString.copyFrom(signedPreKey.signature()))\n                .build())\n            .build());\n\n    final UUID expectedIdentifier = switch (identityType) {\n      case IDENTITY_TYPE_ACI -> AUTHENTICATED_ACI;\n      case IDENTITY_TYPE_PNI -> AUTHENTICATED_PNI;\n      default -> throw new IllegalArgumentException(\"Unexpected identity type\");\n    };\n\n    verify(keysManager).storeEcSignedPreKeys(expectedIdentifier, AUTHENTICATED_DEVICE_ID, signedPreKey);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setSignedPreKeyWithError(final SetEcSignedPreKeyRequest request) {\n    final StatusRuntimeException exception =\n        assertThrows(StatusRuntimeException.class, () -> authenticatedServiceStub().setEcSignedPreKey(request));\n\n    assertEquals(Status.INVALID_ARGUMENT.getCode(), exception.getStatus().getCode());\n  }\n\n  private static Stream<Arguments> setSignedPreKeyWithError() {\n    final ECSignedPreKey signedPreKey = KeysHelper.signedECPreKey(17, ACI_IDENTITY_KEY_PAIR);\n\n    final SetEcSignedPreKeyRequest prototypeRequest = SetEcSignedPreKeyRequest.newBuilder()\n        .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n        .setSignedPreKey(EcSignedPreKey.newBuilder()\n            .setKeyId(KeyIdUtil.toUnsignedInt(signedPreKey.keyId()))\n            .setPublicKey(ByteString.copyFrom(signedPreKey.serializedPublicKey()))\n            .setSignature(ByteString.copyFrom(signedPreKey.signature()))\n            .build())\n        .build();\n\n    return Stream.of(\n        // Missing identity type\n        Arguments.of(SetEcSignedPreKeyRequest.newBuilder(prototypeRequest)\n            .clearIdentityType()\n            .build()),\n\n        // Invalid public key\n        Arguments.of(SetEcSignedPreKeyRequest.newBuilder(prototypeRequest)\n                .setSignedPreKey(EcSignedPreKey.newBuilder(prototypeRequest.getSignedPreKey())\n                    .clearPublicKey()\n                    .build())\n                .build()),\n\n        // Invalid signature\n        Arguments.of(SetEcSignedPreKeyRequest.newBuilder(prototypeRequest)\n            .setSignedPreKey(EcSignedPreKey.newBuilder(prototypeRequest.getSignedPreKey())\n                .clearSignature()\n                .build())\n            .build()),\n\n        // Missing key\n        Arguments.of(SetEcSignedPreKeyRequest.newBuilder(prototypeRequest)\n            .clearSignedPreKey()\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void setLastResortPreKey(final org.signal.chat.common.IdentityType identityType) {\n    when(keysManager.storePqLastResort(any(), anyByte(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final ECKeyPair identityKeyPair = switch (IdentityTypeUtil.fromGrpcIdentityType(identityType)) {\n      case ACI -> ACI_IDENTITY_KEY_PAIR;\n      case PNI -> PNI_IDENTITY_KEY_PAIR;\n    };\n\n    final KEMSignedPreKey lastResortPreKey = KeysHelper.signedKEMPreKey(17, identityKeyPair);\n\n    //noinspection ResultOfMethodCallIgnored\n    authenticatedServiceStub().setKemLastResortPreKey(SetKemLastResortPreKeyRequest.newBuilder()\n            .setIdentityType(identityType)\n            .setSignedPreKey(KemSignedPreKey.newBuilder()\n                .setKeyId(KeyIdUtil.toUnsignedInt(lastResortPreKey.keyId()))\n                .setPublicKey(ByteString.copyFrom(lastResortPreKey.serializedPublicKey()))\n                .setSignature(ByteString.copyFrom(lastResortPreKey.signature()))\n                .build())\n            .build());\n\n    final UUID expectedIdentifier = switch (identityType) {\n      case IDENTITY_TYPE_ACI -> AUTHENTICATED_ACI;\n      case IDENTITY_TYPE_PNI -> AUTHENTICATED_PNI;\n      case IDENTITY_TYPE_UNSPECIFIED, UNRECOGNIZED -> throw new AssertionError(\"Bad identity type\");\n    };\n\n    verify(keysManager).storePqLastResort(expectedIdentifier, AUTHENTICATED_DEVICE_ID, lastResortPreKey);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setLastResortPreKeyWithError(final SetKemLastResortPreKeyRequest request) {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setKemLastResortPreKey(request));\n  }\n\n  private static Stream<Arguments> setLastResortPreKeyWithError() {\n    final KEMSignedPreKey lastResortPreKey = KeysHelper.signedKEMPreKey(17, ACI_IDENTITY_KEY_PAIR);\n\n    final SetKemLastResortPreKeyRequest prototypeRequest = SetKemLastResortPreKeyRequest.newBuilder()\n        .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n        .setSignedPreKey(KemSignedPreKey.newBuilder()\n            .setKeyId(KeyIdUtil.toUnsignedInt(lastResortPreKey.keyId()))\n            .setPublicKey(ByteString.copyFrom(lastResortPreKey.serializedPublicKey()))\n            .setSignature(ByteString.copyFrom(lastResortPreKey.signature()))\n            .build())\n        .build();\n\n    return Stream.of(\n        // No identity type\n        Arguments.of(SetKemLastResortPreKeyRequest.newBuilder(prototypeRequest)\n            .clearIdentityType()\n            .build()),\n\n        // Bad public key\n        Arguments.of(SetKemLastResortPreKeyRequest.newBuilder(prototypeRequest)\n            .setSignedPreKey(KemSignedPreKey.newBuilder(prototypeRequest.getSignedPreKey())\n                .clearPublicKey()\n                .build())\n            .build()),\n\n        // Bad signature\n        Arguments.of(SetKemLastResortPreKeyRequest.newBuilder(prototypeRequest)\n            .setSignedPreKey(KemSignedPreKey.newBuilder(prototypeRequest.getSignedPreKey())\n                .clearSignature()\n                .build())\n            .build()),\n\n        // Missing key\n        Arguments.of(SetKemLastResortPreKeyRequest.newBuilder(prototypeRequest)\n            .clearSignedPreKey()\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void getPreKeys(final org.signal.chat.common.IdentityType grpcIdentityType) {\n    final Account targetAccount = mock(Account.class);\n\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n    final UUID identifier = UUID.randomUUID();\n\n    final IdentityType identityType = IdentityTypeUtil.fromGrpcIdentityType(grpcIdentityType);\n    final org.whispersystems.textsecuregcm.identity.ServiceIdentifier serviceIdentifier = switch (identityType) {\n      case PNI -> new PniServiceIdentifier(identifier);\n      case ACI -> new AciServiceIdentifier(identifier);\n    };\n\n    when(targetAccount.getUuid()).thenReturn(UUID.randomUUID());\n    when(targetAccount.getIdentifier(identityType)).thenReturn(identifier);\n    when(targetAccount.getIdentityKey(identityType)).thenReturn(identityKey);\n    when(accountsManager.getByServiceIdentifier(serviceIdentifier))\n        .thenReturn(Optional.of(targetAccount));\n\n    final Map<Byte, KeysManager.DevicePreKeys> devicePreKeysMap = new HashMap<>();\n\n    final Map<Byte, Device> devices = new HashMap<>();\n    final Map<Byte, DevicePreKeyBundle> expectedPreKeyBundles = new HashMap<>();\n\n    final byte deviceId1 = 1;\n    final byte deviceId2 = 2;\n    final Map<Byte, Integer> deviceRegistrations = Map.of(\n        deviceId1, 123,\n        deviceId2, 456\n    );\n\n    for (Map.Entry<Byte, Integer> entry : deviceRegistrations.entrySet()) {\n\n      final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(3, identityKeyPair);\n      final Optional<ECPreKey> maybeEcPreKey = Optional\n          .of(new ECPreKey(1, ECKeyPair.generate().getPublicKey()))\n          .filter(_ -> entry.getKey() == deviceId1);\n      final KEMSignedPreKey kemSignedPreKey = KeysHelper.signedKEMPreKey(2, identityKeyPair);\n\n      devicePreKeysMap.put(entry.getKey(), new KeysManager.DevicePreKeys(ecSignedPreKey, maybeEcPreKey, kemSignedPreKey));\n\n      final DevicePreKeyBundle.Builder builder = DevicePreKeyBundle.newBuilder()\n          .setEcSignedPreKey(EcSignedPreKey.newBuilder()\n              .setKeyId(KeyIdUtil.toUnsignedInt(ecSignedPreKey.keyId()))\n              .setPublicKey(ByteString.copyFrom(ecSignedPreKey.serializedPublicKey()))\n              .setSignature(ByteString.copyFrom(ecSignedPreKey.signature()))\n              .build())\n          .setKemOneTimePreKey(KemSignedPreKey.newBuilder()\n              .setKeyId(KeyIdUtil.toUnsignedInt(kemSignedPreKey.keyId()))\n              .setPublicKey(ByteString.copyFrom(kemSignedPreKey.serializedPublicKey()))\n              .setSignature(ByteString.copyFrom(kemSignedPreKey.signature()))\n              .build())\n          .setRegistrationId(entry.getValue());\n      maybeEcPreKey.ifPresent(ecPreKey -> builder\n            .setEcOneTimePreKey(EcPreKey.newBuilder()\n                .setKeyId(KeyIdUtil.toUnsignedInt(ecPreKey.keyId()))\n                .setPublicKey(ByteString.copyFrom(ecPreKey.serializedPublicKey()))\n                .build()));\n      expectedPreKeyBundles.put(entry.getKey(), builder.build());\n\n      final Device device = mock(Device.class);\n      when(device.getId()).thenReturn(entry.getKey());\n      when(device.getRegistrationId(any())).thenReturn(entry.getValue());\n\n      devices.put(entry.getKey(), device);\n      when(targetAccount.getDevice(entry.getKey())).thenReturn(Optional.of(device));\n    }\n\n    when(targetAccount.getDevices()).thenReturn(new ArrayList<>(devices.values()));\n\n    devicePreKeysMap.forEach((deviceId, preKeys) -> when(keysManager.takeDevicePreKeys(eq(deviceId),\n        eq(serviceIdentifier), any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(preKeys))));\n\n    {\n      final GetPreKeysResponse response = authenticatedServiceStub().getPreKeys(GetPreKeysRequest.newBuilder()\n          .setTargetIdentifier(ServiceIdentifier.newBuilder()\n              .setIdentityType(grpcIdentityType)\n              .setUuid(UUIDUtil.toByteString(identifier))\n              .build())\n          .setDeviceId(1)\n          .build());\n\n      final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder()\n          .setPreKeys(AccountPreKeyBundles.newBuilder()\n              .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n              .putDevicePreKeys(1, expectedPreKeyBundles.get(deviceId1)))\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    {\n      final GetPreKeysResponse response = authenticatedServiceStub().getPreKeys(GetPreKeysRequest.newBuilder()\n          .setTargetIdentifier(ServiceIdentifier.newBuilder()\n              .setIdentityType(grpcIdentityType)\n              .setUuid(UUIDUtil.toByteString(identifier))\n              .build())\n          .build());\n\n      final GetPreKeysResponse expectedResponse = GetPreKeysResponse.newBuilder()\n          .setPreKeys(AccountPreKeyBundles.newBuilder()\n              .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n              .putDevicePreKeys(1, expectedPreKeyBundles.get(deviceId1))\n              .putDevicePreKeys(2, expectedPreKeyBundles.get(deviceId2)))\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n  }\n\n  @Test\n  void getPreKeysAccountNotFound() {\n    when(accountsManager.getByServiceIdentifier(any()))\n        .thenReturn(Optional.empty());\n\n    final GetPreKeysResponse response = authenticatedServiceStub().getPreKeys(GetPreKeysRequest.newBuilder()\n        .setTargetIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(UUIDUtil.toByteString(UUID.randomUUID()))\n            .build())\n        .build());\n    assertTrue(response.hasTargetNotFound());\n  }\n\n  @Test\n  void getPreKeysDeviceNotFound() {\n    final UUID accountIdentifier = UUID.randomUUID();\n\n    final Account targetAccount = mock(Account.class);\n    when(targetAccount.getUuid()).thenReturn(accountIdentifier);\n    when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(ECKeyPair.generate().getPublicKey()));\n    when(targetAccount.getDevices()).thenReturn(Collections.emptyList());\n    when(targetAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(accountIdentifier)))\n        .thenReturn(Optional.of(targetAccount));\n\n    final GetPreKeysResponse response = authenticatedServiceStub().getPreKeys(GetPreKeysRequest.newBuilder()\n        .setTargetIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(UUIDUtil.toByteString(accountIdentifier))\n            .build())\n        .setDeviceId(Device.PRIMARY_ID)\n        .build());\n    assertTrue(response.hasTargetNotFound());\n  }\n\n  @Test\n  void getPreKeysRateLimited() throws RateLimitExceededException {\n    final Account targetAccount = mock(Account.class);\n    when(targetAccount.getUuid()).thenReturn(UUID.randomUUID());\n    when(targetAccount.getIdentityKey(IdentityType.ACI)).thenReturn(new IdentityKey(ECKeyPair.generate().getPublicKey()));\n    when(targetAccount.getDevices()).thenReturn(Collections.emptyList());\n    when(targetAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n\n    when(accountsManager.getByServiceIdentifier(any()))\n        .thenReturn(Optional.of(targetAccount));\n\n    final Duration retryAfterDuration = Duration.ofMinutes(7);\n\n    doThrow(new RateLimitExceededException(retryAfterDuration))\n        .when(preKeysRateLimiter).validate(anyString());\n\n    assertRateLimitExceeded(retryAfterDuration, () -> authenticatedServiceStub().getPreKeys(GetPreKeysRequest.newBuilder()\n        .setTargetIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(UUIDUtil.toByteString(UUID.randomUUID()))\n            .build())\n        .build()));\n    verifyNoInteractions(accountsManager);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/MessagesAnonymousGrpcServiceTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyCollection;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport io.grpc.Status;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.Mock;\nimport org.signal.chat.errors.FailedUnidentifiedAuthorization;\nimport org.signal.chat.messages.ChallengeRequired;\nimport org.signal.chat.messages.IndividualRecipientMessageBundle;\nimport org.signal.chat.messages.SendMessageType;\nimport org.signal.chat.messages.MessagesAnonymousGrpc;\nimport org.signal.chat.messages.MismatchedDevices;\nimport org.signal.chat.messages.MultiRecipientMessage;\nimport org.signal.chat.messages.MultiRecipientMismatchedDevices;\nimport org.signal.chat.messages.MultiRecipientSuccess;\nimport org.signal.chat.messages.SendMessageResponse;\nimport org.signal.chat.messages.SendMultiRecipientMessageRequest;\nimport org.signal.chat.messages.SendMultiRecipientMessageResponse;\nimport org.signal.chat.messages.SendMultiRecipientStoryRequest;\nimport org.signal.chat.messages.SendSealedSenderMessageRequest;\nimport org.signal.chat.messages.SendStoryMessageRequest;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.MultiRecipientMismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;\nimport org.whispersystems.textsecuregcm.spam.MessageType;\nimport org.whispersystems.textsecuregcm.spam.SpamCheckResult;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.tests.util.MultiRecipientMessageHelper;\nimport org.whispersystems.textsecuregcm.tests.util.TestRecipient;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass MessagesAnonymousGrpcServiceTest extends\n    SimpleBaseGrpcTest<MessagesAnonymousGrpcService, MessagesAnonymousGrpc.MessagesAnonymousBlockingStub> {\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private RateLimiters rateLimiters;\n\n  @Mock\n  private MessageSender messageSender;\n\n  @Mock\n  private GroupSendTokenUtil groupSendTokenUtil;\n\n  @Mock\n  private CardinalityEstimator messageByteLimitEstimator;\n\n  @Mock\n  private SpamChecker spamChecker;\n\n  @Mock\n  private RateLimiter rateLimiter;\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  private static final byte[] UNIDENTIFIED_ACCESS_KEY =\n      TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n  private static final byte[] GROUP_SEND_TOKEN = TestRandomUtil.nextBytes(64);\n\n  @Override\n  protected MessagesAnonymousGrpcService createServiceBeforeEachTest() {\n    return new MessagesAnonymousGrpcService(accountsManager,\n        rateLimiters,\n        messageSender,\n        groupSendTokenUtil,\n        messageByteLimitEstimator,\n        spamChecker,\n        CLOCK);\n  }\n\n  @BeforeEach\n  void setUp() {\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n    when(accountsManager.getByServiceIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(rateLimiters.getInboundMessageBytes()).thenReturn(rateLimiter);\n    when(rateLimiters.getStoriesLimiter()).thenReturn(rateLimiter);\n\n    when(groupSendTokenUtil.checkGroupSendToken(any(), any(ServiceIdentifier.class))).thenReturn(false);\n\n    when(groupSendTokenUtil.checkGroupSendToken(any(), anyCollection())).thenReturn(false);\n\n    when(groupSendTokenUtil.checkGroupSendToken(eq(ByteString.copyFrom(GROUP_SEND_TOKEN)), any(ServiceIdentifier.class)))\n        .thenReturn(true);\n\n    when(groupSendTokenUtil.checkGroupSendToken(eq(ByteString.copyFrom(GROUP_SEND_TOKEN)), anyCollection()))\n        .thenReturn(true);\n\n    when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n        .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.empty()));\n\n    when(spamChecker.checkForMultiRecipientSpamGrpc(any()))\n        .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.empty()));\n  }\n\n  @Nested\n  class SingleRecipient {\n\n    @CartesianTest\n    void sendMessage(@CartesianTest.Values(booleans = {true, false}) final boolean useUak,\n        @CartesianTest.Values(booleans = {true, false}) final boolean ephemeral,\n        @CartesianTest.Values(booleans = {true, false}) final boolean urgent,\n        @CartesianTest.Values(booleans = {true, false}) final boolean includeReportSpamToken)\n        throws MessageTooLargeException, MismatchedDevicesException {\n\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(payload))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      final byte[] reportSpamToken = TestRandomUtil.nextBytes(64);\n\n      if (includeReportSpamToken) {\n        when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n            .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.of(reportSpamToken)));\n      }\n\n      final SendMessageResponse response = unauthenticatedServiceStub().sendSingleRecipientMessage(\n          generateRequest(serviceIdentifier, ephemeral, urgent, messages,\n              useUak ? UNIDENTIFIED_ACCESS_KEY : null,\n              useUak ? null : GROUP_SEND_TOKEN));\n\n      assertEquals(SendMessageResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);\n\n      final MessageProtos.Envelope.Builder expectedEnvelopeBuilder = MessageProtos.Envelope.newBuilder()\n          .setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER)\n          .setDestinationServiceId(serviceIdentifier.toServiceIdentifierString())\n          .setClientTimestamp(CLOCK.millis())\n          .setServerTimestamp(CLOCK.millis())\n          .setEphemeral(ephemeral)\n          .setUrgent(urgent)\n          .setContent(ByteString.copyFrom(payload));\n\n      if (includeReportSpamToken) {\n        expectedEnvelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken));\n      }\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_SEALED_SENDER,\n          Optional.empty(),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender).sendMessages(destinationAccount,\n          serviceIdentifier,\n          Map.of(deviceId, expectedEnvelopeBuilder.build()),\n          Map.of(deviceId, registrationId),\n          Optional.empty(),\n          null);\n    }\n\n\n    @Test\n    void wrongMessageType() {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .setPayload(ByteString.copyFrom(payload))\n              .build());\n      final byte[] reportSpamToken = TestRandomUtil.nextBytes(64);\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n          () -> unauthenticatedServiceStub().sendSingleRecipientMessage(\n              generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null)));\n      verifyNoInteractions(messageSender);\n    }\n    \n    @CartesianTest\n    void sendUnrestrictedAccessMessage(\n        @CartesianTest.Values(booleans = {true, false}) final boolean useUak,\n        @CartesianTest.Values(booleans = {true, false}) final boolean isUua)\n        throws MessageTooLargeException, MismatchedDevicesException {\n\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      when(destinationAccount.isUnrestrictedUnidentifiedAccess()).thenReturn(isUua);\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(payload))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n      final SendSealedSenderMessageRequest request =\n          generateRequest(serviceIdentifier, false, true, messages, useUak ? TestRandomUtil.nextBytes(16) : null, null);\n      final SendMessageResponse response = unauthenticatedServiceStub().sendSingleRecipientMessage(request);\n      final SendMessageResponse.ResponseCase expectedResponse = isUua\n          ? SendMessageResponse.ResponseCase.SUCCESS\n          : SendMessageResponse.ResponseCase.FAILED_UNIDENTIFIED_AUTHORIZATION;\n      assertEquals(expectedResponse, response.getResponseCase());\n    }\n\n    @Test\n    void mismatchedDevices() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      doThrow(new MismatchedDevicesException(new org.whispersystems.textsecuregcm.controllers.MismatchedDevices(\n          Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId))))\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      final SendMessageResponse response = unauthenticatedServiceStub().sendSingleRecipientMessage(\n          generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null));\n\n      final SendMessageResponse expectedResponse = SendMessageResponse.newBuilder()\n          .setMismatchedDevices(MismatchedDevices.newBuilder()\n              .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n              .addMissingDevices(missingDeviceId)\n              .addStaleDevices(staleDeviceId)\n              .addExtraDevices(extraDeviceId)\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void badCredentials(final boolean useUak) throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      final byte[] incorrectUnidentifiedAccessKey = UNIDENTIFIED_ACCESS_KEY.clone();\n      incorrectUnidentifiedAccessKey[0] += 1;\n\n      final byte[] incorrectGroupSendToken = GROUP_SEND_TOKEN.clone();\n      incorrectGroupSendToken[0] += 1;\n\n      assertEquals(\n          SendMessageResponse.newBuilder()\n              .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n              .build(),\n          unauthenticatedServiceStub().sendSingleRecipientMessage(\n              generateRequest(serviceIdentifier, false, true, messages,\n                  useUak ? incorrectUnidentifiedAccessKey : null,\n                  useUak ? null : incorrectGroupSendToken)));\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void destinationNotFound() throws MessageTooLargeException, MismatchedDevicesException {\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(Device.PRIMARY_ID, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(1234)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      final SendMessageResponse response = unauthenticatedServiceStub().sendSingleRecipientMessage(\n          generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null));\n      assertEquals(\n          SendMessageResponse.newBuilder()\n              .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n              .build(),\n          response);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void pniIdentifierWithUak() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final PniServiceIdentifier pniIdentifier = new PniServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(pniIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      final SendSealedSenderMessageRequest request =\n          generateRequest(pniIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null);\n\n      GrpcTestUtils.assertStatusException(\n          Status.INVALID_ARGUMENT,\n          () -> unauthenticatedServiceStub().sendSingleRecipientMessage(request));\n    }\n\n    @Test\n    void rateLimited() throws RateLimitExceededException, MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Duration retryDuration = Duration.ofHours(7);\n\n      doThrow(new RateLimitExceededException(retryDuration)).when(rateLimiter).validate(eq(serviceIdentifier.uuid()), anyLong());\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertRateLimitExceeded(retryDuration,\n          () -> unauthenticatedServiceStub().sendSingleRecipientMessage(\n              generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null)));\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n      verify(messageByteLimitEstimator).add(serviceIdentifier.uuid().toString());\n    }\n\n    @Test\n    void oversizedMessage() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      doThrow(new MessageTooLargeException())\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n          () -> unauthenticatedServiceStub().sendSingleRecipientMessage(\n              generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null)));\n    }\n\n    @Test\n    void spamWithStatus() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n          .thenReturn(new SpamCheckResult<>(\n              Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),\n              Optional.empty()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED,\n          () -> unauthenticatedServiceStub().sendSingleRecipientMessage(\n              generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null)));\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_SEALED_SENDER,\n          Optional.empty(),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void spamWithResponse() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .build());\n\n      final ChallengeRequired challengeResponse =\n          ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();\n\n      when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n          .thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));\n\n      final SendSealedSenderMessageRequest request =\n          generateRequest(serviceIdentifier, false, true, messages, UNIDENTIFIED_ACCESS_KEY, null);\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () ->\n          unauthenticatedServiceStub().sendSingleRecipientMessage(request));\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_SEALED_SENDER,\n          Optional.empty(),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    private static SendSealedSenderMessageRequest generateRequest(final ServiceIdentifier serviceIdentifier,\n        final boolean ephemeral,\n        final boolean urgent,\n        final Map<Byte, IndividualRecipientMessageBundle.Message> messages,\n        @Nullable final byte[] unidentifiedAccessKey,\n        @Nullable final byte[] groupSendToken) {\n\n      final IndividualRecipientMessageBundle.Builder messageBundleBuilder = IndividualRecipientMessageBundle.newBuilder()\n          .setTimestamp(CLOCK.millis());\n\n      messages.forEach(messageBundleBuilder::putMessages);\n\n      final SendSealedSenderMessageRequest.Builder requestBuilder = SendSealedSenderMessageRequest.newBuilder()\n          .setDestination(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n          .setMessages(messageBundleBuilder)\n          .setEphemeral(ephemeral)\n          .setUrgent(urgent);\n\n      if (unidentifiedAccessKey != null) {\n        requestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey));\n      }\n\n      if (groupSendToken != null) {\n        requestBuilder.setGroupSendToken(ByteString.copyFrom(groupSendToken));\n      }\n\n      if (groupSendToken == null && unidentifiedAccessKey == null) {\n        requestBuilder.setUnrestrictedAccess(Empty.getDefaultInstance());\n      }\n\n      return requestBuilder.build();\n    }\n  }\n\n  @Nested\n  class MultiRecipient {\n\n    @CartesianTest\n    void sendMessage(@CartesianTest.Values(booleans = {true, false}) final boolean ephemeral,\n        @CartesianTest.Values(booleans = {true, false}) final boolean urgent)\n        throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account resolvedAccount = mock(Account.class);\n      when(resolvedAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(resolvedAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier resolvedServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      final AciServiceIdentifier unresolvedServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(resolvedServiceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(resolvedAccount)));\n\n      final TestRecipient resolvedRecipient =\n          new TestRecipient(resolvedServiceIdentifier, deviceId, registrationId, new byte[48]);\n\n      final TestRecipient unresolvedRecipient =\n          new TestRecipient(unresolvedServiceIdentifier, Device.PRIMARY_ID, 1, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n          resolvedRecipient, unresolvedRecipient));\n\n      when(messageSender\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any()))\n          .thenReturn(CompletableFuture.completedFuture(null));\n\n      final SendMultiRecipientMessageRequest request = SendMultiRecipientMessageRequest.newBuilder()\n          .setGroupSendToken(ByteString.copyFrom(GROUP_SEND_TOKEN))\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setEphemeral(ephemeral)\n          .setUrgent(urgent)\n          .build();\n\n      final SendMultiRecipientMessageResponse response =\n          unauthenticatedServiceStub().sendMultiRecipientMessage(request);\n\n      final SendMultiRecipientMessageResponse expectedResponse = SendMultiRecipientMessageResponse.newBuilder()\n          .setSuccess(MultiRecipientSuccess.newBuilder()\n              .addUnresolvedRecipients(ServiceIdentifierUtil.toGrpcServiceIdentifier(unresolvedServiceIdentifier))\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n\n      verify(messageSender).sendMultiRecipientMessage(any(),\n          argThat(resolvedRecipients -> resolvedRecipients.containsValue(resolvedAccount)),\n          eq(CLOCK.millis()),\n          eq(false),\n          eq(ephemeral),\n          eq(urgent),\n          any());\n    }\n\n    @Test\n    void mismatchedDevices() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n          new TestRecipient(serviceIdentifier, staleDeviceId, 17, new byte[48])));\n\n      final SendMultiRecipientMessageRequest request = SendMultiRecipientMessageRequest.newBuilder()\n          .setGroupSendToken(ByteString.copyFrom(GROUP_SEND_TOKEN))\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setEphemeral(false)\n          .setUrgent(true)\n          .build();\n\n      doThrow(new MultiRecipientMismatchedDevicesException(Map.of(serviceIdentifier,\n          new org.whispersystems.textsecuregcm.controllers.MismatchedDevices(\n              Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId)))))\n          .when(messageSender).sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n\n      final SendMultiRecipientMessageResponse response =\n          unauthenticatedServiceStub().sendMultiRecipientMessage(request);\n\n      final SendMultiRecipientMessageResponse expectedResponse = SendMultiRecipientMessageResponse.newBuilder()\n          .setMismatchedDevices(MultiRecipientMismatchedDevices.newBuilder()\n              .addMismatchedDevices(MismatchedDevices.newBuilder()\n                  .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n                  .addMissingDevices(missingDeviceId)\n                  .addExtraDevices(extraDeviceId)\n                  .addStaleDevices(staleDeviceId)\n                  .build())\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    @Test\n    void badCredentials() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient = new TestRecipient(serviceIdentifier, deviceId, registrationId, new byte[48]);\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient));\n\n      final byte[] incorrectGroupSendToken = GROUP_SEND_TOKEN.clone();\n      incorrectGroupSendToken[0] += 1;\n\n      assertEquals(\n          SendMultiRecipientMessageResponse.newBuilder()\n              .setFailedUnidentifiedAuthorization(FailedUnidentifiedAuthorization.getDefaultInstance())\n              .build(),\n          unauthenticatedServiceStub().sendMultiRecipientMessage(SendMultiRecipientMessageRequest.newBuilder()\n              .setGroupSendToken(ByteString.copyFrom(incorrectGroupSendToken))\n              .setMessage(MultiRecipientMessage.newBuilder()\n                  .setTimestamp(CLOCK.millis())\n                  .setPayload(ByteString.copyFrom(payload))\n                  .build())\n              .setEphemeral(false)\n              .setUrgent(true)\n              .build()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientMessage(SendMultiRecipientMessageRequest.newBuilder()\n              .setMessage(MultiRecipientMessage.newBuilder()\n                  .setTimestamp(CLOCK.millis())\n                  .setPayload(ByteString.copyFrom(payload))\n                  .build())\n              .setEphemeral(false)\n              .setUrgent(true)\n              .build()));\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void badPayload() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientMessage(SendMultiRecipientMessageRequest.newBuilder()\n              .setMessage(MultiRecipientMessage.newBuilder()\n                  .setTimestamp(CLOCK.millis())\n                  .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n                  .build())\n              .build()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientMessage(SendMultiRecipientMessageRequest.newBuilder().build()));\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void repeatedRecipient() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final Device destinationDevice = DevicesHelper.createDevice(Device.PRIMARY_ID, CLOCK.millis(), 1);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient = new TestRecipient(serviceIdentifier, Device.PRIMARY_ID, 1, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient, recipient));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientMessage(SendMultiRecipientMessageRequest.newBuilder()\n              .setGroupSendToken(ByteString.copyFrom(GROUP_SEND_TOKEN))\n              .setMessage(MultiRecipientMessage.newBuilder()\n                  .setTimestamp(CLOCK.millis())\n                  .setPayload(ByteString.copyFrom(payload))\n                  .build())\n              .setEphemeral(false)\n              .setUrgent(true)\n              .build()));\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void oversizedMessage() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n          new TestRecipient(serviceIdentifier, Device.PRIMARY_ID, 17, new byte[48])));\n\n      final SendMultiRecipientMessageRequest request = SendMultiRecipientMessageRequest.newBuilder()\n          .setGroupSendToken(ByteString.copyFrom(GROUP_SEND_TOKEN))\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setEphemeral(false)\n          .setUrgent(true)\n          .build();\n\n      doThrow(new MessageTooLargeException())\n          .when(messageSender).sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n          () -> unauthenticatedServiceStub().sendMultiRecipientMessage(request));\n    }\n\n    @Test\n    void spamWithStatus() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient =\n          new TestRecipient(serviceIdentifier, deviceId, registrationId, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient));\n\n      final SendMultiRecipientMessageRequest request = SendMultiRecipientMessageRequest.newBuilder()\n          .setGroupSendToken(ByteString.copyFrom(GROUP_SEND_TOKEN))\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setEphemeral(false)\n          .setUrgent(true)\n          .build();\n\n      when(spamChecker.checkForMultiRecipientSpamGrpc(any()))\n          .thenReturn(new SpamCheckResult<>(\n              Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),\n              Optional.empty()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED,\n          () -> unauthenticatedServiceStub().sendMultiRecipientMessage(request));\n\n      verify(spamChecker).checkForMultiRecipientSpamGrpc(MessageType.MULTI_RECIPIENT_SEALED_SENDER);\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void spamWithResponse() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient =\n          new TestRecipient(serviceIdentifier, deviceId, registrationId, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient));\n\n      final SendMultiRecipientMessageRequest request = SendMultiRecipientMessageRequest.newBuilder()\n          .setGroupSendToken(ByteString.copyFrom(GROUP_SEND_TOKEN))\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setEphemeral(false)\n          .setUrgent(true)\n          .build();\n\n      final ChallengeRequired challengeResponse =\n          ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();\n      when(spamChecker.checkForMultiRecipientSpamGrpc(any()))\n          .thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED,\n          () -> unauthenticatedServiceStub().sendMultiRecipientMessage(request));\n\n      verify(spamChecker).checkForMultiRecipientSpamGrpc(MessageType.MULTI_RECIPIENT_SEALED_SENDER);\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n  }\n\n  @Nested\n  class SingleRecipientStory {\n\n    @CartesianTest\n    void sendStory(@CartesianTest.Values(booleans = {true, false}) final boolean urgent,\n        @CartesianTest.Values(booleans = {true, false}) final boolean includeReportSpamToken)\n        throws MessageTooLargeException, MismatchedDevicesException {\n\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(payload))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      final byte[] reportSpamToken = TestRandomUtil.nextBytes(64);\n\n      if (includeReportSpamToken) {\n        when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n            .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.of(reportSpamToken)));\n      }\n\n      final SendMessageResponse response =\n          unauthenticatedServiceStub().sendStory(generateRequest(serviceIdentifier, urgent, messages));\n\n      assertEquals(SendMessageResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);\n\n      final MessageProtos.Envelope.Builder expectedEnvelopeBuilder = MessageProtos.Envelope.newBuilder()\n          .setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER)\n          .setDestinationServiceId(serviceIdentifier.toServiceIdentifierString())\n          .setClientTimestamp(CLOCK.millis())\n          .setServerTimestamp(CLOCK.millis())\n          .setEphemeral(false)\n          .setUrgent(urgent)\n          .setStory(true)\n          .setContent(ByteString.copyFrom(payload));\n\n      if (includeReportSpamToken) {\n        expectedEnvelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken));\n      }\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_STORY,\n          Optional.empty(),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender).sendMessages(destinationAccount,\n          serviceIdentifier,\n          Map.of(deviceId, expectedEnvelopeBuilder.build()),\n          Map.of(deviceId, registrationId),\n          Optional.empty(),\n          null);\n    }\n\n    @Test\n    void mismatchedDevices() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      doThrow(new MismatchedDevicesException(new org.whispersystems.textsecuregcm.controllers.MismatchedDevices(\n          Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId))))\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      final SendMessageResponse response = unauthenticatedServiceStub().sendStory(\n          generateRequest(serviceIdentifier, false, messages));\n\n      final SendMessageResponse expectedResponse = SendMessageResponse.newBuilder()\n          .setMismatchedDevices(MismatchedDevices.newBuilder()\n              .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n              .addMissingDevices(missingDeviceId)\n              .addStaleDevices(staleDeviceId)\n              .addExtraDevices(extraDeviceId)\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    @Test\n    void destinationNotFound() throws MessageTooLargeException, MismatchedDevicesException {\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(Device.PRIMARY_ID, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(7)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      final SendMessageResponse response = unauthenticatedServiceStub().sendStory(\n          generateRequest(new AciServiceIdentifier(UUID.randomUUID()), true, messages));\n\n      assertEquals(SendMessageResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void rateLimited() throws RateLimitExceededException, MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n      when(destinationAccount.getIdentifier(IdentityType.ACI)).thenReturn(serviceIdentifier.uuid());\n\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Duration retryDuration = Duration.ofHours(7);\n      doThrow(new RateLimitExceededException(retryDuration)).when(rateLimiter).validate(eq(serviceIdentifier.uuid()));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertRateLimitExceeded(retryDuration,\n          () -> unauthenticatedServiceStub().sendStory(generateRequest(serviceIdentifier, true, messages)));\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void oversizedMessage() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getUnidentifiedAccessKey()).thenReturn(Optional.of(UNIDENTIFIED_ACCESS_KEY));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      doThrow(new MessageTooLargeException()).when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusInvalidArgument(\n          () -> unauthenticatedServiceStub().sendStory(generateRequest(serviceIdentifier, false, messages)));\n    }\n\n    @Test\n    void spamWithStatus() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n          .thenReturn(new SpamCheckResult<>(\n              Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),\n              Optional.empty()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED,\n          () -> unauthenticatedServiceStub().sendStory(generateRequest(serviceIdentifier, true, messages)));\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_STORY,\n          Optional.empty(),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void spamWithResponse() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      final ChallengeRequired challengeResponse =\n          ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();\n      when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n          .thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));\n\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () ->\n          unauthenticatedServiceStub().sendStory(generateRequest(serviceIdentifier, true, messages)));\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_STORY,\n          Optional.empty(),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    private static SendStoryMessageRequest generateRequest(final ServiceIdentifier serviceIdentifier,\n        final boolean urgent,\n        final Map<Byte, IndividualRecipientMessageBundle.Message> messages) {\n\n      final IndividualRecipientMessageBundle.Builder messageBundleBuilder = IndividualRecipientMessageBundle.newBuilder()\n          .setTimestamp(CLOCK.millis());\n\n      messages.forEach(messageBundleBuilder::putMessages);\n\n      return SendStoryMessageRequest.newBuilder()\n          .setDestination(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n          .setMessages(messageBundleBuilder)\n          .setUrgent(urgent)\n          .build();\n    }\n  }\n\n  @Nested\n  class MultiRecipientStory {\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void sendStory(final boolean urgent) throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account resolvedAccount = mock(Account.class);\n      when(resolvedAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(resolvedAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier resolvedServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      final AciServiceIdentifier unresolvedServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(resolvedServiceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(resolvedAccount)));\n\n      final TestRecipient resolvedRecipient =\n          new TestRecipient(resolvedServiceIdentifier, deviceId, registrationId, new byte[48]);\n\n      final TestRecipient unresolvedRecipient =\n          new TestRecipient(unresolvedServiceIdentifier, Device.PRIMARY_ID, 1, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n          resolvedRecipient, unresolvedRecipient));\n\n      when(messageSender\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any()))\n          .thenReturn(CompletableFuture.completedFuture(null));\n\n      final SendMultiRecipientStoryRequest request = SendMultiRecipientStoryRequest.newBuilder()\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setUrgent(urgent)\n          .build();\n\n      assertEquals(\n          SendMultiRecipientMessageResponse.newBuilder()\n              .setSuccess(MultiRecipientSuccess.getDefaultInstance())\n              .build(),\n          unauthenticatedServiceStub().sendMultiRecipientStory(request));\n\n      verify(messageSender).sendMultiRecipientMessage(any(),\n          argThat(resolvedRecipients -> resolvedRecipients.containsValue(resolvedAccount)),\n          eq(CLOCK.millis()),\n          eq(true),\n          eq(false),\n          eq(urgent),\n          any());\n    }\n\n    @Test\n    void mismatchedDevices() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n          new TestRecipient(serviceIdentifier, staleDeviceId, 17, new byte[48])));\n\n      final SendMultiRecipientStoryRequest request = SendMultiRecipientStoryRequest.newBuilder()\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setUrgent(true)\n          .build();\n\n      doThrow(new MultiRecipientMismatchedDevicesException(Map.of(serviceIdentifier,\n          new org.whispersystems.textsecuregcm.controllers.MismatchedDevices(\n              Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId)))))\n          .when(messageSender).sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n\n      final SendMultiRecipientMessageResponse response =\n          unauthenticatedServiceStub().sendMultiRecipientStory(request);\n\n      final SendMultiRecipientMessageResponse expectedResponse = SendMultiRecipientMessageResponse.newBuilder()\n          .setMismatchedDevices(MultiRecipientMismatchedDevices.newBuilder()\n              .addMismatchedDevices(MismatchedDevices.newBuilder()\n                  .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n                  .addMissingDevices(missingDeviceId)\n                  .addExtraDevices(extraDeviceId)\n                  .addStaleDevices(staleDeviceId)\n                  .build())\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    @Test\n    void badPayload() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientStory(SendMultiRecipientStoryRequest.newBuilder()\n              .setMessage(MultiRecipientMessage.newBuilder()\n                  .setTimestamp(CLOCK.millis())\n                  .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n                  .build())\n              .build()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientMessage(\n              SendMultiRecipientMessageRequest.newBuilder().build()));\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void repeatedRecipient() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final Device destinationDevice = DevicesHelper.createDevice(Device.PRIMARY_ID, CLOCK.millis(), 1);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient = new TestRecipient(serviceIdentifier, Device.PRIMARY_ID, 1, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient, recipient));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () ->\n          unauthenticatedServiceStub().sendMultiRecipientStory(SendMultiRecipientStoryRequest.newBuilder()\n              .setMessage(MultiRecipientMessage.newBuilder()\n                  .setTimestamp(CLOCK.millis())\n                  .setPayload(ByteString.copyFrom(payload))\n                  .build())\n              .setUrgent(true)\n              .build()));\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void oversizedMessage() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n          new TestRecipient(serviceIdentifier, Device.PRIMARY_ID, 17, new byte[48])));\n\n      final SendMultiRecipientStoryRequest request = SendMultiRecipientStoryRequest.newBuilder()\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setUrgent(true)\n          .build();\n\n      doThrow(new MessageTooLargeException())\n          .when(messageSender).sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusInvalidArgument(() -> unauthenticatedServiceStub().sendMultiRecipientStory(request));\n    }\n\n    @Test\n    void spamWithStatus() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient =\n          new TestRecipient(serviceIdentifier, deviceId, registrationId, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient));\n\n      final SendMultiRecipientStoryRequest request = SendMultiRecipientStoryRequest.newBuilder()\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setUrgent(true)\n          .build();\n\n      when(spamChecker.checkForMultiRecipientSpamGrpc(any()))\n          .thenReturn(new SpamCheckResult<>(\n              Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),\n              Optional.empty()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED,\n          () -> unauthenticatedServiceStub().sendMultiRecipientStory(request));\n\n      verify(spamChecker).checkForMultiRecipientSpamGrpc(MessageType.MULTI_RECIPIENT_STORY);\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n\n    @Test\n    void spamWithResponse() throws MessageTooLargeException, MultiRecipientMismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      when(accountsManager.getByServiceIdentifierAsync(serviceIdentifier))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n      final TestRecipient recipient =\n          new TestRecipient(serviceIdentifier, deviceId, registrationId, new byte[48]);\n\n      final byte[] payload = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(recipient));\n\n      final SendMultiRecipientStoryRequest request = SendMultiRecipientStoryRequest.newBuilder()\n          .setMessage(MultiRecipientMessage.newBuilder()\n              .setTimestamp(CLOCK.millis())\n              .setPayload(ByteString.copyFrom(payload))\n              .build())\n          .setUrgent(true)\n          .build();\n\n      final ChallengeRequired challengeResponse =\n          ChallengeRequired.newBuilder().addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA).build();\n      when(spamChecker.checkForMultiRecipientSpamGrpc(any()))\n          .thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeResponse)), Optional.empty()));\n\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () ->\n          unauthenticatedServiceStub().sendMultiRecipientStory(request));\n\n      verify(spamChecker).checkForMultiRecipientSpamGrpc(MessageType.MULTI_RECIPIENT_STORY);\n\n      verify(messageSender, never())\n          .sendMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean(), any());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/MessagesGrpcServiceTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport io.grpc.Status;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.mockito.Mock;\nimport org.signal.chat.messages.ChallengeRequired;\nimport org.signal.chat.messages.IndividualRecipientMessageBundle;\nimport org.signal.chat.messages.SendMessageType;\nimport org.signal.chat.messages.MessagesGrpc;\nimport org.signal.chat.messages.MismatchedDevices;\nimport org.signal.chat.messages.SendAuthenticatedSenderMessageRequest;\nimport org.signal.chat.messages.SendMessageAuthenticatedSenderResponse;\nimport org.signal.chat.messages.SendSyncMessageRequest;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.CardinalityEstimator;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.push.MessageTooLargeException;\nimport org.whispersystems.textsecuregcm.spam.GrpcChallengeResponse;\nimport org.whispersystems.textsecuregcm.spam.MessageType;\nimport org.whispersystems.textsecuregcm.spam.SpamCheckResult;\nimport org.whispersystems.textsecuregcm.spam.SpamChecker;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass MessagesGrpcServiceTest extends SimpleBaseGrpcTest<MessagesGrpcService, MessagesGrpc.MessagesBlockingStub> {\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private RateLimiters rateLimiters;\n\n  @Mock\n  private MessageSender messageSender;\n\n  @Mock\n  private CardinalityEstimator messageByteLimitEstimator;\n\n  @Mock\n  private SpamChecker spamChecker;\n\n  @Mock\n  private RateLimiter rateLimiter;\n\n  @Mock\n  private Account authenticatedAccount;\n\n  @Mock\n  private Device authenticatedDevice;\n\n  @Mock\n  private Device linkedDevice;\n\n  @Mock\n  private Device secondLinkedDevice;\n\n  private static final int AUTHENTICATED_REGISTRATION_ID = 7;\n\n  private static final byte LINKED_DEVICE_ID = AUTHENTICATED_DEVICE_ID + 1;\n  private static final int LINKED_DEVICE_REGISTRATION_ID = 13;\n\n  private static final byte SECOND_LINKED_DEVICE_ID = LINKED_DEVICE_ID + 1;\n  private static final int SECOND_LINKED_DEVICE_REGISTRATION_ID = 19;\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  @Override\n  protected MessagesGrpcService createServiceBeforeEachTest() {\n    return new MessagesGrpcService(accountsManager,\n        rateLimiters,\n        messageSender,\n        messageByteLimitEstimator,\n        spamChecker,\n        CLOCK);\n  }\n\n  @BeforeEach\n  void setUp() {\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n\n    when(rateLimiters.getInboundMessageBytes()).thenReturn(rateLimiter);\n    when(rateLimiters.getMessagesLimiter()).thenReturn(rateLimiter);\n\n    when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n        .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.empty()));\n\n    when(authenticatedDevice.getId()).thenReturn(AUTHENTICATED_DEVICE_ID);\n    when(authenticatedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(AUTHENTICATED_REGISTRATION_ID);\n\n    when(linkedDevice.getId()).thenReturn(LINKED_DEVICE_ID);\n    when(linkedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(LINKED_DEVICE_REGISTRATION_ID);\n\n    when(secondLinkedDevice.getId()).thenReturn(SECOND_LINKED_DEVICE_ID);\n    when(secondLinkedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(SECOND_LINKED_DEVICE_REGISTRATION_ID);\n\n    when(authenticatedAccount.getUuid()).thenReturn(AUTHENTICATED_ACI);\n    when(authenticatedAccount.getIdentifier(IdentityType.ACI)).thenReturn(AUTHENTICATED_ACI);\n    when(authenticatedAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(authenticatedAccount.getDevice(AUTHENTICATED_DEVICE_ID)).thenReturn(Optional.of(authenticatedDevice));\n    when(authenticatedAccount.getDevice(LINKED_DEVICE_ID)).thenReturn(Optional.of(linkedDevice));\n    when(authenticatedAccount.getDevice(SECOND_LINKED_DEVICE_ID)).thenReturn(Optional.of(secondLinkedDevice));\n    when(authenticatedAccount.getDevices()).thenReturn(List.of(authenticatedDevice, linkedDevice, secondLinkedDevice));\n\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(AUTHENTICATED_ACI)))\n        .thenReturn(Optional.of(authenticatedAccount));\n  }\n\n  @Nested\n  class SingleRecipient {\n\n    @CartesianTest\n    void sendMessage(@CartesianTest.Enum(mode = CartesianTest.Enum.Mode.EXCLUDE, names = {\"UNSPECIFIED\", \"UNRECOGNIZED\", \"UNIDENTIFIED_SENDER\"}) final SendMessageType messageType,\n        @CartesianTest.Values(booleans = {true, false}) final boolean ephemeral,\n        @CartesianTest.Values(booleans = {true, false}) final boolean urgent,\n        @CartesianTest.Values(booleans = {true, false}) final boolean includeReportSpamToken)\n        throws MessageTooLargeException, MismatchedDevicesException {\n\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final byte[] reportSpamToken = TestRandomUtil.nextBytes(64);\n\n      if (includeReportSpamToken) {\n        when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n            .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.of(reportSpamToken)));\n      }\n\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(payload))\n              .setType(messageType)\n              .build());\n\n      final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendMessage(\n          generateRequest(serviceIdentifier, ephemeral, urgent, messages));\n\n      assertEquals(SendMessageAuthenticatedSenderResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);\n\n      final MessageProtos.Envelope.Type expectedEnvelopeType = switch (messageType) {\n        case DOUBLE_RATCHET -> MessageProtos.Envelope.Type.CIPHERTEXT;\n        case PREKEY_MESSAGE -> MessageProtos.Envelope.Type.PREKEY_BUNDLE;\n        case PLAINTEXT_CONTENT -> MessageProtos.Envelope.Type.PLAINTEXT_CONTENT;\n        case UNIDENTIFIED_SENDER, UNSPECIFIED, UNRECOGNIZED -> throw new IllegalArgumentException(\"Unexpected message type: \" + messageType);\n      };\n\n      final MessageProtos.Envelope.Builder expectedEnvelopeBuilder = MessageProtos.Envelope.newBuilder()\n          .setType(expectedEnvelopeType)\n          .setSourceServiceId(new AciServiceIdentifier(AUTHENTICATED_ACI).toServiceIdentifierString())\n          .setSourceDevice(AUTHENTICATED_DEVICE_ID)\n          .setDestinationServiceId(serviceIdentifier.toServiceIdentifierString())\n          .setClientTimestamp(CLOCK.millis())\n          .setServerTimestamp(CLOCK.millis())\n          .setEphemeral(ephemeral)\n          .setUrgent(urgent)\n          .setContent(ByteString.copyFrom(payload));\n\n      if (includeReportSpamToken) {\n        expectedEnvelopeBuilder.setReportSpamToken(ByteString.copyFrom(reportSpamToken));\n      }\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_IDENTIFIED_SENDER,\n          Optional.of(new AuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID)),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender).sendMessages(destinationAccount,\n          serviceIdentifier,\n          Map.of(deviceId, expectedEnvelopeBuilder.build()),\n          Map.of(deviceId, registrationId),\n          Optional.empty(),\n          null);\n    }\n\n    @Test\n    void wrongMessageType() {\n\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(payload))\n              .setType(SendMessageType.UNIDENTIFIED_SENDER)\n              .build());\n\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub()\n          .sendMessage(generateRequest(serviceIdentifier, false, true, messages)));\n\n      verifyNoInteractions(messageSender);\n    }\n\n    @Test\n    void mismatchedDevices() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      doThrow(new MismatchedDevicesException(new org.whispersystems.textsecuregcm.controllers.MismatchedDevices(\n          Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId))))\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendMessage(\n          generateRequest(serviceIdentifier, false, true, messages));\n\n      final SendMessageAuthenticatedSenderResponse expectedResponse = SendMessageAuthenticatedSenderResponse.newBuilder()\n          .setMismatchedDevices(MismatchedDevices.newBuilder()\n              .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n              .addMissingDevices(missingDeviceId)\n              .addStaleDevices(staleDeviceId)\n              .addExtraDevices(extraDeviceId)\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    @Test\n    void destinationNotFound() throws MessageTooLargeException, MismatchedDevicesException {\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(Device.PRIMARY_ID, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(1234)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendMessage(\n          generateRequest(serviceIdentifier, false, true, messages));\n      assertTrue(response.hasDestinationNotFound());\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void rateLimited() throws RateLimitExceededException, MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Duration retryDuration = Duration.ofHours(7);\n\n      doThrow(new RateLimitExceededException(retryDuration))\n          .when(rateLimiter).validate(eq(serviceIdentifier.uuid()), anyLong());\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertRateLimitExceeded(retryDuration,\n          () -> authenticatedServiceStub().sendMessage(\n              generateRequest(serviceIdentifier, false, true, messages)));\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n      verify(messageByteLimitEstimator).add(serviceIdentifier.uuid().toString());\n    }\n\n    @Test\n    void oversizedMessage() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      doThrow(new MessageTooLargeException())\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT,\n          () -> authenticatedServiceStub().sendMessage(\n              generateRequest(serviceIdentifier, false, true, messages)));\n    }\n\n    @Test\n    void spamWithStatus() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n          .thenReturn(new SpamCheckResult<>(\n              Optional.of(GrpcChallengeResponse.withStatusException(GrpcExceptions.rateLimitExceeded(null))),\n              Optional.empty()));\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.RESOURCE_EXHAUSTED, () -> authenticatedServiceStub()\n          .sendMessage(generateRequest(serviceIdentifier, false, true, messages)));\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_IDENTIFIED_SENDER,\n          Optional.of(new AuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID)),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    @Test\n    void spamWithResponse() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte deviceId = Device.PRIMARY_ID;\n      final int registrationId = 7;\n\n      final Device destinationDevice = DevicesHelper.createDevice(deviceId, CLOCK.millis(), registrationId);\n\n      final Account destinationAccount = mock(Account.class);\n      when(destinationAccount.getDevices()).thenReturn(List.of(destinationDevice));\n      when(destinationAccount.getDevice(deviceId)).thenReturn(Optional.of(destinationDevice));\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(deviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(registrationId)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      final ChallengeRequired challengeRequired = ChallengeRequired.newBuilder()\n          .addChallengeOptions(ChallengeRequired.ChallengeType.CAPTCHA)\n          .build();\n      final SendMessageAuthenticatedSenderResponse expectedResponse = SendMessageAuthenticatedSenderResponse.newBuilder()\n          .setChallengeRequired(challengeRequired)\n          .build();\n\n      when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n          .thenReturn(new SpamCheckResult<>(Optional.of(GrpcChallengeResponse.withResponse(challengeRequired)), Optional.empty()));\n\n      assertEquals(expectedResponse, authenticatedServiceStub().sendMessage(\n          generateRequest(serviceIdentifier, false, true, messages)));\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.INDIVIDUAL_IDENTIFIED_SENDER,\n          Optional.of(new AuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID)),\n          Optional.of(destinationAccount),\n          serviceIdentifier);\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n    }\n\n    private static SendAuthenticatedSenderMessageRequest generateRequest(final ServiceIdentifier serviceIdentifier,\n        final boolean ephemeral,\n        final boolean urgent,\n        final Map<Byte, IndividualRecipientMessageBundle.Message> messages) {\n\n      final IndividualRecipientMessageBundle.Builder messageBundleBuilder = IndividualRecipientMessageBundle.newBuilder()\n          .setTimestamp(CLOCK.millis());\n\n      messages.forEach(messageBundleBuilder::putMessages);\n\n      final SendAuthenticatedSenderMessageRequest.Builder requestBuilder = SendAuthenticatedSenderMessageRequest.newBuilder()\n          .setDestination(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n          .setMessages(messageBundleBuilder)\n          .setEphemeral(ephemeral)\n          .setUrgent(urgent);\n\n      return requestBuilder.build();\n    }\n  }\n\n  @Nested\n  class Sync {\n\n    @CartesianTest\n    void sendMessage(@CartesianTest.Enum(mode = CartesianTest.Enum.Mode.EXCLUDE, names = {\"UNSPECIFIED\", \"UNRECOGNIZED\", \"UNIDENTIFIED_SENDER\"}) final SendMessageType messageType,\n        @CartesianTest.Values(booleans = {true, false}) final boolean urgent,\n        @CartesianTest.Values(booleans = {true, false}) final boolean includeReportSpamToken)\n        throws MessageTooLargeException, MismatchedDevicesException {\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(AUTHENTICATED_ACI);\n      final byte[] payload = TestRandomUtil.nextBytes(128);\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(LINKED_DEVICE_ID, IndividualRecipientMessageBundle.Message.newBuilder()\n                  .setRegistrationId(LINKED_DEVICE_REGISTRATION_ID)\n                  .setPayload(ByteString.copyFrom(payload))\n                  .setType(messageType)\n                  .build(),\n\n              SECOND_LINKED_DEVICE_ID, IndividualRecipientMessageBundle.Message.newBuilder()\n                  .setRegistrationId(SECOND_LINKED_DEVICE_REGISTRATION_ID)\n                  .setPayload(ByteString.copyFrom(payload))\n                  .setType(messageType)\n                  .build());\n\n      final byte[] reportSpamToken = TestRandomUtil.nextBytes(64);\n\n      if (includeReportSpamToken) {\n        when(spamChecker.checkForIndividualRecipientSpamGrpc(any(), any(), any(), any()))\n            .thenReturn(new SpamCheckResult<>(Optional.empty(), Optional.of(reportSpamToken)));\n      }\n\n      final SendMessageAuthenticatedSenderResponse response =\n          authenticatedServiceStub().sendSyncMessage(generateRequest(urgent, messages));\n\n      assertEquals(SendMessageAuthenticatedSenderResponse.newBuilder().setSuccess(Empty.getDefaultInstance()).build(), response);\n\n      final MessageProtos.Envelope.Type expectedEnvelopeType = switch (messageType) {\n        case DOUBLE_RATCHET -> MessageProtos.Envelope.Type.CIPHERTEXT;\n        case PREKEY_MESSAGE -> MessageProtos.Envelope.Type.PREKEY_BUNDLE;\n        case PLAINTEXT_CONTENT -> MessageProtos.Envelope.Type.PLAINTEXT_CONTENT;\n        case UNIDENTIFIED_SENDER, UNSPECIFIED, UNRECOGNIZED -> throw new IllegalArgumentException(\"Unexpected message type: \" + messageType);\n      };\n\n      final Map<Byte, MessageProtos.Envelope> expectedEnvelopes = new HashMap<>(Map.of(\n          LINKED_DEVICE_ID, MessageProtos.Envelope.newBuilder()\n              .setType(expectedEnvelopeType)\n              .setSourceServiceId(serviceIdentifier.toServiceIdentifierString())\n              .setSourceDevice(AUTHENTICATED_DEVICE_ID)\n              .setDestinationServiceId(serviceIdentifier.toServiceIdentifierString())\n              .setClientTimestamp(CLOCK.millis())\n              .setServerTimestamp(CLOCK.millis())\n              .setEphemeral(false)\n              .setUrgent(urgent)\n              .setContent(ByteString.copyFrom(payload))\n              .build(),\n\n          SECOND_LINKED_DEVICE_ID, MessageProtos.Envelope.newBuilder()\n              .setType(expectedEnvelopeType)\n              .setSourceServiceId(serviceIdentifier.toServiceIdentifierString())\n              .setSourceDevice(AUTHENTICATED_DEVICE_ID)\n              .setDestinationServiceId(serviceIdentifier.toServiceIdentifierString())\n              .setClientTimestamp(CLOCK.millis())\n              .setServerTimestamp(CLOCK.millis())\n              .setEphemeral(false)\n              .setUrgent(urgent)\n              .setContent(ByteString.copyFrom(payload))\n              .build()\n      ));\n\n      if (includeReportSpamToken) {\n        expectedEnvelopes.replaceAll((deviceId, envelope) ->\n            envelope.toBuilder().setReportSpamToken(ByteString.copyFrom(reportSpamToken)).build());\n      }\n\n      verify(spamChecker).checkForIndividualRecipientSpamGrpc(MessageType.SYNC,\n          Optional.of(new AuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID)),\n          Optional.of(authenticatedAccount),\n          serviceIdentifier);\n\n      verify(messageSender).sendMessages(authenticatedAccount,\n          serviceIdentifier,\n          expectedEnvelopes,\n          Map.of(LINKED_DEVICE_ID, LINKED_DEVICE_REGISTRATION_ID,\n              SECOND_LINKED_DEVICE_ID, SECOND_LINKED_DEVICE_REGISTRATION_ID),\n          Optional.of(AUTHENTICATED_DEVICE_ID),\n          null);\n    }\n\n    @Test\n    void mismatchedDevices() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      doThrow(new MismatchedDevicesException(new org.whispersystems.textsecuregcm.controllers.MismatchedDevices(\n          Set.of(missingDeviceId), Set.of(extraDeviceId), Set.of(staleDeviceId))))\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      final SendMessageAuthenticatedSenderResponse response = authenticatedServiceStub().sendSyncMessage(\n          generateRequest(true, messages));\n\n      final SendMessageAuthenticatedSenderResponse expectedResponse = SendMessageAuthenticatedSenderResponse.newBuilder()\n          .setMismatchedDevices(MismatchedDevices.newBuilder()\n              .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(AUTHENTICATED_ACI)))\n              .addMissingDevices(missingDeviceId)\n              .addStaleDevices(staleDeviceId)\n              .addExtraDevices(extraDeviceId)\n              .build())\n          .build();\n\n      assertEquals(expectedResponse, response);\n    }\n\n    @Test\n    void rateLimited() throws RateLimitExceededException, MessageTooLargeException, MismatchedDevicesException {\n      final Duration retryDuration = Duration.ofHours(7);\n      doThrow(new RateLimitExceededException(retryDuration))\n          .when(rateLimiter).validate(eq(AUTHENTICATED_ACI), anyLong());\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages =\n          Map.of(AUTHENTICATED_DEVICE_ID, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(AUTHENTICATED_REGISTRATION_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n              .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertRateLimitExceeded(retryDuration, () ->\n          authenticatedServiceStub().sendSyncMessage(generateRequest(true, messages)));\n\n      verify(messageSender, never()).sendMessages(any(), any(), any(), any(), any(), any());\n      verify(messageByteLimitEstimator).add(AUTHENTICATED_ACI.toString());\n    }\n\n    @Test\n    void oversizedMessage() throws MessageTooLargeException, MismatchedDevicesException {\n      final byte missingDeviceId = Device.PRIMARY_ID;\n      final byte extraDeviceId = missingDeviceId + 1;\n      final byte staleDeviceId = extraDeviceId + 1;\n\n      final Account destinationAccount = mock(Account.class);\n\n      final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n      when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(destinationAccount));\n\n      final Map<Byte, IndividualRecipientMessageBundle.Message> messages = Map.of(\n          staleDeviceId, IndividualRecipientMessageBundle.Message.newBuilder()\n              .setRegistrationId(Device.PRIMARY_ID)\n              .setPayload(ByteString.copyFrom(TestRandomUtil.nextBytes(128)))\n                  .setType(SendMessageType.DOUBLE_RATCHET)\n              .build());\n\n      doThrow(new MessageTooLargeException())\n          .when(messageSender).sendMessages(any(), any(), any(), any(), any(), any());\n\n      //noinspection ResultOfMethodCallIgnored\n      GrpcTestUtils.assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub()\n          .sendSyncMessage( generateRequest( true, messages)));\n    }\n\n    private static SendSyncMessageRequest generateRequest(\n        final boolean urgent,\n        final Map<Byte, IndividualRecipientMessageBundle.Message> messages) {\n\n      final IndividualRecipientMessageBundle.Builder messageBundleBuilder = IndividualRecipientMessageBundle.newBuilder()\n          .setTimestamp(CLOCK.millis());\n\n      messages.forEach(messageBundleBuilder::putMessages);\n\n      final SendSyncMessageRequest.Builder requestBuilder = SendSyncMessageRequest.newBuilder()\n          .setMessages(messageBundleBuilder)\n          .setUrgent(urgent);\n\n      return requestBuilder.build();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/MetricServerInterceptorTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.offset;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.InetAddresses;\nimport com.google.protobuf.Any;\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport com.google.rpc.ErrorInfo;\nimport io.grpc.ManagedChannel;\nimport io.grpc.Server;\nimport io.grpc.Status;\nimport io.grpc.StatusException;\nimport io.grpc.inprocess.InProcessChannelBuilder;\nimport io.grpc.inprocess.InProcessServerBuilder;\nimport io.grpc.protobuf.StatusProto;\nimport io.grpc.stub.BlockingClientCall;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Meter;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport io.micrometer.core.instrument.Timer;\nimport io.micrometer.core.instrument.simple.SimpleMeterRegistry;\nimport java.util.List;\nimport java.util.concurrent.Flow;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Supplier;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.chat.rpc.EchoRequest;\nimport org.signal.chat.rpc.EchoResponse;\nimport org.signal.chat.rpc.EchoServiceGrpc;\nimport org.signal.chat.rpc.SimpleTagTestServiceGrpc;\nimport org.signal.chat.rpc.TagResponse;\nimport org.signal.chat.rpc.TagTestServiceGrpc;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.util.ua.UnrecognizedUserAgentException;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgentUtil;\nimport reactor.adapter.JdkFlowAdapter;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Schedulers;\n\npublic class MetricServerInterceptorTest {\n\n  private static final String USER_AGENT = \"Signal-Android/4.53.7 (Android 8.1; libsignal)\";\n\n  private Server server;\n  private ManagedChannel channel;\n  private SimpleMeterRegistry simpleMeterRegistry;\n  private ClientReleaseManager clientReleaseManager;\n\n  private Supplier<TagResponse> tagResponseSupplier;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    simpleMeterRegistry = new SimpleMeterRegistry();\n    clientReleaseManager = mock(ClientReleaseManager.class);\n    tagResponseSupplier = mock(Supplier.class);\n    final MockRequestAttributesInterceptor mockRequestAttributesInterceptor = new MockRequestAttributesInterceptor();\n    mockRequestAttributesInterceptor.setRequestAttributes(\n        new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"), USER_AGENT, null));\n\n    server = InProcessServerBuilder.forName(\"MetricServerInterceptorTest\")\n        .directExecutor()\n        .addService(new EchoServiceImpl())\n        .addService(new TagTestServiceImpl(tagResponseSupplier))\n        .intercept(new MetricServerInterceptor(simpleMeterRegistry, clientReleaseManager))\n        .intercept(mockRequestAttributesInterceptor)\n        .build()\n        .start();\n\n    channel = InProcessChannelBuilder.forName(\"MetricServerInterceptorTest\")\n        .directExecutor()\n        .build();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    server.shutdownNow();\n    channel.shutdownNow();\n    server.awaitTermination(1, TimeUnit.SECONDS);\n    channel.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void unary() {\n    final EchoServiceGrpc.EchoServiceBlockingStub client = EchoServiceGrpc.newBlockingStub(channel);\n    client.echo(EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(\"hello\")).build());\n\n    final Tags commonTags = Tags.of(\n        \"platform\", \"android\",\n        \"grpcService\", \"org.signal.chat.rpc.EchoService\",\n        \"method\", \"echo\");\n\n    final Counter requestCount = find(Counter.class, MetricServerInterceptor.REQUEST_MESSAGE_COUNTER_NAME);\n    assertThat(requestCount.count()).isCloseTo(1.0, offset(0.01));\n\n    final Counter responseCount = find(Counter.class, MetricServerInterceptor.RESPONSE_COUNTER_NAME);\n    assertThat(responseCount.count()).isCloseTo(1.0, offset(0.01));\n\n    final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);\n    assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));\n\n    final Timer timer = find(Timer.class, MetricServerInterceptor.DURATION_TIMER_NAME);\n    assertThat(timer.count()).isEqualTo(1);\n\n    for (final Meter meter : List.of(requestCount, responseCount, rpcCount, timer)) {\n      for (final Tag tag : commonTags) {\n        assertThat(meter.getId().getTag(tag.getKey())).isEqualTo(tag.getValue());\n      }\n    }\n\n    assertThat(rpcCount.getId().getTag(\"statusCode\")).isEqualTo(\"OK\");\n  }\n\n  @Test\n  void streaming() throws StatusException, InterruptedException {\n    final EchoServiceGrpc.EchoServiceBlockingV2Stub client = EchoServiceGrpc.newBlockingV2Stub(channel);\n    final BlockingClientCall<EchoRequest, EchoResponse> echoStream = client.echoStream();\n    echoStream.write(EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(\"1\")).build());\n    echoStream.write(EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(\"2\")).build());\n    echoStream.read();\n    echoStream.read();\n    echoStream.halfClose();\n\n    // Make sure we don't check metrics before our close is processed\n    channel.shutdown().awaitTermination(5, TimeUnit.SECONDS);\n\n    final Counter requestCount = find(Counter.class, MetricServerInterceptor.REQUEST_MESSAGE_COUNTER_NAME);\n    assertThat(requestCount.count()).isCloseTo(2.0, offset(0.01));\n\n    final Counter responseCount = find(Counter.class, MetricServerInterceptor.RESPONSE_COUNTER_NAME);\n    assertThat(responseCount.count()).isCloseTo(2.0, offset(0.01));\n\n    final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);\n    assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));\n\n    final Timer timer = find(Timer.class, MetricServerInterceptor.DURATION_TIMER_NAME);\n    assertThat(timer.count()).isEqualTo(1);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void clientRelease(boolean enabled) throws UnrecognizedUserAgentException {\n    final UserAgent ua = UserAgentUtil.parseUserAgentString(USER_AGENT);\n    when(clientReleaseManager.isVersionActive(ua.platform(), ua.version())).thenReturn(enabled);\n    final EchoServiceGrpc.EchoServiceBlockingV2Stub client = EchoServiceGrpc.newBlockingV2Stub(channel);\n    client.echo(EchoRequest.newBuilder().setPayload(ByteString.copyFromUtf8(\"hello\")).build());\n\n    final String actualClientVersion = find(Meter.class, MetricServerInterceptor.REQUEST_MESSAGE_COUNTER_NAME)\n        .getId()\n        .getTag(\"clientVersion\");\n    final String expectedClientVersion = enabled ? ua.version().toString() : null;\n\n    assertThat(expectedClientVersion).isEqualTo(actualClientVersion);\n  }\n\n  static Stream<Arguments> testUnaryOkResponseReason() {\n    return Stream.of(\n            Arguments.argumentSet(\"Default reason\", TagResponse.newBuilder().build(), \"success\"),\n            Arguments.argumentSet(\"No reason\", TagResponse.newBuilder().setNoReason(true).build(), \"success\"),\n            Arguments.argumentSet(\"Explicitly set reason\", TagResponse.newBuilder().setReason1(true).build(), \"reason_1\"),\n            Arguments.argumentSet(\"Nested reason\", TagResponse.newBuilder().setNestedReason(TagResponse.NestedReason.newBuilder().setReason(true)).build(), \"nested_reason\"));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testUnaryOkResponseReason(TagResponse response, String expectedReason) throws InterruptedException {\n    final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =\n        TagTestServiceGrpc.newBlockingStub(channel);\n    when(tagResponseSupplier.get()).thenReturn(response);\n    tagTestServiceBlockingStub.tagEndpoint(Empty.getDefaultInstance());\n\n    final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);\n    assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));\n    assertThat(rpcCount.getId().getTag(\"statusCode\")).isEqualTo(\"OK\");\n    assertThat(rpcCount.getId().getTag(\"reason\")).isEqualTo(expectedReason);\n  }\n\n  @Test\n  public void testConflictingReasons() {\n    final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =\n        TagTestServiceGrpc.newBlockingStub(channel);\n    when(tagResponseSupplier.get())\n        .thenReturn(TagResponse.newBuilder().setReason1(true).setConflictingReason(true).build());\n    tagTestServiceBlockingStub.tagEndpoint(Empty.getDefaultInstance());\n\n    // We make no promises if proto fields that have reason tags are present on a message, but this tests for the sane\n    // behavior that at least one of these tags makes it into the metric.\n    assertThat(find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME).getId().getTag(\"reason\"))\n        .isIn(\"duplicate_reason\", \"reason_1\");\n  }\n\n  @CartesianTest\n  public void testStatusErrorResponseReason(\n      @CartesianTest.Enum(mode = CartesianTest.Enum.Mode.EXCLUDE, names = {\"OK\"}) Status.Code statusCode,\n      @CartesianTest.Values(strings = {\"test\", \"\", \"null\"}) String reasonParam) {\n\n    final String reason, expectedReasonTag;\n    if (reasonParam.equals(\"null\")) {\n      reason = null;\n      expectedReasonTag = MetricServerInterceptor.DEFAULT_ERROR_REASON;\n    } else {\n      reason = reasonParam;\n      expectedReasonTag = reasonParam;\n    }\n\n    final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =\n        TagTestServiceGrpc.newBlockingStub(channel);\n\n    final com.google.rpc.Status.Builder builder = com.google.rpc.Status.newBuilder()\n        .setCode(statusCode.value())\n        .setMessage(\"test\");\n    if (reason != null) {\n      builder.addDetails(Any.pack(ErrorInfo.newBuilder()\n          .setDomain(\"domain\")\n          .setReason(reason)\n          .build()));\n    }\n\n    when(tagResponseSupplier.get()).thenThrow(StatusProto.toStatusRuntimeException(builder.build()));\n\n    GrpcTestUtils.assertStatusException(statusCode.toStatus(),\n        () -> tagTestServiceBlockingStub.tagEndpoint(Empty.getDefaultInstance()));\n\n    final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);\n    assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));\n    assertThat(rpcCount.getId().getTag(\"statusCode\")).isEqualTo(statusCode.name());\n    assertThat(rpcCount.getId().getTag(\"reason\")).isEqualTo(expectedReasonTag);\n  }\n\n  @Test\n  public void testStreamingResponseReason() {\n    final TagTestServiceGrpc.TagTestServiceBlockingStub tagTestServiceBlockingStub =\n        TagTestServiceGrpc.newBlockingStub(channel);\n    when(tagResponseSupplier.get())\n        .thenReturn(TagResponse.newBuilder().setReason1(true).build())\n        .thenReturn(TagResponse.newBuilder().setNoReason(true).build())\n        .thenReturn(null);\n\n    tagTestServiceBlockingStub.streamingTagEndpoint(Empty.getDefaultInstance()).forEachRemaining(_ -> {});\n    final Counter messageCounter = find(Counter.class, MetricServerInterceptor.RESPONSE_COUNTER_NAME);\n    assertThat(messageCounter.count()).isCloseTo(2.0, offset(0.01));\n\n    final Counter rpcCount = find(Counter.class, MetricServerInterceptor.RPC_COUNTER_NAME);\n    assertThat(rpcCount.count()).isCloseTo(1.0, offset(0.01));\n    assertThat(rpcCount.getId().getTag(\"statusCode\")).isEqualTo(\"OK\");\n    assertThat(rpcCount.getId().getTag(\"reason\")).isEqualTo(MetricServerInterceptor.DEFAULT_SUCCESS_REASON);\n  }\n\n  private <T extends Meter> T find(Class<T> cls, final String name) {\n    final Meter meter = simpleMeterRegistry.getMeters().stream()\n        .filter(m -> m.getId().getName().equals(name))\n        .findFirst()\n        .orElseThrow();\n    if (cls.isInstance(meter)) {\n      return cls.cast(meter);\n    }\n    throw new IllegalArgumentException(\"Meter \" + name + \" should be an instance of \" + cls);\n  }\n\n  class TagTestServiceImpl extends SimpleTagTestServiceGrpc.TagTestServiceImplBase {\n\n    private Supplier<TagResponse> tagResponseSupplier;\n    TagTestServiceImpl(Supplier<TagResponse> tagResponseSupplier) {\n      this.tagResponseSupplier = tagResponseSupplier;\n    }\n\n    @Override\n    public TagResponse tagEndpoint(final Empty request) {\n      return tagResponseSupplier.get();\n    }\n\n    @Override\n    public Flow.Publisher<TagResponse> streamingTagEndpoint(com.google.protobuf.Empty request) {\n      return JdkFlowAdapter.publisherToFlowPublisher(Flux.<TagResponse>create(sink -> {\n            while (!sink.isCancelled()) {\n              TagResponse item = tagResponseSupplier.get();\n              if (item == null) {\n                sink.complete();\n                break;\n              }\n              sink.next(item);\n            }\n          })\n          .subscribeOn(Schedulers.boundedElastic()));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/MockRequestAttributesInterceptor.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport com.google.common.net.InetAddresses;\nimport io.grpc.Context;\nimport io.grpc.Contexts;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport java.net.InetAddress;\nimport java.util.List;\nimport java.util.Locale;\nimport javax.annotation.Nullable;\nimport org.whispersystems.textsecuregcm.util.ua.UserAgent;\n\npublic class MockRequestAttributesInterceptor implements ServerInterceptor {\n\n  private RequestAttributes requestAttributes = new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"), null, null);\n\n  public void setRequestAttributes(final RequestAttributes requestAttributes) {\n    this.requestAttributes = requestAttributes;\n  }\n\n  @Override\n  public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> serverCall,\n      final Metadata headers,\n      final ServerCallHandler<ReqT, RespT> next) {\n\n    return Contexts.interceptCall(Context.current()\n        .withValue(RequestAttributesUtil.REQUEST_ATTRIBUTES_CONTEXT_KEY, requestAttributes), serverCall, headers, next);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/PaymentsGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\n\nimport io.grpc.Status;\nimport java.math.BigDecimal;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.Mock;\nimport org.signal.chat.payments.GetCurrencyConversionsRequest;\nimport org.signal.chat.payments.GetCurrencyConversionsResponse;\nimport org.signal.chat.payments.PaymentsGrpc;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntity;\nimport org.whispersystems.textsecuregcm.entities.CurrencyConversionEntityList;\n\nclass PaymentsGrpcServiceTest extends SimpleBaseGrpcTest<PaymentsGrpcService, PaymentsGrpc.PaymentsBlockingStub> {\n\n  @Mock\n  private CurrencyConversionManager currencyManager;\n\n  @Override\n  protected PaymentsGrpcService createServiceBeforeEachTest() {\n    return new PaymentsGrpcService(currencyManager);\n  }\n\n  @Test\n  void testGetCurrencyConversions() {\n    final long timestamp = System.currentTimeMillis();\n    when(currencyManager.getCurrencyConversions()).thenReturn(Optional.of(\n        new CurrencyConversionEntityList(List.of(\n            new CurrencyConversionEntity(\"FOO\", Map.of(\n                \"USD\", new BigDecimal(\"2.35\"),\n                \"EUR\", new BigDecimal(\"1.89\")\n            )),\n            new CurrencyConversionEntity(\"BAR\", Map.of(\n                \"USD\", new BigDecimal(\"1.50\"),\n                \"EUR\", new BigDecimal(\"0.98\")\n            ))\n        ), timestamp)));\n\n    final GetCurrencyConversionsResponse currencyConversions = authenticatedServiceStub().getCurrencyConversions(\n        GetCurrencyConversionsRequest.newBuilder().build());\n\n    assertEquals(timestamp, currencyConversions.getTimestamp());\n    assertEquals(2, currencyConversions.getCurrenciesCount());\n    assertEquals(\"FOO\", currencyConversions.getCurrencies(0).getBase());\n    assertEquals(\"2.35\", currencyConversions.getCurrencies(0).getConversionsMap().get(\"USD\"));\n  }\n\n  @Test\n  void testUnavailable() {\n    when(currencyManager.getCurrencyConversions()).thenReturn(Optional.empty());\n    assertStatusException(Status.UNAVAILABLE, () -> authenticatedServiceStub().getCurrencyConversions(\n        GetCurrencyConversionsRequest.newBuilder().build()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileAnonymousGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\n\nimport com.google.common.net.InetAddresses;\nimport com.google.protobuf.ByteString;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.Mock;\nimport org.signal.chat.common.IdentityType;\nimport org.signal.chat.common.ServiceIdentifier;\nimport org.signal.chat.profile.CredentialType;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialAnonymousRequest;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;\nimport org.signal.chat.profile.GetUnversionedProfileAnonymousRequest;\nimport org.signal.chat.profile.GetUnversionedProfileRequest;\nimport org.signal.chat.profile.GetUnversionedProfileResponse;\nimport org.signal.chat.profile.GetVersionedProfileAnonymousRequest;\nimport org.signal.chat.profile.GetVersionedProfileRequest;\nimport org.signal.chat.profile.GetVersionedProfileResponse;\nimport org.signal.chat.profile.ProfileAnonymousGrpc;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKey;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class ProfileAnonymousGrpcServiceTest extends SimpleBaseGrpcTest<ProfileAnonymousGrpcService, ProfileAnonymousGrpc.ProfileAnonymousBlockingStub> {\n\n  private final ServerSecretParams SERVER_SECRET_PARAMS = ServerSecretParams.generate();\n\n  @Mock\n  private Account account;\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private ProfilesManager profilesManager;\n\n  @Mock\n  private ProfileBadgeConverter profileBadgeConverter;\n\n  @Override\n  protected ProfileAnonymousGrpcService createServiceBeforeEachTest() {\n    getMockRequestAttributesInterceptor().setRequestAttributes(new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"),\n        \"Signal-Android/1.2.3\",\n        \"en-us\"));\n\n    return new ProfileAnonymousGrpcService(\n        accountsManager,\n        profilesManager,\n        profileBadgeConverter,\n        SERVER_SECRET_PARAMS\n    );\n  }\n\n  @Test\n  void getUnversionedProfileUnidentifiedAccessKey() {\n    final UUID targetUuid = UUID.randomUUID();\n    final org.whispersystems.textsecuregcm.identity.ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(targetUuid);\n\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n\n    final List<Badge> badges = List.of(new Badge(\n        \"TEST\",\n        \"other\",\n        \"Test Badge\",\n        \"This badge is in unit tests.\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"),\n        \"SVG\",\n        List.of(\n            new BadgeSvg(\"sl\", \"sd\"),\n            new BadgeSvg(\"ml\", \"md\"),\n            new BadgeSvg(\"ll\", \"ld\")))\n    );\n\n    when(account.getBadges()).thenReturn(Collections.emptyList());\n    when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges);\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(account.getIdentityKey(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(identityKey);\n    when(account.hasCapability(any())).thenReturn(false);\n    when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(account));\n\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n                .build())\n            .build())\n        .build();\n\n    final GetUnversionedProfileResponse response = unauthenticatedServiceStub().getUnversionedProfile(request);\n\n    final byte[] unidentifiedAccessChecksum = UnidentifiedAccessChecksum.generateFor(unidentifiedAccessKey);\n    final GetUnversionedProfileResponse expectedResponse = GetUnversionedProfileResponse.newBuilder()\n        .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n        .setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))\n        .setUnrestrictedUnidentifiedAccess(false)\n        .addAllBadges(ProfileGrpcHelper.buildBadges(badges))\n        .build();\n\n    verify(accountsManager).getByServiceIdentifier(serviceIdentifier);\n    assertEquals(expectedResponse, response);\n  }\n\n  @Test\n  void getUnversionedProfileGroupSendEndorsement() throws Exception {\n    final UUID targetUuid = UUID.randomUUID();\n    final org.whispersystems.textsecuregcm.identity.ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(targetUuid);\n\n    // Expiration must be on a day boundary; we want one in the future\n    final Instant expiration = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS);\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(serviceIdentifier), expiration);\n\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n\n    final List<Badge> badges = List.of(new Badge(\n        \"TEST\",\n        \"other\",\n        \"Test Badge\",\n        \"This badge is in unit tests.\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"),\n        \"SVG\",\n        List.of(\n            new BadgeSvg(\"sl\", \"sd\"),\n            new BadgeSvg(\"ml\", \"md\"),\n            new BadgeSvg(\"ll\", \"ld\")))\n    );\n\n    when(account.getBadges()).thenReturn(Collections.emptyList());\n    when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges);\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);\n    when(account.getIdentityKey(org.whispersystems.textsecuregcm.identity.IdentityType.ACI)).thenReturn(identityKey);\n    when(accountsManager.getByServiceIdentifier(serviceIdentifier)).thenReturn(Optional.of(account));\n\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setGroupSendToken(ByteString.copyFrom(token))\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(\n                ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier))\n            .build())\n        .build();\n\n    final GetUnversionedProfileResponse response = unauthenticatedServiceStub().getUnversionedProfile(request);\n\n    final GetUnversionedProfileResponse expectedResponse = GetUnversionedProfileResponse.newBuilder()\n        .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n        .setUnrestrictedUnidentifiedAccess(false)\n        .addAllCapabilities(ProfileGrpcHelper.buildAccountCapabilities(account))\n        .addAllBadges(ProfileGrpcHelper.buildBadges(badges))\n        .build();\n\n    verify(accountsManager).getByServiceIdentifier(serviceIdentifier);\n    assertEquals(expectedResponse, response);\n  }\n\n  @Test\n  void getUnversionedProfileNoAuth() {\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(new AciServiceIdentifier(UUID.randomUUID()))))\n        .build();\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().getUnversionedProfile(request));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getUnversionedProfileIncorrectUnidentifiedAccessKey(final IdentityType identityType, final boolean wrongUnidentifiedAccessKey, final boolean accountNotFound) {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(\n        accountNotFound ? Optional.empty() : Optional.of(account));\n\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(\n            ByteString.copyFrom(wrongUnidentifiedAccessKey\n                ? new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]\n                : unidentifiedAccessKey))\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(identityType)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))))\n        .build();\n\n    assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getUnversionedProfile(request));\n  }\n\n  private static Stream<Arguments> getUnversionedProfileIncorrectUnidentifiedAccessKey() {\n    return Stream.of(\n        Arguments.of(IdentityType.IDENTITY_TYPE_PNI, false, false),\n        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, true, false),\n        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, false, true)\n    );\n  }\n\n  @Test\n  void getUnversionedProfileExpiredGroupSendEndorsement() throws Exception {\n    final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    // Expirations must be on a day boundary; pick one in the recent past\n    final Instant expiration = Instant.now().truncatedTo(ChronoUnit.DAYS);\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(serviceIdentifier), expiration);\n\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setGroupSendToken(ByteString.copyFrom(token))\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(\n                ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier)))\n        .build();\n\n    assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getUnversionedProfile(request));\n  }\n\n  @Test\n  void getUnversionedProfileIncorrectGroupSendEndorsement() throws Exception {\n    final AciServiceIdentifier targetServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final AciServiceIdentifier authorizedServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    // Expiration must be on a day boundary; we want one in the future\n    final Instant expiration = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS);\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(authorizedServiceIdentifier), expiration);\n\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(\n        Optional.empty());\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setGroupSendToken(ByteString.copyFrom(token))\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(\n                ServiceIdentifierUtil.toGrpcServiceIdentifier(targetServiceIdentifier)))\n        .build();\n\n    assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getUnversionedProfile(request));\n  }\n\n  @Test\n  void getUnversionedProfileGroupSendEndorsementAccountNotFound() throws Exception {\n    final AciServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    // Expiration must be on a day boundary; we want one in the future\n    final Instant expiration = Instant.now().plus(Duration.ofDays(1)).truncatedTo(ChronoUnit.DAYS);\n    final byte[] token = AuthHelper.validGroupSendToken(SERVER_SECRET_PARAMS, List.of(serviceIdentifier), expiration);\n\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n    final GetUnversionedProfileAnonymousRequest request = GetUnversionedProfileAnonymousRequest.newBuilder()\n        .setGroupSendToken(ByteString.copyFrom(token))\n        .setRequest(GetUnversionedProfileRequest.newBuilder()\n            .setServiceIdentifier(ServiceIdentifierUtil.toGrpcServiceIdentifier(serviceIdentifier)))\n        .build();\n\n    assertStatusException(Status.NOT_FOUND, () -> unauthenticatedServiceStub().getUnversionedProfile(request));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getVersionedProfile(final String requestVersion,\n      @Nullable final String accountVersion,\n      final boolean expectResponseHasPaymentAddress) {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n    final String avatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n\n    final VersionedProfile profile = new VersionedProfile(accountVersion, name, avatar, emoji, about, paymentAddress, phoneNumberSharing, new byte[0]);\n\n    when(account.getCurrentProfileVersion()).thenReturn(Optional.ofNullable(accountVersion));\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.of(account));\n    when(profilesManager.get(any(), any())).thenReturn(Optional.of(profile));\n\n    final GetVersionedProfileAnonymousRequest request = GetVersionedProfileAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetVersionedProfileRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n                .build())\n            .setVersion(requestVersion)\n            .build())\n        .build();\n\n    final GetVersionedProfileResponse response = unauthenticatedServiceStub().getVersionedProfile(request);\n\n    final GetVersionedProfileResponse.Builder expectedResponseBuilder = GetVersionedProfileResponse.newBuilder()\n        .setName(ByteString.copyFrom(name))\n        .setAbout(ByteString.copyFrom(about))\n        .setAboutEmoji(ByteString.copyFrom(emoji))\n        .setAvatar(avatar)\n        .setPhoneNumberSharing(ByteString.copyFrom(phoneNumberSharing));\n\n    if (expectResponseHasPaymentAddress) {\n      expectedResponseBuilder.setPaymentAddress(ByteString.copyFrom(paymentAddress));\n    }\n\n    assertEquals(expectedResponseBuilder.build(), response);\n  }\n\n  private static Stream<Arguments> getVersionedProfile() {\n    return Stream.of(\n        Arguments.of(\"version1\", \"version1\", true),\n        Arguments.of(\"version1\", null, true),\n        Arguments.of(\"version1\", \"version2\", false)\n    );\n  }\n\n  @Test\n  void getVersionedProfileVersionNotFound() {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);\n\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.of(account));\n    when(profilesManager.get(any(), any())).thenReturn(Optional.empty());\n\n    final GetVersionedProfileAnonymousRequest request = GetVersionedProfileAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetVersionedProfileRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n                .build())\n            .setVersion(\"someVersion\")\n            .build())\n        .build();\n\n    assertStatusException(Status.NOT_FOUND, () -> unauthenticatedServiceStub().getVersionedProfile(request));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getVersionedProfileUnauthenticated(final boolean missingUnidentifiedAccessKey,\n      final boolean accountNotFound) {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(false);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(\n        accountNotFound ? Optional.empty() : Optional.of(account));\n\n    final GetVersionedProfileAnonymousRequest.Builder requestBuilder = GetVersionedProfileAnonymousRequest.newBuilder()\n        .setRequest(GetVersionedProfileRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n                .build())\n            .setVersion(\"someVersion\")\n            .build());\n\n    if (!missingUnidentifiedAccessKey) {\n      requestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey));\n    }\n\n    assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getVersionedProfile(requestBuilder.build()));\n  }\n  private static Stream<Arguments> getVersionedProfileUnauthenticated() {\n    return Stream.of(\n        Arguments.of(true, false),\n        Arguments.of(false, true)\n    );\n  }\n\n  @Test\n  void getVersionedProfilePniInvalidArgument() {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    final GetVersionedProfileAnonymousRequest request = GetVersionedProfileAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetVersionedProfileRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_PNI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n                .build())\n            .setVersion(\"someVersion\")\n            .build())\n        .build();\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().getVersionedProfile(request));\n  }\n\n  @Test\n  void getExpiringProfileKeyCredential() throws InvalidInputException, VerificationFailedException {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final UUID targetUuid = UUID.randomUUID();\n\n    final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(SERVER_SECRET_PARAMS.getPublicParams());\n\n    final byte[] profileKeyBytes = TestRandomUtil.nextBytes(32);\n    final ProfileKey profileKey = new ProfileKey(profileKeyBytes);\n    final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(new ServiceId.Aci(targetUuid));\n    final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext =\n        clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(targetUuid), profileKey);\n\n    final VersionedProfile profile = mock(VersionedProfile.class);\n    when(profile.commitment()).thenReturn(profileKeyCommitment.serialize());\n\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid))).thenReturn(Optional.of(account));\n    when(profilesManager.get(targetUuid, \"someVersion\")).thenReturn(Optional.of(profile));\n\n    final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest();\n\n    final Instant expiration = Instant.now().plus(org.whispersystems.textsecuregcm.util.ProfileHelper.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION)\n        .truncatedTo(ChronoUnit.DAYS);\n\n    final GetExpiringProfileKeyCredentialAnonymousRequest request = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder()\n        .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n                .build())\n            .setCredentialRequest(ByteString.copyFrom(credentialRequest.serialize()))\n            .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY)\n            .setVersion(\"someVersion\")\n            .build())\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .build();\n\n    final GetExpiringProfileKeyCredentialResponse response = unauthenticatedServiceStub().getExpiringProfileKeyCredential(request);\n\n    assertThatNoException().isThrownBy(() ->\n        clientZkProfile.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, new ExpiringProfileKeyCredentialResponse(response.getProfileKeyCredential().toByteArray())));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getExpiringProfileKeyCredentialUnauthenticated(final boolean missingAccount, final boolean missingUnidentifiedAccessKey) {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final UUID targetUuid = UUID.randomUUID();\n\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid))).thenReturn(\n        missingAccount ? Optional.empty() : Optional.of(account));\n\n    final GetExpiringProfileKeyCredentialAnonymousRequest.Builder requestBuilder = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder()\n        .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n                .build())\n            .setCredentialRequest(ByteString.copyFrom(\"credentialRequest\".getBytes(StandardCharsets.UTF_8)))\n            .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY)\n            .setVersion(\"someVersion\")\n            .build());\n\n    if (!missingUnidentifiedAccessKey) {\n      requestBuilder.setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey));\n    }\n\n    assertStatusException(Status.UNAUTHENTICATED, () -> unauthenticatedServiceStub().getExpiringProfileKeyCredential(requestBuilder.build()));\n\n    verifyNoInteractions(profilesManager);\n  }\n\n  private static Stream<Arguments> getExpiringProfileKeyCredentialUnauthenticated() {\n    return Stream.of(\n        Arguments.of(true, false),\n        Arguments.of(false, true)\n    );\n  }\n\n\n  @Test\n  void getExpiringProfileKeyCredentialProfileNotFound() {\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final UUID targetUuid = UUID.randomUUID();\n\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid))).thenReturn(\n        Optional.of(account));\n    when(profilesManager.get(targetUuid, \"someVersion\")).thenReturn(Optional.empty());\n\n    final GetExpiringProfileKeyCredentialAnonymousRequest request = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n                .build())\n            .setCredentialRequest(ByteString.copyFrom(\"credentialRequest\".getBytes(StandardCharsets.UTF_8)))\n            .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY)\n            .setVersion(\"someVersion\")\n            .build())\n        .build();\n\n    assertStatusException(Status.NOT_FOUND, () -> unauthenticatedServiceStub().getExpiringProfileKeyCredential(request));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getExpiringProfileKeyCredentialInvalidArgument(final IdentityType identityType, final CredentialType credentialType,\n      final boolean throwZkVerificationException) throws VerificationFailedException {\n    final UUID targetUuid = UUID.randomUUID();\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n\n    final VersionedProfile profile = mock(VersionedProfile.class);\n    when(profile.commitment()).thenReturn(\"commitment\".getBytes(StandardCharsets.UTF_8));\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid))).thenReturn(Optional.of(account));\n    when(profilesManager.get(targetUuid, \"someVersion\")).thenReturn(Optional.of(profile));\n\n    final GetExpiringProfileKeyCredentialAnonymousRequest request = GetExpiringProfileKeyCredentialAnonymousRequest.newBuilder()\n        .setUnidentifiedAccessKey(ByteString.copyFrom(unidentifiedAccessKey))\n        .setRequest(GetExpiringProfileKeyCredentialRequest.newBuilder()\n            .setAccountIdentifier(ServiceIdentifier.newBuilder()\n                .setIdentityType(identityType)\n                .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n                .build())\n            .setCredentialRequest(ByteString.copyFrom(\"credentialRequest\".getBytes(StandardCharsets.UTF_8)))\n            .setCredentialType(credentialType)\n            .setVersion(\"someVersion\")\n            .build())\n        .build();\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> unauthenticatedServiceStub().getExpiringProfileKeyCredential(request));\n  }\n\n  private static Stream<Arguments> getExpiringProfileKeyCredentialInvalidArgument() {\n    return Stream.of(\n        // Credential type unspecified\n        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_UNSPECIFIED, false),\n        // Illegal identity type\n        Arguments.of(IdentityType.IDENTITY_TYPE_PNI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, false),\n        // Artificially fails zero knowledge verification\n        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, true)\n    );\n  }\n\n  @Override\n  protected List<ServerInterceptor> customizeInterceptors(List<ServerInterceptor> serverInterceptors) {\n    return serverInterceptors.stream()\n        // For now, don't validate error conformance because the profiles gRPC service has not been converted to the\n        // updated error model\n        .filter(interceptor -> !(interceptor instanceof ErrorConformanceInterceptor))\n        .toList();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/ProfileGrpcServiceTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThatNoException;\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.ArgumentMatchers.refEq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertRateLimitExceeded;\nimport static org.whispersystems.textsecuregcm.grpc.GrpcTestUtils.assertStatusException;\n\nimport com.google.common.net.InetAddresses;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport com.google.protobuf.ByteString;\nimport io.grpc.Metadata;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.Status;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.Mock;\nimport org.signal.chat.common.IdentityType;\nimport org.signal.chat.common.ServiceIdentifier;\nimport org.signal.chat.profile.CredentialType;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialRequest;\nimport org.signal.chat.profile.GetExpiringProfileKeyCredentialResponse;\nimport org.signal.chat.profile.GetUnversionedProfileRequest;\nimport org.signal.chat.profile.GetUnversionedProfileResponse;\nimport org.signal.chat.profile.GetVersionedProfileRequest;\nimport org.signal.chat.profile.GetVersionedProfileResponse;\nimport org.signal.chat.profile.ProfileGrpc;\nimport org.signal.chat.profile.SetProfileRequest;\nimport org.signal.chat.profile.SetProfileRequest.AvatarChange;\nimport org.signal.chat.profile.SetProfileResponse;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ServiceId;\n\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.ServerPublicParams;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.VerificationFailedException;\nimport org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;\nimport org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKey;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext;\nimport org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessChecksum;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.badges.ProfileBadgeConverter;\nimport org.whispersystems.textsecuregcm.configuration.BadgeConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.BadgesConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicPaymentsConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.entities.Badge;\nimport org.whispersystems.textsecuregcm.entities.BadgeSvg;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport org.whispersystems.textsecuregcm.s3.PolicySigner;\nimport org.whispersystems.textsecuregcm.s3.PostPolicyGenerator;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountBadge;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.ProfilesManager;\nimport org.whispersystems.textsecuregcm.storage.VersionedProfile;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class ProfileGrpcServiceTest extends SimpleBaseGrpcTest<ProfileGrpcService, ProfileGrpc.ProfileBlockingStub> {\n\n  private static final String VERSION = \"someVersion\";\n\n  private static final byte[] VALID_NAME = new byte[81];\n\n  @Mock\n  private AccountsManager accountsManager;\n\n  @Mock\n  private ProfilesManager profilesManager;\n\n  @Mock\n  private DynamicPaymentsConfiguration dynamicPaymentsConfiguration;\n\n  @Mock\n  private VersionedProfile profile;\n\n  @Mock\n  private Account account;\n\n  @Mock\n  private RateLimiter rateLimiter;\n\n  @Mock\n  private ProfileBadgeConverter profileBadgeConverter;\n\n  @Mock\n  private ServerZkProfileOperations serverZkProfileOperations;\n\n  private Clock clock;\n\n  @Override\n  protected ProfileGrpcService createServiceBeforeEachTest() {\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager = mock(DynamicConfigurationManager.class);\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    final PolicySigner policySigner = new PolicySigner(\"accessSecret\", \"us-west-1\");\n    final PostPolicyGenerator policyGenerator = new PostPolicyGenerator(\"us-west-1\", \"profile-bucket\", \"accessKey\");\n    final BadgesConfiguration badgesConfiguration = new BadgesConfiguration(\n        List.of(new BadgeConfiguration(\n            \"TEST\",\n            \"other\",\n            List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"),\n            \"SVG\",\n            List.of(\n                new BadgeSvg(\"sl\", \"sd\"),\n                new BadgeSvg(\"ml\", \"md\"),\n                new BadgeSvg(\"ll\", \"ld\")\n            )\n        )),\n        List.of(\"TEST1\"),\n        Map.of(1L, \"TEST1\", 2L, \"TEST2\", 3L, \"TEST3\")\n    );\n    final RateLimiters rateLimiters = mock(RateLimiters.class);\n    final String phoneNumber = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    getMockRequestAttributesInterceptor().setRequestAttributes(new RequestAttributes(InetAddresses.forString(\"127.0.0.1\"),\n        \"Signal-Android/1.2.3\",\n        \"en-us\"));\n\n    when(rateLimiters.getProfileLimiter()).thenReturn(rateLimiter);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);\n\n    when(account.getUuid()).thenReturn(AUTHENTICATED_ACI);\n    when(account.getNumber()).thenReturn(phoneNumber);\n    when(account.getBadges()).thenReturn(Collections.emptyList());\n\n    when(profile.paymentAddress()).thenReturn(null);\n    when(profile.avatar()).thenReturn(\"\");\n\n    when(accountsManager.getByAccountIdentifier(any())).thenReturn(Optional.of(account));\n    when(accountsManager.update(any(), any())).thenReturn(null);\n\n    when(profilesManager.get(any(), any())).thenReturn(Optional.of(profile));\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getPaymentsConfiguration()).thenReturn(dynamicPaymentsConfiguration);\n    when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(Collections.emptyList());\n\n    when(profilesManager.deleteAvatar(anyString())).thenReturn(CompletableFuture.completedFuture(null));\n\n    clock = Clock.fixed(Instant.ofEpochSecond(42), ZoneId.of(\"Etc/UTC\"));\n\n    return new ProfileGrpcService(\n        clock,\n        accountsManager,\n        profilesManager,\n        dynamicConfigurationManager,\n        badgesConfiguration,\n        policyGenerator,\n        policySigner,\n        profileBadgeConverter,\n        rateLimiters,\n        serverZkProfileOperations\n    );\n  }\n\n  @Test\n  void setProfile() throws InvalidInputException {\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();\n    final byte[] validAboutEmoji = new byte[60];\n    final byte[] validAbout = new byte[540];\n    final byte[] validPaymentAddress = new byte[582];\n    final byte[] validPhoneNumberSharing = new byte[29];\n\n    final SetProfileRequest request = SetProfileRequest.newBuilder()\n        .setVersion(VERSION)\n        .setName(ByteString.copyFrom(VALID_NAME))\n        .setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)\n        .setAboutEmoji(ByteString.copyFrom(validAboutEmoji))\n        .setAbout(ByteString.copyFrom(validAbout))\n        .setPaymentAddress(ByteString.copyFrom(validPaymentAddress))\n        .setPhoneNumberSharing(ByteString.copyFrom(validPhoneNumberSharing))\n        .setCommitment(ByteString.copyFrom(commitment))\n        .build();\n\n    authenticatedServiceStub().setProfile(request);\n\n    final ArgumentCaptor<VersionedProfile> profileArgumentCaptor = ArgumentCaptor.forClass(VersionedProfile.class);\n\n    verify(profilesManager).set(eq(account.getUuid()), profileArgumentCaptor.capture());\n\n    final VersionedProfile profile = profileArgumentCaptor.getValue();\n\n    assertThat(profile.commitment()).isEqualTo(commitment);\n    assertThat(profile.avatar()).isNull();\n    assertThat(profile.version()).isEqualTo(VERSION);\n    assertThat(profile.name()).isEqualTo(VALID_NAME);\n    assertThat(profile.aboutEmoji()).isEqualTo(validAboutEmoji);\n    assertThat(profile.about()).isEqualTo(validAbout);\n    assertThat(profile.paymentAddress()).isEqualTo(validPaymentAddress);\n    assertThat(profile.phoneNumberSharing()).isEqualTo(validPhoneNumberSharing);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setProfileUpload(final AvatarChange avatarChange, final boolean hasPreviousProfile,\n      final boolean expectHasS3UploadPath, final boolean expectDeleteS3Object) throws InvalidInputException {\n    final String currentAvatar = \"profiles/currentAvatar\";\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();\n\n    final SetProfileRequest request = SetProfileRequest.newBuilder()\n        .setVersion(VERSION)\n        .setName(ByteString.copyFrom(VALID_NAME))\n        .setAvatarChange(avatarChange)\n        .setCommitment(ByteString.copyFrom(commitment))\n        .build();\n\n    when(profile.avatar()).thenReturn(currentAvatar);\n\n    when(profilesManager.get(any(), anyString())).thenReturn(hasPreviousProfile ? Optional.of(profile) : Optional.empty());\n\n    final SetProfileResponse response = authenticatedServiceStub().setProfile(request);\n\n    if (expectHasS3UploadPath) {\n      assertTrue(response.getAttributes().getPath().startsWith(\"profiles/\"));\n    } else {\n      assertEquals(response.getAttributes().getPath(), \"\");\n    }\n\n    if (expectDeleteS3Object) {\n      verify(profilesManager).deleteAvatar(currentAvatar);\n    } else {\n      verify(profilesManager, never()).deleteAvatar(anyString());\n    }\n  }\n\n  private static Stream<Arguments> setProfileUpload() {\n    return Stream.of(\n        // Upload new avatar, no previous avatar\n        Arguments.of(AvatarChange.AVATAR_CHANGE_UPDATE, false, true, false),\n        // Upload new avatar, has previous avatar\n        Arguments.of(AvatarChange.AVATAR_CHANGE_UPDATE, true, true, true),\n        // Clear avatar on profile, no previous avatar\n        Arguments.of(AvatarChange.AVATAR_CHANGE_CLEAR, false, false, false),\n        // Clear avatar on profile, has previous avatar\n        Arguments.of(AvatarChange.AVATAR_CHANGE_CLEAR, true, false, true),\n        // Set same avatar, no previous avatar\n        Arguments.of(AvatarChange.AVATAR_CHANGE_UNCHANGED, false, false, false),\n        // Set same avatar, has previous avatar\n        Arguments.of(AvatarChange.AVATAR_CHANGE_UNCHANGED, true, false, false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void setProfileInvalidRequestData(final SetProfileRequest request) {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().setProfile(request));\n  }\n\n  private static Stream<Arguments> setProfileInvalidRequestData() throws InvalidInputException{\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AuthHelper.VALID_UUID_TWO)).serialize();\n    final byte[] invalidValue = new byte[42];\n\n    final SetProfileRequest prototypeRequest = SetProfileRequest.newBuilder()\n        .setVersion(VERSION)\n        .setName(ByteString.copyFrom(VALID_NAME))\n        .setCommitment(ByteString.copyFrom(commitment))\n        .build();\n\n    return Stream.of(\n        // Missing version\n        Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)\n            .clearVersion()\n            .build()),\n        // Missing name\n        Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)\n            .clearName()\n            .build()),\n        // Invalid name length\n        Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)\n            .setName(ByteString.copyFrom(invalidValue))\n            .build()),\n        // Invalid about emoji length\n        Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)\n            .setAboutEmoji(ByteString.copyFrom(invalidValue))\n            .build()),\n        // Invalid about length\n        Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)\n            .setAbout(ByteString.copyFrom(invalidValue))\n            .build()),\n        // Invalid payment address\n        Arguments.of(SetProfileRequest.newBuilder(prototypeRequest)\n            .setPaymentAddress(ByteString.copyFrom(invalidValue))\n            .build()),\n        // Missing profile commitment\n        Arguments.of(SetProfileRequest.newBuilder()\n            .clearCommitment()\n            .build())\n    );\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void setPaymentAddressDisallowedCountry(final boolean hasExistingPaymentAddress) throws InvalidInputException {\n    final Phonenumber.PhoneNumber disallowedPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber(\"CU\");\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();\n\n    final byte[] validPaymentAddress = new byte[582];\n    if (hasExistingPaymentAddress) {\n      when(profile.paymentAddress()).thenReturn(validPaymentAddress);\n    }\n\n    final SetProfileRequest request = SetProfileRequest.newBuilder()\n        .setVersion(VERSION)\n        .setName(ByteString.copyFrom(VALID_NAME))\n        .setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)\n        .setPaymentAddress(ByteString.copyFrom(validPaymentAddress))\n        .setCommitment(ByteString.copyFrom(commitment))\n        .build();\n    final String disallowedCountryCode = String.format(\"+%d\", disallowedPhoneNumber.getCountryCode());\n    when(dynamicPaymentsConfiguration.getDisallowedPrefixes()).thenReturn(List.of(disallowedCountryCode));\n    when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(\n        disallowedPhoneNumber,\n        PhoneNumberUtil.PhoneNumberFormat.E164));\n    when(profilesManager.get(any(), anyString())).thenReturn(Optional.of(profile));\n\n    if (hasExistingPaymentAddress) {\n      assertDoesNotThrow(() -> authenticatedServiceStub().setProfile(request),\n          \"Payment address changes in disallowed countries should still be allowed if the account already has a valid payment address\");\n    } else {\n      assertStatusException(Status.PERMISSION_DENIED, () -> authenticatedServiceStub().setProfile(request));\n    }\n  }\n\n  @Test\n  void setProfileBadges() throws InvalidInputException {\n\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(AUTHENTICATED_ACI)).serialize();\n\n    final SetProfileRequest request = SetProfileRequest.newBuilder()\n        .setVersion(VERSION)\n        .setName(ByteString.copyFrom(VALID_NAME))\n        .setAvatarChange(AvatarChange.AVATAR_CHANGE_UNCHANGED)\n        .addAllBadgeIds(List.of(\"TEST3\"))\n        .setCommitment(ByteString.copyFrom(commitment))\n        .build();\n\n    final int accountsManagerUpdateRetryCount = 2;\n    AccountsHelper.setupMockUpdateWithRetries(accountsManager, accountsManagerUpdateRetryCount);\n    // set up two invocations -- one for each AccountsManager#update try\n    when(account.getBadges())\n        .thenReturn(List.of(new AccountBadge(\"TEST3\", Instant.ofEpochSecond(41), false)))\n        .thenReturn(List.of(new AccountBadge(\"TEST2\", Instant.ofEpochSecond(41), true),\n            new AccountBadge(\"TEST3\", Instant.ofEpochSecond(41), false)));\n\n    //noinspection ResultOfMethodCallIgnored\n    authenticatedServiceStub().setProfile(request);\n\n    //noinspection unchecked\n    final ArgumentCaptor<List<AccountBadge>> badgeCaptor = ArgumentCaptor.forClass(List.class);\n    verify(account, times(2)).setBadges(refEq(clock), badgeCaptor.capture());\n    // since the stubbing of getBadges() is brittle, we need to verify the number of invocations, to protect against upstream changes\n    verify(account, times(accountsManagerUpdateRetryCount)).getBadges();\n\n    assertEquals(List.of(\n        new AccountBadge(\"TEST3\", Instant.ofEpochSecond(41), true),\n        new AccountBadge(\"TEST2\", Instant.ofEpochSecond(41), false)),\n        badgeCaptor.getValue());\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void getUnversionedProfile(final IdentityType identityType) throws Exception {\n    final UUID targetUuid = UUID.randomUUID();\n    final org.whispersystems.textsecuregcm.identity.ServiceIdentifier targetIdentifier =\n        identityType == IdentityType.IDENTITY_TYPE_ACI ? new AciServiceIdentifier(targetUuid) : new PniServiceIdentifier(targetUuid);\n\n    final GetUnversionedProfileRequest request = GetUnversionedProfileRequest.newBuilder()\n        .setServiceIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(identityType)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n            .build())\n        .build();\n    final byte[] unidentifiedAccessKey = TestRandomUtil.nextBytes(UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH);\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final IdentityKey identityKey = new IdentityKey(identityKeyPair.getPublicKey());\n\n    final List<Badge> badges = List.of(new Badge(\n        \"TEST\",\n        \"other\",\n        \"Test Badge\",\n        \"This badge is in unit tests.\",\n        List.of(\"l\", \"m\", \"h\", \"x\", \"xx\", \"xxx\"),\n        \"SVG\",\n        List.of(\n            new BadgeSvg(\"sl\", \"sd\"),\n            new BadgeSvg(\"ml\", \"md\"),\n            new BadgeSvg(\"ll\", \"ld\")))\n    );\n\n    when(account.getIdentityKey(IdentityTypeUtil.fromGrpcIdentityType(identityType))).thenReturn(identityKey);\n    when(account.isUnrestrictedUnidentifiedAccess()).thenReturn(true);\n    when(account.getUnidentifiedAccessKey()).thenReturn(Optional.of(unidentifiedAccessKey));\n    when(account.getBadges()).thenReturn(Collections.emptyList());\n    when(account.hasCapability(any())).thenReturn(false);\n    when(profileBadgeConverter.convert(any(), any(), anyBoolean())).thenReturn(badges);\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.of(account));\n\n    final GetUnversionedProfileResponse response = authenticatedServiceStub().getUnversionedProfile(request);\n\n    final byte[] unidentifiedAccessChecksum = UnidentifiedAccessChecksum.generateFor(unidentifiedAccessKey);\n    final GetUnversionedProfileResponse prototypeExpectedResponse = GetUnversionedProfileResponse.newBuilder()\n        .setIdentityKey(ByteString.copyFrom(identityKey.serialize()))\n        .setUnidentifiedAccess(ByteString.copyFrom(unidentifiedAccessChecksum))\n        .setUnrestrictedUnidentifiedAccess(true)\n        .addAllBadges(ProfileGrpcHelper.buildBadges(badges))\n        .build();\n\n    final GetUnversionedProfileResponse expectedResponse;\n    if (identityType == IdentityType.IDENTITY_TYPE_PNI) {\n      expectedResponse = GetUnversionedProfileResponse.newBuilder(prototypeExpectedResponse)\n          .clearUnidentifiedAccess()\n          .clearBadges()\n          .setUnrestrictedUnidentifiedAccess(false)\n          .build();\n    } else {\n      expectedResponse = prototypeExpectedResponse;\n    }\n\n    verify(rateLimiter).validate(AUTHENTICATED_ACI);\n    verify(accountsManager).getByServiceIdentifier(targetIdentifier);\n\n    assertEquals(expectedResponse, response);\n  }\n\n  @Test\n  void getUnversionedProfileTargetAccountNotFound() {\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.empty());\n\n    final GetUnversionedProfileRequest request = GetUnversionedProfileRequest.newBuilder()\n        .setServiceIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .build();\n\n    assertStatusException(Status.NOT_FOUND, () -> authenticatedServiceStub().getUnversionedProfile(request));\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = org.signal.chat.common.IdentityType.class, names = {\"IDENTITY_TYPE_ACI\", \"IDENTITY_TYPE_PNI\"})\n  void getUnversionedProfileRatelimited(final IdentityType identityType) throws Exception {\n    final Duration retryAfterDuration = Duration.ofMinutes(7);\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.of(account));\n    doThrow(new RateLimitExceededException(retryAfterDuration))\n        .when(rateLimiter).validate(any(UUID.class));\n\n    final GetUnversionedProfileRequest request = GetUnversionedProfileRequest.newBuilder()\n        .setServiceIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(identityType)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .build();\n\n    assertRateLimitExceeded(retryAfterDuration, () -> authenticatedServiceStub().getUnversionedProfile(request), accountsManager);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getVersionedProfile(final String requestVersion, @Nullable final String accountVersion, final boolean expectResponseHasPaymentAddress) {\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n    final String avatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n\n    final VersionedProfile profile = new VersionedProfile(accountVersion, name, avatar, emoji, about, paymentAddress,\n        phoneNumberSharing, new byte[0]);\n\n    final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .setVersion(requestVersion)\n        .build();\n\n    when(account.getCurrentProfileVersion()).thenReturn(Optional.ofNullable(accountVersion));\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.of(account));\n    when(profilesManager.get(any(), any())).thenReturn(Optional.of(profile));\n\n    final GetVersionedProfileResponse response = authenticatedServiceStub().getVersionedProfile(request);\n\n    final GetVersionedProfileResponse.Builder expectedResponseBuilder = GetVersionedProfileResponse.newBuilder()\n        .setName(ByteString.copyFrom(name))\n        .setAbout(ByteString.copyFrom(about))\n        .setAboutEmoji(ByteString.copyFrom(emoji))\n        .setAvatar(avatar)\n        .setPhoneNumberSharing(ByteString.copyFrom(phoneNumberSharing));\n\n    if (expectResponseHasPaymentAddress) {\n      expectedResponseBuilder.setPaymentAddress(ByteString.copyFrom(paymentAddress));\n    }\n\n    assertEquals(expectedResponseBuilder.build(), response);\n  }\n  private static Stream<Arguments> getVersionedProfile() {\n    return Stream.of(\n        Arguments.of(\"version1\", \"version1\", true),\n        Arguments.of(\"version1\", null, true),\n        Arguments.of(\"version1\", \"version2\", false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getVersionedProfileAccountOrProfileNotFound(final boolean missingAccount, final boolean missingProfile) {\n    final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .setVersion(\"versionWithNoProfile\")\n        .build();\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(missingAccount ? Optional.empty() : Optional.of(account));\n    when(profilesManager.get(any(), any())).thenReturn(missingProfile ? Optional.empty() : Optional.of(profile));\n\n    assertStatusException(Status.NOT_FOUND, () -> authenticatedServiceStub().getVersionedProfile(request));\n  }\n\n  private static Stream<Arguments> getVersionedProfileAccountOrProfileNotFound() {\n    return Stream.of(\n        Arguments.of(true, false),\n        Arguments.of(false, true)\n    );\n  }\n\n  @Test\n  void getVersionedProfileRatelimited() {\n    final Duration retryAfterDuration = MockUtils.updateRateLimiterResponseToFail(rateLimiter, AUTHENTICATED_ACI, Duration.ofMinutes(7));\n\n    final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .setVersion(\"someVersion\")\n        .build();\n\n    assertRateLimitExceeded(retryAfterDuration, () -> authenticatedServiceStub().getVersionedProfile(request), accountsManager, profilesManager);\n  }\n\n  @Test\n  void getVersionedProfilePniInvalidArgument() {\n    final GetVersionedProfileRequest request = GetVersionedProfileRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_PNI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .setVersion(\"someVersion\")\n        .build();\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getVersionedProfile(request));\n  }\n\n  @Test\n  void getExpiringProfileKeyCredential() throws InvalidInputException, VerificationFailedException {\n    final UUID targetUuid = UUID.randomUUID();\n\n    final ServerSecretParams serverSecretParams = ServerSecretParams.generate();\n    final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();\n\n    final ServerZkProfileOperations serverZkProfile = new ServerZkProfileOperations(serverSecretParams);\n    final ClientZkProfileOperations clientZkProfile = new ClientZkProfileOperations(serverPublicParams);\n\n    final byte[] profileKeyBytes = TestRandomUtil.nextBytes(32);\n    final ProfileKey profileKey = new ProfileKey(profileKeyBytes);\n    final ProfileKeyCommitment profileKeyCommitment = profileKey.getCommitment(new ServiceId.Aci(targetUuid));\n    final ProfileKeyCredentialRequestContext profileKeyCredentialRequestContext =\n        clientZkProfile.createProfileKeyCredentialRequestContext(new ServiceId.Aci(targetUuid), profileKey);\n\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(profile.commitment()).thenReturn(profileKeyCommitment.serialize());\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid))).thenReturn(Optional.of(account));\n    when(profilesManager.get(targetUuid, \"someVersion\")).thenReturn(Optional.of(profile));\n\n    final ProfileKeyCredentialRequest credentialRequest = profileKeyCredentialRequestContext.getRequest();\n\n    final Instant expiration = Instant.now().plus(org.whispersystems.textsecuregcm.util.ProfileHelper.EXPIRING_PROFILE_KEY_CREDENTIAL_EXPIRATION)\n        .truncatedTo(ChronoUnit.DAYS);\n\n    final ExpiringProfileKeyCredentialResponse credentialResponse =\n        serverZkProfile.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration);\n\n    when(serverZkProfileOperations.issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration))\n        .thenReturn(credentialResponse);\n\n    final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n            .build())\n        .setCredentialRequest(ByteString.copyFrom(credentialRequest.serialize()))\n        .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY)\n        .setVersion(\"someVersion\")\n        .build();\n\n    final GetExpiringProfileKeyCredentialResponse response = authenticatedServiceStub().getExpiringProfileKeyCredential(request);\n\n    assertArrayEquals(credentialResponse.serialize(), response.getProfileKeyCredential().toByteArray());\n\n    verify(serverZkProfileOperations).issueExpiringProfileKeyCredential(credentialRequest, new ServiceId.Aci(targetUuid), profileKeyCommitment, expiration);\n\n    final ClientZkProfileOperations clientZkProfileCipher = new ClientZkProfileOperations(serverPublicParams);\n    assertThatNoException().isThrownBy(() ->\n        clientZkProfileCipher.receiveExpiringProfileKeyCredential(profileKeyCredentialRequestContext, new ExpiringProfileKeyCredentialResponse(response.getProfileKeyCredential().toByteArray())));\n  }\n\n  @Test\n  void getExpiringProfileKeyCredentialRateLimited() {\n    final Duration retryAfterDuration = MockUtils.updateRateLimiterResponseToFail(\n        rateLimiter, AUTHENTICATED_ACI, Duration.ofMinutes(5));\n    when(accountsManager.getByServiceIdentifier(any())).thenReturn(Optional.of(account));\n\n    final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(UUID.randomUUID())))\n            .build())\n        .setCredentialRequest(ByteString.copyFrom(\"credentialRequest\".getBytes(StandardCharsets.UTF_8)))\n        .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY)\n        .setVersion(\"someVersion\")\n        .build();\n\n    assertRateLimitExceeded(retryAfterDuration, () -> authenticatedServiceStub().getExpiringProfileKeyCredential(request), profilesManager);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getExpiringProfileKeyCredentialAccountOrProfileNotFound(final boolean missingAccount,\n      final boolean missingProfile) {\n    final UUID targetUuid = UUID.randomUUID();\n\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid)))\n        .thenReturn(missingAccount ? Optional.empty() : Optional.of(account));\n    when(profilesManager.get(targetUuid, \"someVersion\"))\n        .thenReturn(missingProfile ? Optional.empty() : Optional.of(profile));\n\n    final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(IdentityType.IDENTITY_TYPE_ACI)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n            .build())\n        .setCredentialRequest(ByteString.copyFrom(\"credentialRequest\".getBytes(StandardCharsets.UTF_8)))\n        .setCredentialType(CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY)\n        .setVersion(\"someVersion\")\n        .build();\n\n    assertStatusException(Status.NOT_FOUND, () -> authenticatedServiceStub().getExpiringProfileKeyCredential(request));\n  }\n\n  private static Stream<Arguments> getExpiringProfileKeyCredentialAccountOrProfileNotFound() {\n    return Stream.of(\n        Arguments.of(true, false),\n        Arguments.of(false, true)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getExpiringProfileKeyCredentialInvalidArgument(final IdentityType identityType, final CredentialType credentialType,\n      final boolean throwZkVerificationException) throws VerificationFailedException {\n    final UUID targetUuid = UUID.randomUUID();\n\n    if (throwZkVerificationException) {\n      when(serverZkProfileOperations.issueExpiringProfileKeyCredential(any(), any(), any(), any())).thenThrow(new VerificationFailedException());\n    }\n\n    when(account.getUuid()).thenReturn(targetUuid);\n    when(profile.commitment()).thenReturn(\"commitment\".getBytes(StandardCharsets.UTF_8));\n    when(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(targetUuid))).thenReturn(Optional.of(account));\n    when(profilesManager.get(targetUuid, \"someVersion\")).thenReturn(Optional.of(profile));\n\n    final GetExpiringProfileKeyCredentialRequest request = GetExpiringProfileKeyCredentialRequest.newBuilder()\n        .setAccountIdentifier(ServiceIdentifier.newBuilder()\n            .setIdentityType(identityType)\n            .setUuid(ByteString.copyFrom(UUIDUtil.toBytes(targetUuid)))\n            .build())\n        .setCredentialRequest(ByteString.copyFrom(\"credentialRequest\".getBytes(StandardCharsets.UTF_8)))\n        .setCredentialType(credentialType)\n        .setVersion(\"someVersion\")\n        .build();\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> authenticatedServiceStub().getExpiringProfileKeyCredential(request));\n  }\n\n  private static Stream<Arguments> getExpiringProfileKeyCredentialInvalidArgument() {\n    return Stream.of(\n        // Credential type unspecified\n        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_UNSPECIFIED, false),\n        // Illegal identity type\n        Arguments.of(IdentityType.IDENTITY_TYPE_PNI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, false),\n        // Artificially fails zero knowledge verification\n        Arguments.of(IdentityType.IDENTITY_TYPE_ACI, CredentialType.CREDENTIAL_TYPE_EXPIRING_PROFILE_KEY, true)\n    );\n  }\n\n  @Override\n  protected List<ServerInterceptor> customizeInterceptors(List<ServerInterceptor> serverInterceptors) {\n    return serverInterceptors.stream()\n        // For now, don't validate error conformance because the profiles gRPC service has not been converted to the\n        // updated error model\n        .filter(interceptor -> !(interceptor instanceof ErrorConformanceInterceptor))\n        .toList();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/RequestAttributesInterceptorTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.ManagedChannel;\nimport io.grpc.Metadata;\nimport io.grpc.Server;\nimport io.grpc.ServerCall;\nimport io.grpc.ServerCallHandler;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.netty.NettyChannelBuilder;\nimport io.grpc.netty.NettyServerBuilder;\nimport io.grpc.stub.MetadataUtils;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.chat.rpc.GetRequestAttributesRequest;\nimport org.signal.chat.rpc.GetRequestAttributesResponse;\nimport org.signal.chat.rpc.RequestAttributesGrpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\npublic class RequestAttributesInterceptorTest {\n\n  private static final String USER_AGENT = \"Signal-Android/4.53.7 (Android 8.1; libsignal)\";\n  private Server server;\n  private AtomicBoolean removeUserAgent;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    removeUserAgent = new AtomicBoolean(false);\n\n    server = NettyServerBuilder.forPort(0)\n        .directExecutor()\n        .intercept(new RequestAttributesInterceptor())\n        // the grpc client always inserts a user-agent if we don't set one, so to test missing UAs we remove the header\n        // on the server-side\n        .intercept(new ServerInterceptor() {\n          @Override\n          public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,\n              final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {\n            if (removeUserAgent.get()) {\n              headers.removeAll(Metadata.Key.of(\"user-agent\", Metadata.ASCII_STRING_MARSHALLER));\n            }\n            return next.startCall(call, headers);\n          }\n        })\n        .addService(new RequestAttributesServiceImpl())\n        .build()\n        .start();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    server.shutdownNow();\n    server.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  private static List<Arguments> handleInvalidAcceptLanguage() {\n    return List.of(\n        Arguments.argumentSet(\"Null Accept-Language header\", Optional.empty()),\n        Arguments.argumentSet(\"Empty Accept-Language header\", Optional.of(\"\")),\n        Arguments.argumentSet(\"Invalid Accept-Language header\", Optional.of(\"This is not a valid language preference list\")));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void handleInvalidAcceptLanguage(Optional<String> acceptLanguageHeader) throws Exception {\n    final Metadata metadata = new Metadata();\n    acceptLanguageHeader.ifPresent(h -> metadata\n        .put(Metadata.Key.of(\"accept-language\", Metadata.ASCII_STRING_MARSHALLER), h));\n    final GetRequestAttributesResponse response = getRequestAttributes(metadata);\n    assertEquals(response.getAcceptableLanguagesCount(), 0);\n  }\n\n  @Test\n  void handleMissingUserAgent() throws InterruptedException {\n    removeUserAgent.set(true);\n    final GetRequestAttributesResponse response = getRequestAttributes(new Metadata());\n    assertEquals(\"\", response.getUserAgent());\n  }\n\n  @Test\n  void allAttributes() throws InterruptedException {\n    final Metadata metadata = new Metadata();\n    metadata.put(Metadata.Key.of(\"accept-language\", Metadata.ASCII_STRING_MARSHALLER), \"ja,en;q=0.4\");\n    metadata.put(Metadata.Key.of(\"x-forwarded-for\", Metadata.ASCII_STRING_MARSHALLER), \"127.0.0.3\");\n    final GetRequestAttributesResponse response = getRequestAttributes(metadata);\n\n    assertTrue(response.getUserAgent().contains(USER_AGENT));\n    assertEquals(\"127.0.0.3\", response.getRemoteAddress());\n    assertEquals(2, response.getAcceptableLanguagesCount());\n    assertEquals(\"ja\", response.getAcceptableLanguages(0));\n    assertEquals(\"en;q=0.4\", response.getAcceptableLanguages(1));\n  }\n\n  @Test\n  void useSocketAddrIfHeaderMissing() throws InterruptedException {\n    final GetRequestAttributesResponse response = getRequestAttributes(new Metadata());\n    assertEquals(\"127.0.0.1\", response.getRemoteAddress());\n  }\n\n  private GetRequestAttributesResponse getRequestAttributes(Metadata metadata)\n      throws InterruptedException {\n    final ManagedChannel channel = NettyChannelBuilder.forAddress(\"localhost\", server.getPort())\n        .directExecutor()\n        .usePlaintext()\n        .userAgent(USER_AGENT)\n        .build();\n    try {\n      final RequestAttributesGrpc.RequestAttributesBlockingStub client = RequestAttributesGrpc\n          .newBlockingStub(channel)\n          .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));\n      return client.getRequestAttributes(GetRequestAttributesRequest.getDefaultInstance());\n    } finally {\n      channel.shutdownNow();\n      channel.awaitTermination(1, TimeUnit.SECONDS);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/RequestAttributesServiceImpl.java",
    "content": "package org.whispersystems.textsecuregcm.grpc;\n\nimport io.grpc.stub.StreamObserver;\nimport org.signal.chat.rpc.GetAuthenticatedDeviceRequest;\nimport org.signal.chat.rpc.GetAuthenticatedDeviceResponse;\nimport org.signal.chat.rpc.GetRequestAttributesRequest;\nimport org.signal.chat.rpc.GetRequestAttributesResponse;\nimport org.signal.chat.rpc.RequestAttributesGrpc;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.grpc.AuthenticationUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\npublic class RequestAttributesServiceImpl extends RequestAttributesGrpc.RequestAttributesImplBase {\n\n  @Override\n  public void getRequestAttributes(final GetRequestAttributesRequest request,\n      final StreamObserver<GetRequestAttributesResponse> responseObserver) {\n\n    final GetRequestAttributesResponse.Builder responseBuilder = GetRequestAttributesResponse.newBuilder();\n\n    RequestAttributesUtil.getAcceptableLanguages()\n        .forEach(languageRange -> responseBuilder.addAcceptableLanguages(languageRange.toString()));\n\n    RequestAttributesUtil.getAvailableAcceptedLocales().forEach(locale ->\n        responseBuilder.addAvailableAcceptedLocales(locale.toLanguageTag()));\n\n    responseBuilder.setRemoteAddress(RequestAttributesUtil.getRemoteAddress().getHostAddress());\n\n    RequestAttributesUtil.getUserAgent().ifPresent(responseBuilder::setUserAgent);\n\n    responseObserver.onNext(responseBuilder.build());\n    responseObserver.onCompleted();\n  }\n\n  @Override\n  public void getAuthenticatedDevice(final GetAuthenticatedDeviceRequest request,\n      final StreamObserver<GetAuthenticatedDeviceResponse> responseObserver) {\n\n    final GetAuthenticatedDeviceResponse.Builder responseBuilder = GetAuthenticatedDeviceResponse.newBuilder();\n\n    try {\n      final AuthenticatedDevice authenticatedDevice = AuthenticationUtil.requireAuthenticatedDevice();\n\n      responseBuilder.setAccountIdentifier(UUIDUtil.toByteString(authenticatedDevice.accountIdentifier()));\n      responseBuilder.setDeviceId(authenticatedDevice.deviceId());\n    } catch (final Exception ignored) {\n    }\n\n    responseObserver.onNext(responseBuilder.build());\n    responseObserver.onCompleted();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/RequestAttributesUtilTest.java",
    "content": "package org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.google.common.net.InetAddresses;\nimport io.grpc.Context;\nimport java.net.InetAddress;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Optional;\nimport java.util.concurrent.Callable;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\n\nclass RequestAttributesUtilTest {\n\n  private static final InetAddress REMOTE_ADDRESS = InetAddresses.forString(\"127.0.0.1\");\n\n  @Test\n  void getAcceptableLanguages() throws Exception {\n    assertEquals(Collections.emptyList(),\n        callWithRequestAttributes(buildRequestAttributes(null, null),\n            RequestAttributesUtil::getAcceptableLanguages));\n\n    assertEquals(Locale.LanguageRange.parse(\"en,ja\"),\n        callWithRequestAttributes(buildRequestAttributes(null, \"en,ja\"),\n            RequestAttributesUtil::getAcceptableLanguages));\n  }\n\n  @Test\n  void getAvailableAcceptedLocales() throws Exception {\n    assertEquals(Collections.emptyList(),\n        callWithRequestAttributes(buildRequestAttributes(null, null),\n            RequestAttributesUtil::getAvailableAcceptedLocales));\n\n    final List<Locale> availableAcceptedLocales =\n        callWithRequestAttributes(buildRequestAttributes(null, \"en,ja\"),\n            RequestAttributesUtil::getAvailableAcceptedLocales);\n\n    assertFalse(availableAcceptedLocales.isEmpty());\n\n    availableAcceptedLocales.forEach(locale ->\n        assertTrue(\"en\".equals(locale.getLanguage()) || \"ja\".equals(locale.getLanguage())));\n  }\n\n  @Test\n  void getRemoteAddress() throws Exception {\n    assertEquals(REMOTE_ADDRESS,\n        callWithRequestAttributes(new RequestAttributes(REMOTE_ADDRESS, null, null),\n            RequestAttributesUtil::getRemoteAddress));\n  }\n\n  @Test\n  void getUserAgent() throws Exception {\n    assertEquals(Optional.empty(),\n        callWithRequestAttributes(buildRequestAttributes(null, null),\n            RequestAttributesUtil::getUserAgent));\n\n    assertEquals(Optional.of(\"Signal-Desktop/1.2.3 Linux\"),\n        callWithRequestAttributes(buildRequestAttributes(\"Signal-Desktop/1.2.3 Linux\", null),\n            RequestAttributesUtil::getUserAgent));\n  }\n\n  private static <V> V callWithRequestAttributes(final RequestAttributes requestAttributes, final Callable<V> callable) throws Exception {\n    return Context.current()\n        .withValue(RequestAttributesUtil.REQUEST_ATTRIBUTES_CONTEXT_KEY, requestAttributes)\n        .call(callable);\n  }\n\n  private static RequestAttributes buildRequestAttributes(@Nullable final String userAgent,\n      @Nullable final String acceptLanguage) {\n\n    return new RequestAttributes(REMOTE_ADDRESS, userAgent, acceptLanguage);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/SimpleBaseGrpcTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static java.util.Objects.requireNonNull;\n\nimport io.grpc.BindableService;\nimport io.grpc.Channel;\nimport io.grpc.ServerInterceptor;\nimport io.grpc.ServerInterceptors;\nimport io.grpc.stub.AbstractBlockingStub;\nimport java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.util.List;\nimport java.util.UUID;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.mockito.MockitoAnnotations;\nimport org.whispersystems.textsecuregcm.auth.grpc.MockAuthenticationInterceptor;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\n/**\n * Base class for the common case of gRPC services tests. This base class makes some assumptions\n * and introduces some constraints on the implementing classes with a goal of simplifying the process\n * of creating a test for the most of the gRPC services.\n * <ul>\n * <li>\n *   Test classes extending this class will have to override the {@link #createServiceBeforeEachTest()} method\n *   with the logic that creates an instance of the service to test. This method is called before each test and should\n *   contain other setup code that would normally go into {@code @BeforeEach} method.\n * </li>\n * <li>\n *   This base class takes care of creating two service stubs: {@code authenticatedServiceStub} and {@code unauthenticatedServiceStub}.\n *   Normally, those stubs are created by the call to the {@code newBlockingStub()} method on the {@code *Stub} class, e.g.:\n *   <pre>CallingGrpc.newBlockingStub(GRPC_SERVER_EXTENSION_AUTHENTICATED.getChannel());</pre>\n *   In this class, those stubs are created by the {@link #createStub(Channel)} method that has a default implementation that is based on\n *   figuring out the name of the {@code `*Grpc`} class and invoking {@code `*Grpc.newBlockingStub()`} method with reflection.\n * </li>\n * <li>\n *   This class takes care of initializing {@code Mockito} annotations processing, so implementing classes\n *   can annotate their fields with {@code @Mock} and have those mocks ready by the time {@link #createServiceBeforeEachTest()} is called.\n * </li>\n * </ul>\n * @param <SERVICE> Class of the gRPC service that is being tested.\n * @param <STUB> Class of the gRPC service stub.\n */\npublic abstract class SimpleBaseGrpcTest<SERVICE extends BindableService, STUB extends AbstractBlockingStub<STUB>> {\n\n  @RegisterExtension\n  protected static final GrpcServerExtension GRPC_SERVER_EXTENSION_AUTHENTICATED = new GrpcServerExtension();\n\n  @RegisterExtension\n  protected static final GrpcServerExtension GRPC_SERVER_EXTENSION_UNAUTHENTICATED = new GrpcServerExtension();\n\n  protected static final UUID AUTHENTICATED_ACI = UUID.randomUUID();\n\n  protected static final byte AUTHENTICATED_DEVICE_ID = Device.PRIMARY_ID;\n\n  private AutoCloseable mocksCloseable;\n\n  private final MockAuthenticationInterceptor mockAuthenticationInterceptor = new MockAuthenticationInterceptor();\n  private final MockRequestAttributesInterceptor mockRequestAttributesInterceptor = new MockRequestAttributesInterceptor();\n\n  private SERVICE service;\n\n  private STUB authenticatedServiceStub;\n\n  private STUB unauthenticatedServiceStub;\n\n\n  /**\n   * This method is invoked before each test and is expected to create an instance of the gRPC service\n   * that is being tested and also to perform all necessary before-each setup.\n   * </p>\n   * Extending classes may have their own {@code @BeforeEach} method, but it will be called after this method.\n   * @return an instance of the gRPC service.\n   */\n  protected abstract SERVICE createServiceBeforeEachTest();\n\n  /**\n   * The default implementation of this method is based on figuring out the name of the {@code `*Grpc`} class\n   * and invoking {@code `*Grpc.newBlockingStub()`} method with reflection.\n   * <p>\n   * Overriding this method can be helpful if addutional configuration of the stub is required, e.g. adding interceptors:\n   * <pre>\n   * protected ProfileAnonymousGrpc.ProfileAnonymousBlockingStub createStub(final Channel channel) throws ClassNotFoundException, InvocationTargetException, NoSuchMethodException, IllegalAccessException {\n   *   final Metadata metadata = new Metadata();\n   *   metadata.put(AcceptLanguageInterceptor.ACCEPTABLE_LANGUAGES_GRPC_HEADER, \"en-us\");\n   *   metadata.put(UserAgentInterceptor.USER_AGENT_GRPC_HEADER, \"Signal-Android/1.2.3\");\n   *   return super.createStub(channel)\n   *       .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(metadata));\n   * }\n   * </pre>\n   * @param channel grpc channel to create create the stub for.\n   * @return and instance of the service stub.\n   */\n  protected STUB createStub(final Channel channel) throws\n      ClassNotFoundException,\n      NoSuchMethodException,\n      InvocationTargetException,\n      IllegalAccessException {\n    final String serviceClassName = service.bindService().getServiceDescriptor().getName();\n    final String grpcClassName = serviceClassName + \"Grpc\";\n    final Class<?> grpcClass = ClassLoader.getSystemClassLoader().loadClass(grpcClassName);\n    final Method newBlockingStubMethod = grpcClass.getMethod(\"newBlockingStub\", Channel.class);\n    final Object stub = newBlockingStubMethod.invoke(null, channel);\n    //noinspection unchecked\n    return (STUB) stub;\n  }\n\n  @BeforeEach\n  protected void baseSetup() {\n    mocksCloseable = MockitoAnnotations.openMocks(this);\n    service = requireNonNull(createServiceBeforeEachTest(), \"created service must not be `null`\");\n\n    mockAuthenticationInterceptor.setAuthenticatedDevice(AUTHENTICATED_ACI, AUTHENTICATED_DEVICE_ID);\n    final List<ServerInterceptor> authenticatedInterceptors =\n        List.of(new ValidatingInterceptor(), mockRequestAttributesInterceptor, mockAuthenticationInterceptor, new ErrorMappingInterceptor(), new ErrorConformanceInterceptor());\n    GRPC_SERVER_EXTENSION_AUTHENTICATED\n        .getServiceRegistry()\n        .addService(ServerInterceptors.intercept(service, customizeInterceptors(authenticatedInterceptors)));\n\n    final List<ServerInterceptor> unauthenticatedInterceptors =\n        List.of(new ValidatingInterceptor(), mockRequestAttributesInterceptor, new ErrorMappingInterceptor(), new ErrorConformanceInterceptor());\n    GRPC_SERVER_EXTENSION_UNAUTHENTICATED.getServiceRegistry()\n        .addService(ServerInterceptors.intercept(service, customizeInterceptors(unauthenticatedInterceptors)));\n    try {\n      authenticatedServiceStub = createStub(GRPC_SERVER_EXTENSION_AUTHENTICATED.getChannel());\n      unauthenticatedServiceStub = createStub(GRPC_SERVER_EXTENSION_UNAUTHENTICATED.getChannel());\n    } catch (Exception e) {\n      throw new RuntimeException(\"Could not create a stub based on the service name. Try overriding `createStub()` method.\");\n    }\n  }\n\n  @AfterEach\n  public void releaseMocks() throws Exception {\n    mocksCloseable.close();\n  }\n\n  public MockAuthenticationInterceptor mockAuthenticationInterceptor() {\n    return mockAuthenticationInterceptor;\n  }\n\n  protected SERVICE service() {\n    return service;\n  }\n\n  protected STUB authenticatedServiceStub() {\n    return authenticatedServiceStub;\n  }\n\n  protected STUB unauthenticatedServiceStub() {\n    return unauthenticatedServiceStub;\n  }\n\n  protected MockRequestAttributesInterceptor getMockRequestAttributesInterceptor() {\n    return mockRequestAttributesInterceptor;\n  }\n\n  protected MockAuthenticationInterceptor getMockAuthenticationInterceptor() {\n    return mockAuthenticationInterceptor;\n  }\n\n  protected List<ServerInterceptor> customizeInterceptors(List<ServerInterceptor> serverInterceptors) {\n    return serverInterceptors;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.grpc;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.Empty;\nimport io.grpc.ServerInterceptors;\nimport io.grpc.Status;\nimport io.grpc.StatusRuntimeException;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport javax.annotation.Nonnull;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.junit.jupiter.api.Assertions;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.api.function.Executable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.signal.chat.require.Auth;\nimport org.signal.chat.rpc.Color;\nimport org.signal.chat.rpc.NestedMessage;\nimport org.signal.chat.rpc.RecursiveMessage;\nimport org.signal.chat.rpc.SimpleAnonymousServiceGrpc;\nimport org.signal.chat.rpc.SimpleAuthServiceGrpc;\nimport org.signal.chat.rpc.SimpleValidationTestServiceGrpc;\nimport org.signal.chat.rpc.ValidationTestServiceGrpc;\nimport org.signal.chat.rpc.ValidationsRequest;\nimport org.signal.chat.rpc.ValidationsResponse;\nimport org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\npublic class ValidatingInterceptorTest {\n\n  @RegisterExtension\n  static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension();\n\n  private static final class ValidationTestGrpcServiceImpl extends\n      SimpleValidationTestServiceGrpc.ValidationTestServiceImplBase {\n\n    @Override\n    public ValidationsResponse validationsEndpoint(final ValidationsRequest request) {\n      return ValidationsResponse.getDefaultInstance();\n    }\n  }\n\n  private static final class AuthGrpcServiceImpl extends SimpleAuthServiceGrpc.AuthServiceImplBase {\n\n    @Override\n    public Empty authenticatedMethod(final Empty request) {\n      return Empty.getDefaultInstance();\n    }\n  }\n\n  private static final class AnonymousGrpcServiceImpl extends SimpleAnonymousServiceGrpc.AnonymousServiceImplBase {\n\n    @Override\n    public Empty anonymousMethod(final Empty request) {\n      return Empty.getDefaultInstance();\n    }\n  }\n\n  private ValidationTestServiceGrpc.ValidationTestServiceBlockingStub stub;\n\n\n  @BeforeEach\n  void setUp() {\n    final ValidationTestGrpcServiceImpl validationTestGrpcService = new ValidationTestGrpcServiceImpl();\n    final AuthGrpcServiceImpl authGrpcService = new AuthGrpcServiceImpl();\n    final AnonymousGrpcServiceImpl anonymousGrpcService = new AnonymousGrpcServiceImpl();\n\n    GRPC_SERVER_EXTENSION.getServiceRegistry()\n        .addService(ServerInterceptors.intercept(validationTestGrpcService, new ValidatingInterceptor()));\n    GRPC_SERVER_EXTENSION.getServiceRegistry()\n        .addService(authGrpcService);\n    GRPC_SERVER_EXTENSION.getServiceRegistry()\n        .addService(anonymousGrpcService);\n\n    stub = ValidationTestServiceGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel());\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"15551234567\", \"\", \"123\", \"+1 555 1234567\", \"asdf\"})\n  public void testE164ValidationFailure(final String invalidNumber) throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setNumber(invalidNumber)\n            .build()\n    ));\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {0, 1, 2, 3, 4, 6, 1000})\n  public void testExactlySizeValidationFailure(final int size) throws Exception {\n    final String stringValue = RandomStringUtils.secure().nextAlphanumeric(size);\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setFixedSizeString(stringValue)\n            .build()\n    ));\n\n    final ByteString byteValue = ByteString.copyFrom(TestRandomUtil.nextBytes(size));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setFixedSizeBytes(byteValue)\n            .build()\n    ));\n\n    final List<String> listValue = IntStream.range(0, size)\n        .mapToObj(i -> RandomStringUtils.secure().nextAlphabetic(10))\n        .toList();\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearFixedSizeList()\n            .addAllFixedSizeList(listValue)\n            .build()\n    ));\n  }\n\n  @Test\n  public void testExactlySizeMultiplePermittedValues() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setExactlySizeVariants(\"abc\")\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setExactlySizeVariants(\"\")\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearExactlySizeVariants()\n            .build()\n    ));\n    stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setExactlySizeVariants(\"ab\")\n            .build()\n    );\n    stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setExactlySizeVariants(\"abcd\")\n            .build()\n    );\n    stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .build()\n    );\n  }\n\n  public static Stream<Arguments> testRangeSizeValidationFailure() {\n    return Stream.of(\n        Arguments.of(0, Status.INVALID_ARGUMENT),\n        Arguments.of(1, Status.INVALID_ARGUMENT),\n        Arguments.of(2, Status.INVALID_ARGUMENT),\n        Arguments.of(3, Status.OK),\n        Arguments.of(4, Status.OK),\n        Arguments.of(5, Status.OK),\n        Arguments.of(6, Status.OK),\n        Arguments.of(7, Status.OK),\n        Arguments.of(8, Status.OK),\n        Arguments.of(9, Status.INVALID_ARGUMENT),\n        Arguments.of(1000, Status.INVALID_ARGUMENT)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void testRangeSizeValidationFailure(final int size, final Status expectedStatus) throws Exception {\n    final String stringValue = RandomStringUtils.secure().nextAlphanumeric(size);\n    assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setRangeSizeString(stringValue)\n            .build()\n    )).getCode());\n\n    final ByteString byteValue = ByteString.copyFrom(TestRandomUtil.nextBytes(size));\n    assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setRangeSizeBytes(byteValue)\n            .build()\n    )).getCode());\n\n    final List<String> listValue = IntStream.range(0, size)\n        .mapToObj(i -> RandomStringUtils.secure().nextAlphabetic(10))\n        .toList();\n    assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearRangeSizeList()\n            .addAllRangeSizeList(listValue)\n            .build()\n    )).getCode());\n  }\n\n  @Test\n  public void testNotOptionalWithMaxLimit() throws Exception {\n    stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearWithMaxBytes()\n            .build()\n    );\n    stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearWithMaxString()\n            .build()\n    );\n  }\n\n  @Test\n  public void testNotOptionalWithMinLimit() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearWithMinBytes()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearWithMinString()\n            .build()\n    ));\n  }\n\n  @Test\n  public void testServiceExtensionValueExtraction() throws Exception {\n    final Map<String, Optional<Auth>> authValues = GRPC_SERVER_EXTENSION.getServiceRegistry().getServices()\n        .stream()\n        .map(sd -> Pair.of(\n            sd.getServiceDescriptor().getName(),\n            ValidatorUtils.serviceAuthExtensionValue(sd)\n        ))\n        .collect(Collectors.toMap(Pair::getKey, Pair::getValue));\n    assertEquals(Map.of(\n        \"org.signal.chat.rpc.ValidationTestService\", Optional.empty(),\n        \"org.signal.chat.rpc.AuthService\", Optional.of(Auth.AUTH_ONLY_AUTHENTICATED),\n        \"org.signal.chat.rpc.AnonymousService\", Optional.of(Auth.AUTH_ONLY_ANONYMOUS)\n    ), authValues);\n  }\n\n  @Test\n  public void testNonEmpty() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearNonEmptyList()\n            .build()\n    ));\n    // check not setting a value\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearNonEmptyBytes()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearNonEmptyBytesOptional()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearNonEmptyString()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearNonEmptyStringOptional()\n            .build()\n    ));\n    // now check explicitly setting an empty value\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setNonEmptyBytes(ByteString.EMPTY)\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setNonEmptyBytesOptional(ByteString.EMPTY)\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setNonEmptyString(\"\")\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setNonEmptyStringOptional(\"\")\n            .build()\n    ));\n  }\n\n  @Test\n  public void testEnumSpecified() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearColor()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setColor(Color.COLOR_UNSPECIFIED)\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearColorOptional()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setColorOptional(Color.COLOR_UNSPECIFIED)\n            .build()\n    ));\n  }\n\n  @Test\n  public void testRange() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setI32(1000)\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setUi32(-1)\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearI32Range()\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setI32OptRange(5)\n            .build()\n    ));\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .setI32OptRange(1000)\n            .build()\n    ));\n  }\n\n  @Test\n  public void testPresent() throws Exception {\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearPresentMessage()\n            .build()\n    ));\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(\n        builderWithValidDefaults()\n            .clearOptionalPresentMessage()\n            .build()\n    ));\n  }\n\n  @Test\n  public void testAllFieldsValidationSuccess() throws Exception {\n    stub.validationsEndpoint(builderWithValidDefaults().build());\n  }\n\n  @Test\n  public void testFailedValidationOnNestedMessage() {\n    assertStatusException(Status.INVALID_ARGUMENT, () ->\n        stub.validationsEndpoint(builderWithValidDefaults().setNested(NestedMessage.newBuilder().setI32(101)).build()));\n\n    assertStatusException(Status.INVALID_ARGUMENT, () ->\n        stub.validationsEndpoint(builderWithValidDefaults()\n            .clearRepeatedNested()\n            .addRepeatedNested(NestedMessage.newBuilder().setI32(101)).build()));\n\n    assertStatusException(Status.INVALID_ARGUMENT, () ->\n        stub.validationsEndpoint(builderWithValidDefaults()\n            .clearMapNested()\n            .putMapNested(\"foo\", NestedMessage.newBuilder().setI32(101).build()).build()));\n  }\n\n  @Test\n  public void deeplyNestedValidation() throws Exception {\n    // The default proto nesting limit (what we use in gRPC) is 100. Make sure we can handle twice that amount of nesting\n    int numNestedMessages = 100 * 2;\n    RecursiveMessage.Builder curr = RecursiveMessage.newBuilder().setI32(101);\n    for (int i = 0; i < numNestedMessages; i++) {\n      final RecursiveMessage.Builder pred = RecursiveMessage.newBuilder();\n      pred.setNext(curr);\n      curr = pred;\n    }\n    final RecursiveMessage recursiveMessage = curr.build();\n    assertStatusException(Status.INVALID_ARGUMENT, () ->\n        stub.validationsEndpoint(builderWithValidDefaults().setRecursiveMessage(recursiveMessage).build()));\n  }\n\n  @Test\n  public void oneOfValidation() throws Exception {\n\n    // These should pass even if we don't meet the requirements on the one-of case that we're not setting\n    assertDoesNotThrow(() ->\n        stub.validationsEndpoint(builderWithValidDefaults()\n            .setOneOfNonEmptyBytes(ByteString.copyFrom(new byte[1]))\n            .build()));\n    assertDoesNotThrow(() ->\n        stub.validationsEndpoint(builderWithValidDefaults()\n            .setOneOfMessage(ValidationsRequest.RequirePresentMessage.getDefaultInstance())\n            .build()));\n\n    assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint(ValidationsRequest.newBuilder().setOneOfNonEmptyBytes(ByteString.EMPTY).build()));\n  }\n\n  @Nonnull\n  private static ValidationsRequest.Builder builderWithValidDefaults() {\n    return ValidationsRequest.newBuilder()\n        .setNumber(\"+15551234567\")\n        .setFixedSizeString(\"12345\")\n        .setFixedSizeBytes(ByteString.copyFrom(new byte[5]))\n        .setWithMinBytes(ByteString.copyFrom(new byte[5]))\n        .setWithMaxBytes(ByteString.copyFrom(new byte[5]))\n        .setWithMinString(\"12345\")\n        .setWithMaxString(\"12345\")\n        .setExactlySizeVariants(\"ab\")\n        .setRangeSizeString(\"abc\")\n        .setNonEmptyString(\"abc\")\n        .setNonEmptyStringOptional(\"abc\")\n        .setPresentMessage(ValidationsRequest.RequirePresentMessage.getDefaultInstance())\n        .setOptionalPresentMessage(ValidationsRequest.RequirePresentMessage.getDefaultInstance())\n        .setColor(Color.COLOR_GREEN)\n        .setColorOptional(Color.COLOR_GREEN)\n        .setNonEmptyBytes(ByteString.copyFrom(new byte[5]))\n        .setNonEmptyBytesOptional(ByteString.copyFrom(new byte[5]))\n        .addAllNonEmptyList(List.of(\"a\", \"b\", \"c\", \"d\", \"e\"))\n        .setRangeSizeBytes(ByteString.copyFrom(new byte[3]))\n        .addAllFixedSizeList(List.of(\"a\", \"b\", \"c\", \"d\", \"e\"))\n        .addAllRangeSizeList(List.of(\"a\", \"b\", \"c\", \"d\", \"e\"))\n        .setI32Range(15)\n        .setNested(NestedMessage.getDefaultInstance())\n        .addRepeatedNested(NestedMessage.getDefaultInstance())\n        .putMapNested(\"test\", NestedMessage.getDefaultInstance());\n  }\n\n  private static void assertStatusException(final Status expected, final Executable serviceCall) {\n    final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall);\n    assertEquals(expected.getCode(), exception.getStatus().getCode());\n  }\n\n  private static Status requestStatus(final Runnable runnable) {\n    try {\n      runnable.run();\n      return Status.OK;\n    } catch (final StatusRuntimeException e) {\n      return e.getStatus();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/http/FaultTolerantHttpClientTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.http;\n\nimport static com.github.tomakehurst.wiremock.client.WireMock.aResponse;\nimport static com.github.tomakehurst.wiremock.client.WireMock.get;\nimport static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;\nimport static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.github.tomakehurst.wiremock.junit5.WireMockExtension;\nimport io.github.resilience4j.circuitbreaker.CallNotPermittedException;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.github.resilience4j.retry.Retry;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.net.http.HttpClient;\nimport java.net.http.HttpRequest;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.RetryConfiguration;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\n\nclass FaultTolerantHttpClientTest {\n\n  @RegisterExtension\n  private static final WireMockExtension wireMock = WireMockExtension.newInstance()\n      .options(wireMockConfig().dynamicPort().dynamicHttpsPort())\n      .build();\n\n  private ExecutorService httpExecutor;\n  private ScheduledExecutorService retryExecutor;\n\n  @BeforeEach\n  void setUp() {\n    httpExecutor = Executors.newSingleThreadExecutor();\n    retryExecutor = Executors.newSingleThreadScheduledExecutor();\n  }\n\n  @SuppressWarnings(\"ResultOfMethodCallIgnored\")\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    httpExecutor.shutdown();\n    httpExecutor.awaitTermination(1, TimeUnit.SECONDS);\n\n    retryExecutor.shutdown();\n    retryExecutor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void testSimpleGetAsync() {\n    wireMock.stubFor(get(urlEqualTo(\"/ping\"))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"text/plain\")\n            .withBody(\"Pong!\")));\n\n    final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder(\"testSimpleGet\", httpExecutor)\n        .withRetry(null, retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_2)\n        .build();\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:\" + wireMock.getPort() + \"/ping\"))\n        .GET()\n        .build();\n\n    final HttpResponse<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();\n\n    assertThat(response.statusCode()).isEqualTo(200);\n    assertThat(response.body()).isEqualTo(\"Pong!\");\n\n    wireMock.verify(1, getRequestedFor(urlEqualTo(\"/ping\")));\n  }\n\n  @Test\n  void testSimpleGetSync() throws IOException, InterruptedException {\n    wireMock.stubFor(get(urlEqualTo(\"/ping\"))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"text/plain\")\n            .withBody(\"Pong!\")));\n\n    final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder(\"testSimpleGet\", httpExecutor)\n        .withRetry(null, retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_2)\n        .build();\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:\" + wireMock.getPort() + \"/ping\"))\n        .GET()\n        .build();\n\n    final HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());\n\n    assertThat(response.statusCode()).isEqualTo(200);\n    assertThat(response.body()).isEqualTo(\"Pong!\");\n\n    wireMock.verify(1, getRequestedFor(urlEqualTo(\"/ping\")));\n  }\n\n  @Test\n  void testRetryGetAsync() {\n    wireMock.stubFor(get(urlEqualTo(\"/failure\"))\n        .willReturn(aResponse()\n            .withStatus(500)\n            .withHeader(\"Content-Type\", \"text/plain\")\n            .withBody(\"Pong!\")));\n\n    final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder(\"testRetryGet\", httpExecutor)\n        .withRetry(null, retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_2)\n        .build();\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:\" + wireMock.getPort() + \"/failure\"))\n        .GET()\n        .build();\n\n    final HttpResponse<String> response = client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();\n\n    assertThat(response.statusCode()).isEqualTo(500);\n    assertThat(response.body()).isEqualTo(\"Pong!\");\n\n    wireMock.verify(3, getRequestedFor(urlEqualTo(\"/failure\")));\n  }\n\n  @Test\n  void testRetryGetSync() throws IOException, InterruptedException {\n    wireMock.stubFor(get(urlEqualTo(\"/failure\"))\n        .willReturn(aResponse()\n            .withStatus(500)\n            .withHeader(\"Content-Type\", \"text/plain\")\n            .withBody(\"Pong!\")));\n\n    final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder(\"testRetryGet\", httpExecutor)\n        .withRetry(null, retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_2)\n        .build();\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:\" + wireMock.getPort() + \"/failure\"))\n        .GET()\n        .build();\n\n    final HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());\n\n    assertThat(response.statusCode()).isEqualTo(500);\n    assertThat(response.body()).isEqualTo(\"Pong!\");\n\n    wireMock.verify(3, getRequestedFor(urlEqualTo(\"/failure\")));\n  }\n\n  @Test\n  void testRetryGetAsyncOnException() {\n    final HttpClient mockHttpClient = mock(HttpClient.class);\n\n    final Retry retry = Retry.of(\"test\", new RetryConfiguration().toRetryConfigBuilder()\n        .retryOnException(throwable -> throwable instanceof IOException)\n        .build());\n\n    final FaultTolerantHttpClient client = new FaultTolerantHttpClient(\n        List.of(mockHttpClient),\n        Duration.ofSeconds(1),\n        retryExecutor,\n        retry,\n        CircuitBreaker.ofDefaults(\"test\"));\n\n    when(mockHttpClient.sendAsync(any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new IOException(\"test exception\")));\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:1234/failure\"))\n        .GET()\n        .build();\n\n    assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(IOException.class);\n\n    verify(mockHttpClient, times(3)).sendAsync(any(), any());\n  }\n\n  @Test\n  void testRetryGetSyncOnException() throws IOException, InterruptedException {\n    final HttpClient mockHttpClient = mock(HttpClient.class);\n\n    final Retry retry = Retry.of(\"test\", new RetryConfiguration().toRetryConfigBuilder()\n        .retryOnException(throwable -> throwable instanceof IOException)\n        .build());\n\n    final FaultTolerantHttpClient client = new FaultTolerantHttpClient(\n        List.of(mockHttpClient),\n        Duration.ofSeconds(1),\n        retryExecutor,\n        retry,\n        CircuitBreaker.ofDefaults(\"test\"));\n\n    when(mockHttpClient.send(any(), any()))\n        .thenThrow(IOException.class);\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:1234/failure\"))\n        .GET()\n        .build();\n\n    assertThatThrownBy(() -> client.send(request, HttpResponse.BodyHandlers.ofString()))\n        .isInstanceOf(IOException.class);\n\n    verify(mockHttpClient, times(3)).send(any(), any());\n  }\n\n  @Test\n  void testMultipleClients() throws IOException, InterruptedException {\n    final HttpClient mockHttpClient1 = mock(HttpClient.class);\n    final HttpClient mockHttpClient2 = mock(HttpClient.class);\n\n    final Retry retry = Retry.of(\"test\", new RetryConfiguration().toRetryConfigBuilder()\n        .retryOnException(throwable -> throwable instanceof IOException)\n        .build());\n\n    final FaultTolerantHttpClient client = new FaultTolerantHttpClient(\n        List.of(mockHttpClient1, mockHttpClient2),\n        Duration.ofSeconds(1),\n        retryExecutor,\n        retry,\n        CircuitBreaker.ofDefaults(\"test\"));\n\n    // Just to get a dummy HttpResponse\n    wireMock.stubFor(get(urlEqualTo(\"/ping\"))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"text/plain\")\n            .withBody(\"Pong!\")));\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:\" + wireMock.getPort() + \"/ping\"))\n        .GET()\n        .build();\n\n    try (final HttpClient httpClient = HttpClient.newHttpClient()) {\n      final HttpResponse<Void> response = httpClient.send(request, HttpResponse.BodyHandlers.discarding());\n\n      final AtomicInteger client1Calls = new AtomicInteger(0);\n      final AtomicInteger client2Calls = new AtomicInteger(0);\n      when(mockHttpClient1.sendAsync(any(), any()))\n          .thenAnswer(_ -> {\n            client1Calls.incrementAndGet();\n            return CompletableFuture.completedFuture(response);\n          });\n      when(mockHttpClient2.sendAsync(any(), any()))\n          .thenAnswer(_ -> {\n            client2Calls.incrementAndGet();\n            return CompletableFuture.completedFuture(response);\n          });\n\n      final int numCalls = 100;\n      for (int i = 0; i < numCalls; i++) {\n        client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join();\n      }\n      assertThat(client2Calls.get()).isGreaterThan(0);\n      assertThat(client1Calls.get()).isGreaterThan(0);\n      assertThat(client1Calls.get() + client2Calls.get()).isEqualTo(numCalls);\n    }\n  }\n\n  @Test\n  void testNetworkFailureCircuitBreaker() throws InterruptedException {\n    final CircuitBreakerConfiguration circuitBreakerConfiguration = new CircuitBreakerConfiguration();\n    circuitBreakerConfiguration.setSlidingWindowSize(2);\n    circuitBreakerConfiguration.setSlidingWindowMinimumNumberOfCalls(2);\n    circuitBreakerConfiguration.setPermittedNumberOfCallsInHalfOpenState(1);\n    circuitBreakerConfiguration.setFailureRateThreshold(50);\n    circuitBreakerConfiguration.setWaitDurationInOpenState(Duration.ofSeconds(1));\n\n    final String circuitBreakerConfigurationName = getClass().getSimpleName() + \"#testNetworkFailureCircuitBreaker\";\n\n    ResilienceUtil.getCircuitBreakerRegistry()\n        .addConfiguration(circuitBreakerConfigurationName, circuitBreakerConfiguration.toCircuitBreakerConfig());\n\n    final FaultTolerantHttpClient client = FaultTolerantHttpClient.newBuilder(\"testNetworkFailureCircuitBreaker\",\n            httpExecutor)\n        .withCircuitBreaker(circuitBreakerConfigurationName)\n        .withRetry(null, retryExecutor)\n        .withVersion(HttpClient.Version.HTTP_2)\n        .build();\n\n    final HttpRequest request = HttpRequest.newBuilder()\n        .uri(URI.create(\"http://localhost:\" + 39873 + \"/failure\"))\n        .GET()\n        .build();\n\n    assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(IOException.class);\n\n    assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(IOException.class);\n\n    assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(CallNotPermittedException.class);\n\n    Thread.sleep(1001);\n\n    assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(IOException.class);\n\n    assertThatThrownBy(() -> client.sendAsync(request, HttpResponse.BodyHandlers.ofString()).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(CallNotPermittedException.class);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/identity/AciServiceIdentifierTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport java.nio.ByteBuffer;\nimport java.util.UUID;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass AciServiceIdentifierTest {\n\n  @Test\n  void identityType() {\n    assertEquals(IdentityType.ACI, new AciServiceIdentifier(UUID.randomUUID()).identityType());\n  }\n\n  @Test\n  void toServiceIdentifierString() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertEquals(uuid.toString(), new AciServiceIdentifier(uuid).toServiceIdentifierString());\n  }\n\n  @Test\n  void toCompactByteArray() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertArrayEquals(UUIDUtil.toBytes(uuid), new AciServiceIdentifier(uuid).toCompactByteArray());\n  }\n\n  @Test\n  void toFixedWidthByteArray() {\n    final UUID uuid = UUID.randomUUID();\n\n    final ByteBuffer expectedBytesBuffer = ByteBuffer.allocate(17);\n    expectedBytesBuffer.put((byte) 0x00);\n    expectedBytesBuffer.putLong(uuid.getMostSignificantBits());\n    expectedBytesBuffer.putLong(uuid.getLeastSignificantBits());\n    expectedBytesBuffer.flip();\n\n    assertArrayEquals(expectedBytesBuffer.array(), new AciServiceIdentifier(uuid).toFixedWidthByteArray());\n  }\n\n  @Test\n  void valueOf() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertEquals(uuid, AciServiceIdentifier.valueOf(uuid.toString()).uuid());\n    assertThrows(IllegalArgumentException.class, () -> AciServiceIdentifier.valueOf(\"Not a valid UUID\"));\n    assertThrows(IllegalArgumentException.class, () -> AciServiceIdentifier.valueOf(\"PNI:\" + uuid));\n    assertThrows(IllegalArgumentException.class, () -> AciServiceIdentifier.valueOf(\"ACI:\" + uuid).uuid());\n  }\n\n\n  @Test\n  void fromBytes() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertEquals(uuid, AciServiceIdentifier.fromBytes(UUIDUtil.toBytes(uuid)).uuid());\n\n    final byte[] prefixedBytes = new byte[17];\n    prefixedBytes[0] = 0x00;\n    System.arraycopy(UUIDUtil.toBytes(uuid), 0, prefixedBytes, 1, 16);\n\n    assertEquals(uuid, AciServiceIdentifier.fromBytes(prefixedBytes).uuid());\n\n    prefixedBytes[0] = 0x01;\n\n    assertThrows(IllegalArgumentException.class, () -> AciServiceIdentifier.fromBytes(prefixedBytes));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/identity/PniServiceIdentifierTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport java.nio.ByteBuffer;\nimport java.util.UUID;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass PniServiceIdentifierTest {\n\n  @Test\n  void identityType() {\n    assertEquals(IdentityType.PNI, new PniServiceIdentifier(UUID.randomUUID()).identityType());\n  }\n\n  @Test\n  void toServiceIdentifierString() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertEquals(\"PNI:\" + uuid, new PniServiceIdentifier(uuid).toServiceIdentifierString());\n  }\n\n  @Test\n  void toByteArray() {\n    final UUID uuid = UUID.randomUUID();\n\n    final ByteBuffer expectedBytesBuffer = ByteBuffer.allocate(17);\n    expectedBytesBuffer.put((byte) 0x01);\n    expectedBytesBuffer.putLong(uuid.getMostSignificantBits());\n    expectedBytesBuffer.putLong(uuid.getLeastSignificantBits());\n    expectedBytesBuffer.flip();\n\n    assertArrayEquals(expectedBytesBuffer.array(), new PniServiceIdentifier(uuid).toCompactByteArray());\n    assertArrayEquals(expectedBytesBuffer.array(), new PniServiceIdentifier(uuid).toFixedWidthByteArray());\n  }\n\n  @Test\n  void valueOf() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertEquals(uuid, PniServiceIdentifier.valueOf(\"PNI:\" + uuid).uuid());\n    assertThrows(IllegalArgumentException.class, () -> PniServiceIdentifier.valueOf(uuid.toString()));\n    assertThrows(IllegalArgumentException.class, () -> PniServiceIdentifier.valueOf(\"Not a valid UUID\"));\n    assertThrows(IllegalArgumentException.class, () -> PniServiceIdentifier.valueOf(\"ACI:\" + uuid));\n  }\n\n  @Test\n  void fromBytes() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertThrows(IllegalArgumentException.class, () -> PniServiceIdentifier.fromBytes(UUIDUtil.toBytes(uuid)));\n\n    final byte[] prefixedBytes = new byte[17];\n    prefixedBytes[0] = 0x00;\n    System.arraycopy(UUIDUtil.toBytes(uuid), 0, prefixedBytes, 1, 16);\n\n    assertThrows(IllegalArgumentException.class, () -> PniServiceIdentifier.fromBytes(prefixedBytes));\n\n    prefixedBytes[0] = 0x01;\n\n    assertEquals(uuid, PniServiceIdentifier.fromBytes(prefixedBytes).uuid());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/identity/ServiceIdentifierTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.identity;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass ServiceIdentifierTest {\n  \n  @ParameterizedTest\n  @MethodSource\n  void valueOf(final String identifierString, final IdentityType expectedIdentityType, final UUID expectedUuid) {\n    final ServiceIdentifier serviceIdentifier = ServiceIdentifier.valueOf(identifierString);\n\n    assertEquals(expectedIdentityType, serviceIdentifier.identityType());\n    assertEquals(expectedUuid, serviceIdentifier.uuid());\n  }\n\n  private static Stream<Arguments> valueOf() {\n    final UUID uuid = UUID.randomUUID();\n\n    return Stream.of(\n        Arguments.of(uuid.toString(), IdentityType.ACI, uuid),\n        Arguments.of(\"PNI:\" + uuid, IdentityType.PNI, uuid));\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"Not a valid UUID\", \"BAD:a9edc243-3e93-45d4-95c6-e3a84cd4a254\", \"ACI:a9edc243-3e93-45d4-95c6-e3a84cd4a254\"})\n  void valueOfIllegalArgument(final String identifierString) {\n    assertThrows(IllegalArgumentException.class, () -> ServiceIdentifier.valueOf(identifierString));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void fromBytes(final byte[] bytes, final IdentityType expectedIdentityType, final UUID expectedUuid) {\n    final ServiceIdentifier serviceIdentifier = ServiceIdentifier.fromBytes(bytes);\n\n    assertEquals(expectedIdentityType, serviceIdentifier.identityType());\n    assertEquals(expectedUuid, serviceIdentifier.uuid());\n  }\n\n  private static Stream<Arguments> fromBytes() {\n    final UUID uuid = UUID.randomUUID();\n\n    final byte[] aciPrefixedBytes = new byte[17];\n    aciPrefixedBytes[0] = 0x00;\n    System.arraycopy(UUIDUtil.toBytes(uuid), 0, aciPrefixedBytes, 1, 16);\n\n    final byte[] pniPrefixedBytes = new byte[17];\n    pniPrefixedBytes[0] = 0x01;\n    System.arraycopy(UUIDUtil.toBytes(uuid), 0, pniPrefixedBytes, 1, 16);\n\n    return Stream.of(\n        Arguments.of(UUIDUtil.toBytes(uuid), IdentityType.ACI, uuid),\n        Arguments.of(aciPrefixedBytes, IdentityType.ACI, uuid),\n        Arguments.of(pniPrefixedBytes, IdentityType.PNI, uuid));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void fromBytesIllegalArgument(final byte[] bytes) {\n    assertThrows(IllegalArgumentException.class, () -> ServiceIdentifier.fromBytes(bytes));\n  }\n\n  private static Stream<Arguments> fromBytesIllegalArgument() {\n    final byte[] invalidPrefixBytes = new byte[17];\n    invalidPrefixBytes[0] = (byte) 0xff;\n\n    return Stream.of(\n        Arguments.of(new byte[0]),\n        Arguments.of(new byte[15]),\n        Arguments.of(new byte[18]),\n        Arguments.of(invalidPrefixBytes));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/CardinalityEstimatorTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport java.time.Duration;\n\npublic class CardinalityEstimatorTest {\n\n  @RegisterExtension\n  private static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @Test\n  public void testAdd() throws Exception {\n    final FaultTolerantRedisClusterClient redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster();\n    final CardinalityEstimator estimator = new CardinalityEstimator(redisCluster, \"test\", Duration.ofSeconds(1));\n\n    estimator.add(\"1\");\n\n    long count = redisCluster.withCluster(conn -> conn.sync().pfcount(\"cardinality_estimator::test\"));\n    assertThat(count).isEqualTo(1).isEqualTo(estimator.estimate());\n\n    estimator.add(\"2\");\n    count = redisCluster.withCluster(conn -> conn.sync().pfcount(\"cardinality_estimator::test\"));\n    assertThat(count).isEqualTo(2).isEqualTo(estimator.estimate());\n\n    estimator.add(\"1\");\n    count = redisCluster.withCluster(conn -> conn.sync().pfcount(\"cardinality_estimator::test\"));\n    assertThat(count).isEqualTo(2).isEqualTo(estimator.estimate());\n  }\n\n  @Test\n  @Timeout(5)\n  public void testEventuallyExpires() throws InterruptedException {\n    final FaultTolerantRedisClusterClient redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster();\n    final CardinalityEstimator estimator = new CardinalityEstimator(redisCluster, \"test\", Duration.ofMillis(100));\n    estimator.add(\"1\");\n    long count;\n    do {\n      count = redisCluster.withCluster(conn -> conn.sync().pfcount(\"cardinality_estimator::test\"));\n      Thread.sleep(1);\n    } while (count != 0);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/LeakyBucketRateLimiterTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.atomic.AtomicReference;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass LeakyBucketRateLimiterTest {\n\n  private ClusterLuaScript validateRateLimitScript;\n  private ScheduledExecutorService retryExecutor;\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  @RegisterExtension\n  private static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @BeforeEach\n  void setUp() throws IOException {\n    retryExecutor = Executors.newSingleThreadScheduledExecutor();\n\n    validateRateLimitScript = ClusterLuaScript.fromResource(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(), \"lua/validate_rate_limit.lua\", ScriptOutputType.INTEGER);\n  }\n\n  @AfterEach\n  void tearDown() {\n    retryExecutor.shutdown();\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void validate(final boolean failOpen) {\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, Duration.ofHours(1), failOpen),\n        validateRateLimitScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void validateAsync(final boolean failOpen) {\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, Duration.ofHours(1), failOpen),\n        validateRateLimitScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validateAsync(key).toCompletableFuture().join());\n    final CompletionException completionException =\n        assertThrows(CompletionException.class, () -> rateLimiter.validateAsync(key).toCompletableFuture().join());\n\n    assertInstanceOf(RateLimitExceededException.class, completionException.getCause());\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void validateFailOpen(final boolean failOpen) {\n    final ClusterLuaScript failingScript = mock(ClusterLuaScript.class);\n    when(failingScript.execute(any(), any())).thenThrow(new RuntimeException(\"OH NO\"));\n\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, Duration.ofHours(1), failOpen),\n        failingScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    if (failOpen) {\n      assertDoesNotThrow(() -> rateLimiter.validate(key));\n    } else {\n      assertThrows(RuntimeException.class, () -> rateLimiter.validate(key));\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void validateFailOpenAsync(final boolean failOpen) {\n    final ClusterLuaScript failingScript = mock(ClusterLuaScript.class);\n    when(failingScript.executeAsync(any(), any())).thenReturn(CompletableFuture.failedFuture(new RuntimeException(\"OH NO\")));\n\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, Duration.ofHours(1), failOpen),\n        failingScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    if (failOpen) {\n      assertDoesNotThrow(() -> rateLimiter.validate(key));\n    } else {\n      final CompletionException completionException =\n          assertThrows(CompletionException.class, () -> rateLimiter.validateAsync(key).toCompletableFuture().join());\n\n      assertInstanceOf(RuntimeException.class, completionException.getCause());\n    }\n  }\n\n  @Test\n  void configChange_ReduceRefillRate() {\n    final AtomicReference<Duration> refillRate = new AtomicReference<>(Duration.ofMinutes(5));\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, refillRate.get(), false),\n        validateRateLimitScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n\n    CLOCK.pin(CLOCK.instant().plus(Duration.ofMinutes(1)));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n\n    refillRate.set(Duration.ofMinutes(1));\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n  }\n\n  @Test\n  void configChange_IncreaseRefillRate() {\n    final AtomicReference<Duration> refillRate = new AtomicReference<>(Duration.ofMinutes(5));\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, refillRate.get(), false),\n        validateRateLimitScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n\n    CLOCK.pin(CLOCK.instant().plus(Duration.ofMinutes(5)));\n    assertTrue(rateLimiter.hasAvailablePermits(key, 1));\n\n    refillRate.set(Duration.ofMinutes(10));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n\n    CLOCK.pin(CLOCK.instant().plus(Duration.ofMinutes(5)));\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key));\n  }\n\n  @Test\n  void configChange_ReduceBucketSize() {\n    final AtomicInteger bucketSize = new AtomicInteger(5);\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(bucketSize.get(), Duration.ofMinutes(1), false),\n        validateRateLimitScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertTrue(rateLimiter.hasAvailablePermits(key, 4));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key, 5));\n\n    bucketSize.set(1);\n    // Changing the bucket size doesn't spend the tokens remaining in existing buckets, but does\n    // effectively make those buckets overflow if it got smaller. There were 4 tokens available\n    // before, so changing the bucket size to 1 effectively means there is 1 token left, not 0\n    assertTrue(rateLimiter.hasAvailablePermits(key, 1));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key, 2));\n  }\n\n  @Test\n  void configChange_IncreaseBucketSize() {\n    final AtomicInteger bucketSize = new AtomicInteger(5);\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(bucketSize.get(), Duration.ofMinutes(1), false),\n        validateRateLimitScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n    assertTrue(rateLimiter.hasAvailablePermits(key, 4));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key, 5));\n\n    bucketSize.set(10);\n    // Increasing the bucket size doesn't retroactively refill buckets in redis, so we have to wait\n    // until the bucket fills up\n    CLOCK.pin(CLOCK.instant().plus(Duration.ofMinutes(10)));\n    assertTrue(rateLimiter.hasAvailablePermits(key, 10));\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(key, 11));\n  }\n\n  @Test\n  void configChange_enableFailOpen() {\n    final ClusterLuaScript failingScript = mock(ClusterLuaScript.class);\n    when(failingScript.execute(any(), any())).thenThrow(new RuntimeException(\"OH NO\"));\n\n    final AtomicBoolean failOpen = new AtomicBoolean(false);\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, Duration.ofMinutes(1), failOpen.get()),\n        failingScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertThrows(RuntimeException.class, () -> rateLimiter.validate(key));\n\n    failOpen.set(true);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n  }\n\n  @Test\n  void configChange_disableFailOpen() {\n    final ClusterLuaScript failingScript = mock(ClusterLuaScript.class);\n    when(failingScript.execute(any(), any())).thenThrow(new RuntimeException(\"OH NO\"));\n\n    final AtomicBoolean failOpen = new AtomicBoolean(true);\n    final LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(\n        \"test\",\n        () -> new RateLimiterConfig(1, Duration.ofMinutes(1), failOpen.get()),\n        failingScript,\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        retryExecutor,\n        CLOCK);\n\n    final String key = RandomStringUtils.insecure().nextAlphanumeric(16);\n\n    assertDoesNotThrow(() -> rateLimiter.validate(key));\n\n    failOpen.set(false);\n\n    assertThrows(RuntimeException.class, () -> rateLimiter.validate(key));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/MessageDeliveryLoopMonitorTest.java",
    "content": "package org.whispersystems.textsecuregcm.limits;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\nclass MessageDeliveryLoopMonitorTest {\n\n  private RedisMessageDeliveryLoopMonitor messageDeliveryLoopMonitor;\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @BeforeEach\n  void setUp() {\n    messageDeliveryLoopMonitor = new RedisMessageDeliveryLoopMonitor(REDIS_CLUSTER_EXTENSION.getRedisCluster());\n  }\n\n  @Test\n  void incrementDeliveryAttemptCount() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    assertEquals(1, messageDeliveryLoopMonitor.incrementDeliveryAttemptCount(accountIdentifier, deviceId, UUID.randomUUID()).join());\n    assertEquals(1, messageDeliveryLoopMonitor.incrementDeliveryAttemptCount(accountIdentifier, deviceId, UUID.randomUUID()).join());\n\n    final UUID repeatedDeliveryGuid = UUID.randomUUID();\n\n    for (int i = 1; i < 10; i++) {\n      assertEquals(i, messageDeliveryLoopMonitor.incrementDeliveryAttemptCount(accountIdentifier, deviceId, repeatedDeliveryGuid).join());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport java.io.IOException;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.captcha.Action;\nimport org.whispersystems.textsecuregcm.captcha.AssessmentResult;\nimport org.whispersystems.textsecuregcm.captcha.CaptchaChecker;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.spam.ChallengeType;\nimport org.whispersystems.textsecuregcm.spam.RateLimitChallengeListener;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\nclass RateLimitChallengeManagerTest {\n\n  private static final float DEFAULT_SCORE_THRESHOLD = 0.1f;\n\n  private PushChallengeManager pushChallengeManager;\n  private CaptchaChecker captchaChecker;\n  private RateLimiters rateLimiters;\n  private RateLimitChallengeListener rateLimitChallengeListener;\n\n  private RateLimitChallengeManager rateLimitChallengeManager;\n\n  @BeforeEach\n  void setUp() {\n    pushChallengeManager = mock(PushChallengeManager.class);\n    captchaChecker = mock(CaptchaChecker.class);\n    rateLimiters = mock(RateLimiters.class);\n    rateLimitChallengeListener = mock(RateLimitChallengeListener.class);\n\n    rateLimitChallengeManager = new RateLimitChallengeManager(\n        pushChallengeManager,\n        captchaChecker,\n        rateLimiters,\n        List.of(rateLimitChallengeListener));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void answerPushChallenge(final boolean successfulChallenge) throws RateLimitExceededException {\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(UUID.randomUUID());\n\n    when(pushChallengeManager.answerChallenge(eq(account), any())).thenReturn(successfulChallenge);\n\n    when(rateLimiters.getPushChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));\n    when(rateLimiters.getPushChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));\n    when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class));\n\n    rateLimitChallengeManager.answerPushChallenge(account, \"challenge\");\n\n    if (successfulChallenge) {\n      verify(rateLimitChallengeListener).handleRateLimitChallengeAnswered(account, ChallengeType.PUSH);\n    } else {\n      verifyNoInteractions(rateLimitChallengeListener);\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void answerCaptchaChallenge(Optional<Float> scoreThreshold, float actualScore, boolean expectSuccess)\n    throws RateLimitExceededException, IOException {\n    final Account account = mock(Account.class);\n    when(account.getNumber()).thenReturn(\"+18005551234\");\n    when(account.getUuid()).thenReturn(UUID.randomUUID());\n\n    when(captchaChecker.verify(any(), eq(Action.CHALLENGE), any(), any(), any()))\n        .thenReturn(AssessmentResult.fromScore(actualScore, DEFAULT_SCORE_THRESHOLD));\n\n    when(rateLimiters.getCaptchaChallengeAttemptLimiter()).thenReturn(mock(RateLimiter.class));\n    when(rateLimiters.getCaptchaChallengeSuccessLimiter()).thenReturn(mock(RateLimiter.class));\n    when(rateLimiters.getRateLimitResetLimiter()).thenReturn(mock(RateLimiter.class));\n\n    rateLimitChallengeManager.answerCaptchaChallenge(account, \"captcha\", \"10.0.0.1\", \"Test User-Agent\", scoreThreshold);\n\n    if (expectSuccess) {\n      verify(rateLimitChallengeListener).handleRateLimitChallengeAnswered(account, ChallengeType.CAPTCHA);\n    } else {\n      verifyNoInteractions(rateLimitChallengeListener);\n    }\n  }\n\n  private static Stream<Arguments> answerCaptchaChallenge() {\n    return Stream.of(\n        Arguments.of(Optional.empty(), 0.5f, true),\n        Arguments.of(Optional.empty(), 0.1f, true),\n        Arguments.of(Optional.empty(), 0.0f, false),\n        Arguments.of(Optional.of(0.1f), 0.5f, true),\n        Arguments.of(Optional.of(0.1f), 0.1f, true),\n        Arguments.of(Optional.of(0.1f), 0.0f, false),\n        Arguments.of(Optional.of(0.3f), 0.5f, true),\n        Arguments.of(Optional.of(0.3f), 0.1f, false),\n        Arguments.of(Optional.of(0.3f), 0.0f, false));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitChallengeOptionManagerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\nclass RateLimitChallengeOptionManagerTest {\n\n  private RateLimiters rateLimiters;\n\n  private RateLimitChallengeOptionManager rateLimitChallengeOptionManager;\n\n  @BeforeEach\n  void setUp() {\n    rateLimiters = mock(RateLimiters.class);\n    rateLimitChallengeOptionManager = new RateLimitChallengeOptionManager(rateLimiters);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getChallengeOptions(final boolean captchaAttemptPermitted,\n      final boolean captchaSuccessPermitted,\n      final boolean pushAttemptPermitted,\n      final boolean pushSuccessPermitted,\n      final boolean expectCaptcha,\n      final boolean expectPushChallenge) {\n\n    final RateLimiter captchaChallengeAttemptLimiter = mock(RateLimiter.class);\n    final RateLimiter captchaChallengeSuccessLimiter = mock(RateLimiter.class);\n    final RateLimiter pushChallengeAttemptLimiter = mock(RateLimiter.class);\n    final RateLimiter pushChallengeSuccessLimiter = mock(RateLimiter.class);\n\n    when(rateLimiters.getCaptchaChallengeAttemptLimiter()).thenReturn(captchaChallengeAttemptLimiter);\n    when(rateLimiters.getCaptchaChallengeSuccessLimiter()).thenReturn(captchaChallengeSuccessLimiter);\n    when(rateLimiters.getPushChallengeAttemptLimiter()).thenReturn(pushChallengeAttemptLimiter);\n    when(rateLimiters.getPushChallengeSuccessLimiter()).thenReturn(pushChallengeSuccessLimiter);\n\n    when(captchaChallengeAttemptLimiter.hasAvailablePermits(any(UUID.class), anyLong())).thenReturn(\n        captchaAttemptPermitted);\n    when(captchaChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyLong())).thenReturn(\n        captchaSuccessPermitted);\n    when(pushChallengeAttemptLimiter.hasAvailablePermits(any(UUID.class), anyLong())).thenReturn(pushAttemptPermitted);\n    when(pushChallengeSuccessLimiter.hasAvailablePermits(any(UUID.class), anyLong())).thenReturn(pushSuccessPermitted);\n\n    final int expectedLength = (expectCaptcha ? 1 : 0) + (expectPushChallenge ? 1 : 0);\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(UUID.randomUUID());\n\n    final List<RateLimitChallengeOption> options = rateLimitChallengeOptionManager.getChallengeOptions(account);\n    assertEquals(expectedLength, options.size());\n\n    if (expectCaptcha) {\n      assertTrue(options.contains(RateLimitChallengeOption.CAPTCHA));\n    }\n\n    if (expectPushChallenge) {\n      assertTrue(options.contains(RateLimitChallengeOption.PUSH_CHALLENGE));\n    }\n  }\n\n  private static Stream<Arguments> getChallengeOptions() {\n    return Stream.of(\n        Arguments.of(false, false, false, false, false, false),\n        Arguments.of(false, false, false, true,  false, false),\n        Arguments.of(false, false, true,  false, false, false),\n        Arguments.of(false, false, true,  true,  false, true),\n        Arguments.of(false, true,  false, false, false, false),\n        Arguments.of(false, true,  false, true,  false, false),\n        Arguments.of(false, true,  true,  false, false, false),\n        Arguments.of(false, true,  true,  true,  false, true),\n        Arguments.of(true,  false, false, false, false, false),\n        Arguments.of(true,  false, false, true,  false, false),\n        Arguments.of(true,  false, true,  false, false, false),\n        Arguments.of(true,  false, true,  true,  false, true),\n        Arguments.of(true,  true,  false, false, true,  false),\n        Arguments.of(true,  true,  false, true,  true,  false),\n        Arguments.of(true,  true,  true,  false, true,  false),\n        Arguments.of(true,  true,  true,  true,  true,  true)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitedByIpTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doNothing;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.core.Response;\nimport java.time.Duration;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestRemoteAddressFilterProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\npublic class RateLimitedByIpTest {\n\n  private static final String IP = \"127.0.0.1\";\n\n  private static final Duration RETRY_AFTER = Duration.ofSeconds(100);\n\n\n  @Path(\"/test\")\n  public static class Controller {\n    @GET\n    @Path(\"/strict\")\n    @RateLimitedByIp(RateLimiters.For.BACKUP_AUTH_CHECK)\n    public Response strict() {\n      return Response.ok().build();\n    }\n\n    @GET\n    @Path(\"/loose\")\n    @RateLimitedByIp(value = RateLimiters.For.BACKUP_AUTH_CHECK, failOnUnresolvedIp = false)\n    public Response loose() {\n      return Response.ok().build();\n    }\n  }\n\n  private static final RateLimiter RATE_LIMITER = mock(RateLimiter.class);\n\n  private static final RateLimiters RATE_LIMITERS = MockUtils.buildMock(RateLimiters.class, rl ->\n      when(rl.forDescriptor(eq(RateLimiters.For.BACKUP_AUTH_CHECK))).thenReturn(RATE_LIMITER));\n\n  private static final ResourceExtension RESOURCES = ResourceExtension.builder()\n      .setMapper(SystemMapper.jsonMapper())\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new Controller())\n      .addProvider(new RateLimitByIpFilter(RATE_LIMITERS))\n      .addProvider(new TestRemoteAddressFilterProvider(IP))\n      .build();\n\n  @Test\n  public void testRateLimits() throws Exception {\n    doNothing().when(RATE_LIMITER).validate(eq(IP));\n    validateSuccess(\"/test/strict\");\n    doThrow(new RateLimitExceededException(RETRY_AFTER)).when(RATE_LIMITER).validate(eq(IP));\n    validateFailure(\"/test/strict\", RETRY_AFTER);\n    doNothing().when(RATE_LIMITER).validate(eq(IP));\n    validateSuccess(\"/test/strict\");\n    doThrow(new RateLimitExceededException(RETRY_AFTER)).when(RATE_LIMITER).validate(eq(IP));\n    validateFailure(\"/test/strict\", RETRY_AFTER);\n  }\n\n  private static void validateSuccess(final String path) {\n    final Response response = RESOURCES.getJerseyTest()\n        .target(path)\n        .request()\n        .get();\n\n    assertEquals(200, response.getStatus());\n  }\n\n  private static void validateFailure(final String path, final Duration expectedRetryAfter) {\n    final Response response = RESOURCES.getJerseyTest()\n        .target(path)\n        .request()\n        .get();\n\n    assertEquals(429, response.getStatus());\n    assertEquals(\"\" + expectedRetryAfter.getSeconds(), response.getHeaderString(HttpHeaders.RETRY_AFTER));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimiterConfigTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport org.junit.jupiter.api.Test;\n\nimport java.time.Duration;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass RateLimiterConfigTest {\n\n  @Test\n  void leakRatePerMillis() {\n    assertEquals(0.001, new RateLimiterConfig(1, Duration.ofSeconds(1), false).leakRatePerMillis());\n    assertEquals(1e6, new RateLimiterConfig(1, Duration.ofNanos(1), false).leakRatePerMillis());\n  }\n\n  @Test\n  void isRegenerationRatePositive() {\n    assertTrue(new RateLimiterConfig(1, Duration.ofSeconds(1), false).isPositiveRegenerationRate());\n    assertTrue(new RateLimiterConfig(1, Duration.ofNanos(1), false).isPositiveRegenerationRate());\n    assertFalse(new RateLimiterConfig(1, Duration.ZERO, false).isPositiveRegenerationRate());\n    assertFalse(new RateLimiterConfig(1, Duration.ofSeconds(-1), false).isPositiveRegenerationRate());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersLuaScriptTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.ScriptOutputType;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.redis.RedisLuaScriptSandbox;\nimport org.whispersystems.textsecuregcm.util.redis.SimpleCacheCommandsHandler;\n\npublic class RateLimitersLuaScriptTest {\n\n  @RegisterExtension\n  private static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private final DynamicConfiguration configuration = mock(DynamicConfiguration.class);\n\n  private final MutableClock clock = MockUtils.mutableClock(0);\n\n  private final RedisLuaScriptSandbox sandbox = RedisLuaScriptSandbox.fromResource(\n      \"lua/validate_rate_limit.lua\",\n      ScriptOutputType.INTEGER);\n\n  private final SimpleCacheCommandsHandler redisCommandsHandler = new SimpleCacheCommandsHandler(clock);\n\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfig =\n      MockUtils.buildMock(DynamicConfigurationManager.class, cfg -> when(cfg.getConfiguration()).thenReturn(configuration));\n\n  @Test\n  public void testWithEmbeddedRedis() throws Exception {\n    final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION;\n    final Map<String, RateLimiterConfig> limiterConfig = Map.of(descriptor.id(), new RateLimiterConfig(60, Duration.ofSeconds(1), false));\n    when(configuration.getLimits()).thenReturn(limiterConfig);\n\n    final FaultTolerantRedisClusterClient redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster();\n    final RateLimiters limiters = new RateLimiters(\n        dynamicConfig,\n        RateLimiters.defaultScript(redisCluster),\n        redisCluster,\n        mock(ScheduledExecutorService.class),\n        Clock.systemUTC());\n\n    final RateLimiter rateLimiter = limiters.forDescriptor(descriptor);\n    rateLimiter.validate(\"test\", 25);\n    rateLimiter.validate(\"test\", 25);\n    assertThrows(RateLimitExceededException.class, () -> rateLimiter.validate(\"test\", 25));\n  }\n\n  @Test\n  public void testTtl() throws Exception {\n    final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION;\n    final Map<String, RateLimiterConfig> limiterConfig = Map.of(descriptor.id(), new RateLimiterConfig(1000, Duration.ofSeconds(1), false));\n    when(configuration.getLimits()).thenReturn(limiterConfig);\n\n    final FaultTolerantRedisClusterClient redisCluster = REDIS_CLUSTER_EXTENSION.getRedisCluster();\n    final RateLimiters limiters = new RateLimiters(\n        dynamicConfig,\n        RateLimiters.defaultScript(redisCluster),\n        redisCluster,\n        mock(ScheduledExecutorService.class),\n        Clock.systemUTC());\n\n    final RateLimiter rateLimiter = limiters.forDescriptor(descriptor);\n    rateLimiter.validate(\"test\", 200);\n    // after using 200 tokens, we expect 200 seconds to refill, so the TTL should be under 200000\n    final long ttl = redisCluster.withCluster(c -> c.sync().ttl(\"test\"));\n    assertTrue(ttl <= 200000);\n  }\n\n  @Test\n  public void testLuaUpdatesTokenBucket() throws Exception {\n    final String key = \"key1\";\n    clock.setTimeMillis(0);\n    long result = (long) sandbox.execute(\n        List.of(key),\n        scriptArgs(1000, 1, 200, true),\n        redisCommandsHandler\n    );\n    assertEquals(0L, result);\n    assertEquals(800L, decodeBucket(key).orElseThrow().tokensRemaining);\n\n    // 50 tokens replenished, acquiring 100 more, should end up with 750 available\n    clock.setTimeMillis(50);\n    result = (long) sandbox.execute(\n        List.of(key),\n        scriptArgs(1000, 1, 100, true),\n        redisCommandsHandler\n    );\n    assertEquals(0L, result);\n    assertEquals(750L, decodeBucket(key).orElseThrow().tokensRemaining);\n\n    // now checking without an update, should not affect the count\n    result = (long) sandbox.execute(\n        List.of(key),\n        scriptArgs(1000, 1, 100, false),\n        redisCommandsHandler\n    );\n    assertEquals(0L, result);\n    assertEquals(750L, decodeBucket(key).orElseThrow().tokensRemaining);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  public void testFailOpen(final boolean failOpen) {\n    final RateLimiters.For descriptor = RateLimiters.For.REGISTRATION;\n    final FaultTolerantRedisClusterClient redisCluster = mock(FaultTolerantRedisClusterClient.class);\n\n    final Map<String, RateLimiterConfig> limiterConfig = Map.of(descriptor.id(), new RateLimiterConfig(1, Duration.ofSeconds(1), failOpen));\n    when(configuration.getLimits()).thenReturn(limiterConfig);\n\n    final RateLimiters limiters = new RateLimiters(\n        dynamicConfig,\n        RateLimiters.defaultScript(redisCluster),\n        redisCluster,\n        mock(ScheduledExecutorService.class),\n        Clock.systemUTC());\n    when(redisCluster.withCluster(any())).thenThrow(new RedisException(\"fail\"));\n    final RateLimiter rateLimiter = limiters.forDescriptor(descriptor);\n\n    if (failOpen) {\n      assertDoesNotThrow(() -> rateLimiter.validate(\"test\", 200));\n    } else {\n      assertThrows(RedisException.class, () -> rateLimiter.validate(\"test\", 200));\n    }\n  }\n\n  private String serializeToOldBucketValueFormat(\n      final long bucketSize,\n      final long leakRatePerMillis,\n      final long spaceRemaining,\n      final long lastUpdateTimeMillis) {\n    try {\n      return SystemMapper.jsonMapper().writeValueAsString(Map.of(\n          \"bucketSize\", bucketSize,\n          \"leakRatePerMillis\", leakRatePerMillis,\n          \"spaceRemaining\", spaceRemaining,\n          \"lastUpdateTimeMillis\", lastUpdateTimeMillis\n      ));\n    } catch (JsonProcessingException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  private Optional<TokenBucket> decodeBucket(final String key) {\n    final Object[] fields = redisCommandsHandler.hmget(key, List.of(\"s\", \"t\"));\n    return fields[0] == null\n        ? Optional.empty()\n        : Optional.of(new TokenBucket(\n            Double.valueOf(fields[0].toString()).longValue(), Double.valueOf(fields[1].toString()).longValue()));\n  }\n\n  private List<String> scriptArgs(\n      final long bucketSize,\n      final long ratePerMillis,\n      final long requestedAmount,\n      final boolean useTokens) {\n    return List.of(\n        String.valueOf(bucketSize),\n        String.valueOf(ratePerMillis),\n        String.valueOf(clock.millis()),\n        String.valueOf(requestedAmount),\n        String.valueOf(useTokens)\n    );\n  }\n\n  private record TokenBucket(long tokensRemaining, long lastUpdateTimeMillis) {\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/limits/RateLimitersTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.limits;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\n\n@SuppressWarnings(\"unchecked\")\npublic class RateLimitersTest {\n\n  private final DynamicConfiguration configuration = mock(DynamicConfiguration.class);\n\n  private final DynamicConfigurationManager<DynamicConfiguration> dynamicConfig =\n      MockUtils.buildMock(DynamicConfigurationManager.class, cfg -> when(cfg.getConfiguration()).thenReturn(configuration));\n\n  private final ClusterLuaScript validateScript = mock(ClusterLuaScript.class);\n\n  private final FaultTolerantRedisClusterClient redisCluster = mock(FaultTolerantRedisClusterClient.class);\n\n  private final MutableClock clock = MockUtils.mutableClock(0);\n\n  @Test\n  public void testValidateDuplicates() {\n    final TestDescriptor td1 = new TestDescriptor(\"id1\");\n    final TestDescriptor td2 = new TestDescriptor(\"id2\");\n    final TestDescriptor td3 = new TestDescriptor(\"id3\");\n    final TestDescriptor tdDup = new TestDescriptor(\"id1\");\n\n    assertThrows(IllegalStateException.class, () -> new BaseRateLimiters<>(\n        new TestDescriptor[] { td1, td2, td3, tdDup },\n        dynamicConfig,\n        validateScript,\n        redisCluster,\n        mock(ScheduledExecutorService.class),\n        clock) {});\n\n    new BaseRateLimiters<>(\n        new TestDescriptor[] { td1, td2, td3 },\n        dynamicConfig,\n        validateScript,\n        redisCluster,\n        mock(ScheduledExecutorService.class),\n        clock) {};\n  }\n\n  @Test\n  void testUnchangingConfiguration() {\n    final RateLimiters rateLimiters = new RateLimiters(dynamicConfig, validateScript, redisCluster, mock(ScheduledExecutorService.class), clock);\n    final RateLimiter limiter = rateLimiters.getRateLimitResetLimiter();\n    final RateLimiterConfig expected = RateLimiters.For.RATE_LIMIT_RESET.defaultConfig();\n    assertEquals(expected, limiter.config());\n  }\n\n  @Test\n  void testChangingConfiguration() {\n    final RateLimiterConfig initialRateLimiterConfig = new RateLimiterConfig(4, Duration.ofMinutes(1), false);\n    final RateLimiterConfig updatedRateLimiterCongig = new RateLimiterConfig(17, Duration.ofSeconds(3), false);\n    final RateLimiterConfig baseConfig = new RateLimiterConfig(1, Duration.ofMinutes(1), false);\n\n    final Map<String, RateLimiterConfig> limitsConfigMap = new HashMap<>();\n\n    limitsConfigMap.put(RateLimiters.For.CAPTCHA_CHALLENGE_ATTEMPT.id(), baseConfig);\n    limitsConfigMap.put(RateLimiters.For.CAPTCHA_CHALLENGE_SUCCESS.id(), baseConfig);\n\n    when(configuration.getLimits()).thenReturn(limitsConfigMap);\n\n    final RateLimiters rateLimiters = new RateLimiters(dynamicConfig, validateScript, redisCluster, mock(ScheduledExecutorService.class), clock);\n    final RateLimiter limiter = rateLimiters.getRateLimitResetLimiter();\n\n    limitsConfigMap.put(RateLimiters.For.RATE_LIMIT_RESET.id(), initialRateLimiterConfig);\n    assertEquals(initialRateLimiterConfig, limiter.config());\n\n    assertEquals(baseConfig, rateLimiters.getCaptchaChallengeAttemptLimiter().config());\n    assertEquals(baseConfig, rateLimiters.getCaptchaChallengeSuccessLimiter().config());\n\n    limitsConfigMap.put(RateLimiters.For.RATE_LIMIT_RESET.id(), updatedRateLimiterCongig);\n    assertEquals(updatedRateLimiterCongig, limiter.config());\n\n    assertEquals(baseConfig, rateLimiters.getCaptchaChallengeAttemptLimiter().config());\n    assertEquals(baseConfig, rateLimiters.getCaptchaChallengeSuccessLimiter().config());\n  }\n\n  @Test\n  public void testRateLimiterHasItsPrioritiesStraight() throws Exception {\n    final RateLimiters.For descriptor = RateLimiters.For.CAPTCHA_CHALLENGE_ATTEMPT;\n    final RateLimiterConfig configForDynamic = new RateLimiterConfig(1, Duration.ofMinutes(1), false);\n    final RateLimiterConfig defaultConfig = descriptor.defaultConfig();\n\n    final Map<String, RateLimiterConfig> mapForDynamic = new HashMap<>();\n\n    when(configuration.getLimits()).thenReturn(mapForDynamic);\n\n    final RateLimiters rateLimiters = new RateLimiters(dynamicConfig, validateScript, redisCluster, mock(ScheduledExecutorService.class), clock);\n    final RateLimiter limiter = rateLimiters.forDescriptor(descriptor);\n\n    // test only default is present\n    mapForDynamic.remove(descriptor.id());\n    assertEquals(defaultConfig, limiter.config());\n\n    // test dynamic config is present\n    mapForDynamic.put(descriptor.id(), configForDynamic);\n    assertEquals(configForDynamic, limiter.config());\n  }\n\n  private record TestDescriptor(String id) implements RateLimiterDescriptor {\n\n    @Override\n    public RateLimiterConfig defaultConfig() {\n      return new RateLimiterConfig(1, Duration.ofMinutes(1), false);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/mappers/GrpcStatusRuntimeExceptionMapperTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport io.dropwizard.jersey.errors.ErrorMessage;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport io.grpc.Status;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.Response;\nimport java.util.stream.Stream;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass GrpcStatusRuntimeExceptionMapperTest {\n\n  private static final GrpcStatusRuntimeExceptionMapper exceptionMapper = new GrpcStatusRuntimeExceptionMapper();\n  private static final TestController testController = new TestController();\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(new CompletionExceptionMapper())\n      .addProvider(exceptionMapper)\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(testController)\n      .build();\n\n  @BeforeEach\n  public void setUp() {\n    testController.exception = null;\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"json\", \"text\"})\n  public void responseBody(final String path) throws JsonProcessingException {\n    testController.exception = Status.INVALID_ARGUMENT.withDescription(\"oofta\").asRuntimeException();\n    final Response response = resources.getJerseyTest().target(\"/v1/test/\" + path).request().get();\n    assertThat(response.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());\n    final ErrorMessage body = SystemMapper.jsonMapper().readValue(\n        response.readEntity(String.class),\n        ErrorMessage.class);\n\n    assertThat(body.getMessage()).isEqualTo(testController.exception.getMessage());\n    assertThat(body.getCode()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());\n  }\n\n  public static Stream<Arguments> errorMapping() {\n    return Stream.of(\n        Arguments.of(Status.INVALID_ARGUMENT, 400),\n        Arguments.of(Status.NOT_FOUND, 404),\n        Arguments.of(Status.UNAVAILABLE, 500));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  public void errorMapping(final Status status, final int expectedHttpCode) {\n    testController.exception = status.asRuntimeException();\n    final Response response = resources.getJerseyTest().target(\"/v1/test/json\").request().get();\n    assertThat(response.getStatus()).isEqualTo(expectedHttpCode);\n  }\n\n  @Path(\"/v1/test\")\n  public static class TestController {\n\n    volatile RuntimeException exception = null;\n\n    @GET\n    @Path(\"/text\")\n    public Response plaintext() {\n      if (exception != null) {\n        throw exception;\n      }\n      return Response.ok().build();\n    }\n\n    @GET\n    @Path(\"/json\")\n    @Produces(MediaType.APPLICATION_JSON)\n    public Response json() {\n      if (exception != null) {\n        throw exception;\n      }\n      return Response.ok().build();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/mappers/IOExceptionMapperTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.mappers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.util.concurrent.TimeoutException;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nclass IOExceptionMapperTest {\n\n  @ParameterizedTest\n  @MethodSource\n  void testExceptionParsing(final IOException exception, final int expectedStatus) {\n\n    try (Response response = new IOExceptionMapper().toResponse(exception)) {\n      assertEquals(expectedStatus, response.getStatus());\n    }\n  }\n\n  static Stream<Arguments> testExceptionParsing() {\n    return Stream.of(\n        Arguments.of(new IOException(), 503),\n        Arguments.of(new IOException(new TimeoutException(\"A timeout\")), 503),\n        Arguments.of(new IOException(new TimeoutException()), 503),\n        Arguments.of(new IOException(new TimeoutException(\"Idle timeout 30000 ms elapsed\")), 408),\n        Arguments.of(new IOException(new TimeoutException(\"Idle timeout expired\")), 408),\n        Arguments.of(new IOException(new RuntimeException(new TimeoutException(\"Idle timeout expired\"))), 503),\n        Arguments.of(new IOException(new TimeoutException(\"Idle timeout of another kind expired\")), 503)\n    );\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/CallQualitySurveyManagerTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.api.core.ApiFuture;\nimport com.google.cloud.pubsub.v1.PublisherInterface;\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport com.google.pubsub.v1.PubsubMessage;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.ThreadLocalRandom;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.function.Executable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.platform.commons.util.StringUtils;\nimport org.mockito.ArgumentCaptor;\nimport org.signal.calling.survey.CallQualitySurveyResponsePubSubMessage;\nimport org.signal.chat.calling.quality.SubmitCallQualitySurveyRequest;\nimport org.whispersystems.textsecuregcm.asn.AsnInfo;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass CallQualitySurveyManagerTest {\n\n  private AsnInfoProvider asnInfoProvider;\n  private PublisherInterface pubsubPublisher;\n\n  private CallQualitySurveyManager callQualitySurveyManager;\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  private static final String USER_AGENT = \"Signal-iOS/7.78.0.1041 iOS/18.3.2 libsignal/0.80.3\";\n  private static final String REMOTE_ADDRESS = \"127.0.0.1\";\n\n  @BeforeEach\n  void setUp() {\n    asnInfoProvider = mock(AsnInfoProvider.class);\n    pubsubPublisher = mock(PublisherInterface.class);\n\n    callQualitySurveyManager = new CallQualitySurveyManager(() -> asnInfoProvider, pubsubPublisher, CLOCK, Runnable::run);\n  }\n\n  @Test\n  void submitCallQualitySurvey() throws InvalidProtocolBufferException {\n    final long asn = 1234;\n    final String asnRegion = \"US\";\n\n    final byte[] telemetryBytes = TestRandomUtil.nextBytes(32);\n\n    final float connectionRttMedian = ThreadLocalRandom.current().nextFloat();\n    final float audioRttMedian = ThreadLocalRandom.current().nextFloat();\n    final float videoRttMedian = ThreadLocalRandom.current().nextFloat();\n    final float audioRecvJitterMedian = ThreadLocalRandom.current().nextFloat();\n    final float videoRecvJitterMedian = ThreadLocalRandom.current().nextFloat();\n    final float audioSendJitterMedian = ThreadLocalRandom.current().nextFloat();\n    final float videoSendJitterMedian = ThreadLocalRandom.current().nextFloat();\n    final float audioRecvPacketLossFraction = ThreadLocalRandom.current().nextFloat();\n    final float videoRecvPacketLossFraction = ThreadLocalRandom.current().nextFloat();\n    final float audioSendPacketLossFraction = ThreadLocalRandom.current().nextFloat();\n    final float videoSendPacketLossFraction = ThreadLocalRandom.current().nextFloat();\n\n    when(asnInfoProvider.lookup(REMOTE_ADDRESS)).thenReturn(Optional.of(new AsnInfo(asn, asnRegion)));\n\n    final SubmitCallQualitySurveyRequest request = SubmitCallQualitySurveyRequest.newBuilder()\n        .setUserSatisfied(false)\n        .addCallQualityIssues(\"too_hot\")\n        .addCallQualityIssues(\"too_cold\")\n        .setAdditionalIssuesDescription(\"But this one is just right\")\n        .setDebugLogUrl(\"https://example.com/\")\n        .setStartTimestamp(123456789)\n        .setEndTimestamp(987654321)\n        .setCallType(\"direct_video\")\n        .setSuccess(true)\n        .setCallEndReason(\"caller_hang_up\")\n        .setConnectionRttMedian(connectionRttMedian)\n        .setAudioRttMedian(audioRttMedian)\n        .setVideoRttMedian(videoRttMedian)\n        .setAudioRecvJitterMedian(audioRecvJitterMedian)\n        .setVideoRecvJitterMedian(videoRecvJitterMedian)\n        .setAudioSendJitterMedian(audioSendJitterMedian)\n        .setVideoSendJitterMedian(videoSendJitterMedian)\n        .setAudioRecvPacketLossFraction(audioRecvPacketLossFraction)\n        .setVideoRecvPacketLossFraction(videoRecvPacketLossFraction)\n        .setAudioSendPacketLossFraction(audioSendPacketLossFraction)\n        .setVideoSendPacketLossFraction(videoSendPacketLossFraction)\n        .setCallTelemetry(ByteString.copyFrom(telemetryBytes))\n        .build();\n\n    //noinspection unchecked\n    when(pubsubPublisher.publish(any())).thenReturn(mock(ApiFuture.class));\n\n    assertDoesNotThrow(() -> callQualitySurveyManager.submitCallQualitySurvey(request, REMOTE_ADDRESS, USER_AGENT));\n\n    final ArgumentCaptor<PubsubMessage> pubsubMessageCaptor = ArgumentCaptor.forClass(PubsubMessage.class);\n\n    verify(pubsubPublisher).publish(pubsubMessageCaptor.capture());\n\n    final CallQualitySurveyResponsePubSubMessage callQualitySurveyResponsePubSubMessage =\n        CallQualitySurveyResponsePubSubMessage.parseFrom(pubsubMessageCaptor.getValue().getData());\n\n    assertEquals(4, UUID.fromString(callQualitySurveyResponsePubSubMessage.getResponseId()).version());\n    assertEquals(\"ios\", callQualitySurveyResponsePubSubMessage.getClientPlatform());\n    assertEquals(\"7.78.0.1041\", callQualitySurveyResponsePubSubMessage.getClientVersion());\n    assertEquals(\"iOS/18.3.2 libsignal/0.80.3\", callQualitySurveyResponsePubSubMessage.getClientUaAdditionalSpecifiers());\n    assertEquals(asnRegion, callQualitySurveyResponsePubSubMessage.getAsnRegion());\n    assertFalse(callQualitySurveyResponsePubSubMessage.getUserSatisfied());\n    assertEquals(List.of(\"too_hot\", \"too_cold\"), callQualitySurveyResponsePubSubMessage.getCallQualityIssuesList());\n    assertEquals(\"But this one is just right\", callQualitySurveyResponsePubSubMessage.getAdditionalIssuesDescription());\n    assertEquals(\"https://example.com/\", callQualitySurveyResponsePubSubMessage.getDebugLogUrl());\n    assertEquals(123456789L * 1_000, callQualitySurveyResponsePubSubMessage.getStartTimestamp());\n    assertEquals(987654321L * 1_000, callQualitySurveyResponsePubSubMessage.getEndTimestamp());\n    assertEquals(\"direct_video\", callQualitySurveyResponsePubSubMessage.getCallType());\n    assertTrue(callQualitySurveyResponsePubSubMessage.getSuccess());\n    assertEquals(\"caller_hang_up\", callQualitySurveyResponsePubSubMessage.getCallEndReason());\n    assertEquals(connectionRttMedian, callQualitySurveyResponsePubSubMessage.getConnectionRttMedian());\n    assertEquals(audioRttMedian, callQualitySurveyResponsePubSubMessage.getAudioRttMedian());\n    assertEquals(videoRttMedian, callQualitySurveyResponsePubSubMessage.getVideoRttMedian());\n    assertEquals(audioRecvJitterMedian, callQualitySurveyResponsePubSubMessage.getAudioRecvJitterMedian());\n    assertEquals(videoRecvJitterMedian, callQualitySurveyResponsePubSubMessage.getVideoRecvJitterMedian());\n    assertEquals(audioSendJitterMedian, callQualitySurveyResponsePubSubMessage.getAudioSendJitterMedian());\n    assertEquals(videoSendJitterMedian, callQualitySurveyResponsePubSubMessage.getVideoSendJitterMedian());\n    assertEquals(audioRecvPacketLossFraction, callQualitySurveyResponsePubSubMessage.getAudioRecvPacketLossFraction());\n    assertEquals(videoRecvPacketLossFraction, callQualitySurveyResponsePubSubMessage.getVideoRecvPacketLossFraction());\n    assertEquals(audioSendPacketLossFraction, callQualitySurveyResponsePubSubMessage.getAudioSendPacketLossFraction());\n    assertEquals(videoSendPacketLossFraction, callQualitySurveyResponsePubSubMessage.getVideoSendPacketLossFraction());\n    assertArrayEquals(telemetryBytes, callQualitySurveyResponsePubSubMessage.getCallTelemetry().toByteArray());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void validateRequest(final SubmitCallQualitySurveyRequest request, final boolean expectValid) {\n    final Executable validateRequest = () -> CallQualitySurveyManager.validateRequest(request);\n\n    if (expectValid) {\n      assertDoesNotThrow(validateRequest);\n    } else {\n      final CallQualityInvalidArgumentsException invalidArgumentsException =\n          assertThrows(CallQualityInvalidArgumentsException.class, validateRequest);\n\n      assertTrue(StringUtils.isNotBlank(invalidArgumentsException.getMessage()));\n    }\n  }\n\n  private static List<Arguments> validateRequest() {\n    final SubmitCallQualitySurveyRequest validRequest = SubmitCallQualitySurveyRequest.newBuilder()\n        .setStartTimestamp(Instant.now().toEpochMilli())\n        .setEndTimestamp(Instant.now().plusSeconds(60).toEpochMilli())\n        .setCallType(\"test\")\n        .setCallEndReason(\"test\")\n        .build();\n\n    return List.of(\n        Arguments.argumentSet(\"Valid survey response\", validRequest, true),\n        Arguments.argumentSet(\"No start timestamp\", validRequest.toBuilder().clearStartTimestamp().build(), false),\n        Arguments.argumentSet(\"No end timestamp\", validRequest.toBuilder().clearEndTimestamp().build(), false),\n        Arguments.argumentSet(\"No call type\", validRequest.toBuilder().clearCallType().build(), false),\n        Arguments.argumentSet(\"No call end reason\", validRequest.toBuilder().clearCallEndReason().build(), false)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/MessageMetricsTest.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.Meter;\nimport io.micrometer.core.instrument.simple.SimpleMeterRegistry;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.entities.OutgoingMessageEntity;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\n\nclass MessageMetricsTest {\n\n  private final Account account = mock(Account.class);\n  private final UUID aci = UUID.fromString(\"11111111-1111-1111-1111-111111111111\");\n  private final UUID pni = UUID.fromString(\"22222222-2222-2222-2222-222222222222\");\n  private final UUID otherUuid = UUID.fromString(\"99999999-9999-9999-9999-999999999999\");\n  private MessageMetrics messageMetrics;\n  private SimpleMeterRegistry simpleMeterRegistry;\n\n  @BeforeEach\n  void setup() {\n    when(account.getUuid()).thenReturn(aci);\n    when(account.getPhoneNumberIdentifier()).thenReturn(pni);\n    when(account.isIdentifiedBy(any())).thenReturn(false);\n    when(account.isIdentifiedBy(new AciServiceIdentifier(aci))).thenReturn(true);\n    when(account.isIdentifiedBy(new PniServiceIdentifier(pni))).thenReturn(true);\n    simpleMeterRegistry = new SimpleMeterRegistry();\n    messageMetrics = new MessageMetrics(simpleMeterRegistry);\n  }\n\n  @Test\n  void measureAccountOutgoingMessageUuidMismatches() {\n\n    final OutgoingMessageEntity outgoingMessageToAci = createOutgoingMessageEntity(new AciServiceIdentifier(aci));\n    messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageToAci);\n\n    Optional<Counter> counter = findCounter(simpleMeterRegistry);\n\n    assertTrue(counter.isEmpty());\n\n    final OutgoingMessageEntity outgoingMessageToPni = createOutgoingMessageEntity(new PniServiceIdentifier(pni));\n    messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageToPni);\n    counter = findCounter(simpleMeterRegistry);\n\n    assertTrue(counter.isEmpty());\n\n    final OutgoingMessageEntity outgoingMessageToOtherUuid = createOutgoingMessageEntity(new AciServiceIdentifier(otherUuid));\n    messageMetrics.measureAccountOutgoingMessageUuidMismatches(account, outgoingMessageToOtherUuid);\n    counter = findCounter(simpleMeterRegistry);\n\n    assertEquals(1.0, counter.map(Counter::count).orElse(0.0));\n  }\n\n  private OutgoingMessageEntity createOutgoingMessageEntity(final ServiceIdentifier destinationIdentifier) {\n    return new OutgoingMessageEntity(UUID.randomUUID(), 1, 1L, null, 1, destinationIdentifier, null, new byte[]{}, 1, true, false, null);\n  }\n\n  @Test\n  void measureAccountEnvelopeUuidMismatches() {\n    final MessageProtos.Envelope envelopeToAci = createEnvelope(new AciServiceIdentifier(aci));\n    messageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToAci);\n\n    Optional<Counter> counter = findCounter(simpleMeterRegistry);\n\n    assertTrue(counter.isEmpty());\n\n    final MessageProtos.Envelope envelopeToPni = createEnvelope(new PniServiceIdentifier(pni));\n    messageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToPni);\n    counter = findCounter(simpleMeterRegistry);\n\n    assertTrue(counter.isEmpty());\n\n    final MessageProtos.Envelope envelopeToOtherUuid = createEnvelope(new AciServiceIdentifier(otherUuid));\n    messageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToOtherUuid);\n    counter = findCounter(simpleMeterRegistry);\n\n    assertEquals(1.0, counter.map(Counter::count).orElse(0.0));\n\n    final MessageProtos.Envelope envelopeToNull = createEnvelope(null);\n    messageMetrics.measureAccountEnvelopeUuidMismatches(account, envelopeToNull);\n    counter = findCounter(simpleMeterRegistry);\n\n    assertEquals(1.0, counter.map(Counter::count).orElse(0.0));\n  }\n\n  private MessageProtos.Envelope createEnvelope(ServiceIdentifier destinationIdentifier) {\n    final MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder();\n\n    if (destinationIdentifier != null) {\n      builder.setDestinationServiceId(destinationIdentifier.toServiceIdentifierString());\n    }\n\n    return builder.build();\n  }\n\n  private Optional<Counter> findCounter(SimpleMeterRegistry meterRegistry) {\n    final Optional<Meter> maybeMeter = meterRegistry.getMeters().stream()\n        .filter(meter -> meter.getId().getName().contains(MessageMetrics.MISMATCHED_ACCOUNT_ENVELOPE_UUID_COUNTER_NAME))\n        .findFirst();\n    return maybeMeter.map(meter -> meter instanceof Counter ? (Counter) meter : null);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsHttpChannelListenerIntegrationTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.junit.jupiter.api.Assertions.fail;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Tag;\nimport jakarta.annotation.Priority;\nimport jakarta.servlet.DispatcherType;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.InternalServerErrorException;\nimport jakarta.ws.rs.NotAuthorizedException;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Priorities;\nimport jakarta.ws.rs.WebApplicationException;\nimport jakarta.ws.rs.client.Client;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerRequestFilter;\nimport jakarta.ws.rs.core.Context;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.security.Principal;\nimport java.time.Duration;\nimport java.util.EnumSet;\nimport java.util.HashSet;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Supplier;\nimport java.util.stream.Stream;\nimport javax.security.auth.Subject;\nimport org.eclipse.jetty.server.Connector;\nimport org.eclipse.jetty.server.HttpChannel;\nimport org.eclipse.jetty.server.Request;\nimport org.eclipse.jetty.util.component.Container;\nimport org.eclipse.jetty.util.component.LifeCycle;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.WebSocketListener;\nimport org.eclipse.jetty.websocket.client.ClientUpgradeRequest;\nimport org.eclipse.jetty.websocket.client.WebSocketClient;\nimport org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.websocket.WebSocketResourceProviderFactory;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass MetricsHttpChannelListenerIntegrationTest {\n\n  private static final TrafficSource TRAFFIC_SOURCE = TrafficSource.HTTP;\n  private static final MeterRegistry METER_REGISTRY = mock(MeterRegistry.class);\n  private static final Counter REQUEST_COUNTER = mock(Counter.class);\n  private static final Counter RESPONSE_BYTES_COUNTER = mock(Counter.class);\n  private static final Counter REQUEST_BYTES_COUNTER = mock(Counter.class);\n  private static final AtomicReference<CountDownLatch> COUNT_DOWN_LATCH_FUTURE_REFERENCE = new AtomicReference<>();\n\n  private static final DropwizardAppExtension<Configuration> EXTENSION = new DropwizardAppExtension<>(\n      MetricsHttpChannelListenerIntegrationTest.TestApplication.class);\n\n  @AfterEach\n  void teardown() {\n    reset(METER_REGISTRY);\n    reset(REQUEST_COUNTER);\n    reset(RESPONSE_BYTES_COUNTER);\n    reset(REQUEST_BYTES_COUNTER);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  @SuppressWarnings(\"unchecked\")\n  void testSimplePath(String requestPath, String expectedTagPath, String expectedResponse, int expectedStatus)\n      throws Exception {\n\n    final CountDownLatch countDownLatch = new CountDownLatch(1);\n    COUNT_DOWN_LATCH_FUTURE_REFERENCE.set(countDownLatch);\n\n    final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n    final Map<String, Counter> counterMap = Map.of(\n        MetricsHttpChannelListener.REQUEST_COUNTER_NAME, REQUEST_COUNTER,\n        MetricsHttpChannelListener.RESPONSE_BYTES_COUNTER_NAME, RESPONSE_BYTES_COUNTER,\n        MetricsHttpChannelListener.REQUEST_BYTES_COUNTER_NAME, REQUEST_BYTES_COUNTER\n    );\n    when(METER_REGISTRY.counter(anyString(), any(Iterable.class)))\n        .thenAnswer(a -> counterMap.getOrDefault(a.getArgument(0, String.class), mock(Counter.class)));\n\n    Client client = EXTENSION.client();\n\n    final Supplier<String> request = () -> client.target(\n            String.format(\"http://localhost:%d%s\", EXTENSION.getLocalPort(), requestPath))\n        .request()\n        .header(HttpHeaders.USER_AGENT, \"Signal-Android/4.53.7 (Android 8.1)\")\n        .get(String.class);\n\n    switch (expectedStatus) {\n      case 200: {\n        final String response = request.get();\n        assertEquals(expectedResponse, response);\n        break;\n      }\n      case 401: {\n        assertThrows(NotAuthorizedException.class, request::get);\n        break;\n      }\n      case 500: {\n        assertThrows(InternalServerErrorException.class, request::get);\n        break;\n      }\n      default: {\n        fail(\"unexpected status\");\n      }\n    }\n\n    assertTrue(countDownLatch.await(1000, TimeUnit.MILLISECONDS));\n\n    verify(METER_REGISTRY).counter(eq(MetricsHttpChannelListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n    verify(REQUEST_COUNTER).increment();\n\n    final Iterable<Tag> tagIterable = tagCaptor.getValue();\n    final Set<Tag> tags = new HashSet<>();\n\n    for (final Tag tag : tagIterable) {\n      tags.add(tag);\n    }\n\n    assertEquals(Set.of(\n            Tag.of(MetricsHttpChannelListener.PATH_TAG, expectedTagPath),\n            Tag.of(MetricsHttpChannelListener.METHOD_TAG, \"GET\"),\n            Tag.of(MetricsHttpChannelListener.STATUS_CODE_TAG, String.valueOf(expectedStatus)),\n            Tag.of(MetricsHttpChannelListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()),\n            Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        tags);\n  }\n\n\n  @Nested\n  class WebSocket {\n\n    private WebSocketClient client;\n\n    @BeforeEach\n    void setUp() throws Exception {\n      client = new WebSocketClient();\n      client.start();\n    }\n\n    @AfterEach\n    void tearDown() throws Exception {\n      client.stop();\n    }\n\n    @Test\n    void testWebSocketUpgrade() throws Exception {\n      final ClientUpgradeRequest upgradeRequest = new ClientUpgradeRequest();\n      upgradeRequest.setHeader(HttpHeaders.USER_AGENT, \"Signal-Android/4.53.7 (Android 8.1)\");\n\n      final CountDownLatch countDownLatch = new CountDownLatch(1);\n      COUNT_DOWN_LATCH_FUTURE_REFERENCE.set(countDownLatch);\n\n      final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n      final Map<String, Counter> counterMap = Map.of(\n          MetricsHttpChannelListener.REQUEST_COUNTER_NAME, REQUEST_COUNTER,\n          MetricsHttpChannelListener.RESPONSE_BYTES_COUNTER_NAME, RESPONSE_BYTES_COUNTER,\n          MetricsHttpChannelListener.REQUEST_BYTES_COUNTER_NAME, REQUEST_BYTES_COUNTER\n      );\n      when(METER_REGISTRY.counter(anyString(), any(Iterable.class)))\n          .thenAnswer(a -> counterMap.getOrDefault(a.getArgument(0, String.class), mock(Counter.class)));\n\n      client.connect(new WebSocketListener() {\n                       @Override\n                       public void onWebSocketConnect(final Session session) {\n                         session.close(1000, \"OK\");\n                       }\n                     },\n          URI.create(String.format(\"ws://localhost:%d%s\", EXTENSION.getLocalPort(), \"/v1/websocket\")), upgradeRequest);\n\n      assertTrue(countDownLatch.await(1000, TimeUnit.MILLISECONDS));\n\n      verify(METER_REGISTRY).counter(eq(MetricsHttpChannelListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n      verify(REQUEST_COUNTER).increment();\n\n      final Iterable<Tag> tagIterable = tagCaptor.getValue();\n      final Set<Tag> tags = new HashSet<>();\n\n      for (final Tag tag : tagIterable) {\n        tags.add(tag);\n      }\n\n      assertEquals(Set.of(\n              Tag.of(MetricsHttpChannelListener.PATH_TAG, \"/v1/websocket\"),\n              Tag.of(MetricsHttpChannelListener.METHOD_TAG, \"GET\"),\n              Tag.of(MetricsHttpChannelListener.STATUS_CODE_TAG, String.valueOf(101)),\n              Tag.of(MetricsHttpChannelListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()),\n              Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n          tags);\n    }\n  }\n\n  static Stream<Arguments> testSimplePath() {\n    return Stream.of(\n        Arguments.of(\"/v1/test/hello\", \"/v1/test/hello\", \"Hello!\", 200),\n        Arguments.of(\"/v1/test/greet/friend\", \"/v1/test/greet/{name}\",\n            String.format(TestResource.GREET_FORMAT, \"friend\"), 200),\n        Arguments.of(\"/v1/test/greet/unauthorized\", \"/v1/test/greet/{name}\", null, 401),\n        Arguments.of(\"/v1/test/greet/exception\", \"/v1/test/greet/{name}\", null, 500)\n    );\n  }\n\n  public static class TestApplication extends Application<Configuration> {\n\n    @Override\n    public void run(final Configuration configuration,\n        final Environment environment) throws Exception {\n\n      final MetricsHttpChannelListener metricsHttpChannelListener = new MetricsHttpChannelListener(\n          METER_REGISTRY,\n          mock(ClientReleaseManager.class),\n          Set.of(\"/v1/websocket\")\n      );\n\n      metricsHttpChannelListener.configure(environment);\n      environment.lifecycle().addEventListener(new TestListener(COUNT_DOWN_LATCH_FUTURE_REFERENCE));\n\n      environment.servlets().addFilter(\"RemoteAddressFilter\", new RemoteAddressFilter())\n          .addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST), false, \"/*\");\n\n      environment.jersey().register(new TestResource());\n      environment.jersey().register(new TestAuthFilter());\n\n      // WebSocket set up\n      final WebSocketConfiguration webSocketConfiguration = new WebSocketConfiguration();\n\n      WebSocketEnvironment<TestPrincipal> webSocketEnvironment = new WebSocketEnvironment<>(environment,\n          webSocketConfiguration, Duration.ofMillis(1000));\n\n      webSocketEnvironment.jersey().register(new TestResource());\n\n      JettyWebSocketServletContainerInitializer.configure(environment.getApplicationContext(), null);\n\n      WebSocketResourceProviderFactory<TestPrincipal> webSocketServlet = new WebSocketResourceProviderFactory<>(\n          webSocketEnvironment, TestPrincipal.class, webSocketConfiguration,\n          RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME);\n\n      environment.servlets().addServlet(\"WebSocket\", webSocketServlet)\n          .addMapping(\"/v1/websocket\");\n    }\n  }\n\n  @Priority(Priorities.AUTHENTICATION)\n  static class TestAuthFilter implements ContainerRequestFilter {\n\n    @Override\n    public void filter(final ContainerRequestContext requestContext) throws IOException {\n      if (requestContext.getUriInfo().getPath().contains(\"unauthorized\")) {\n        throw new WebApplicationException(Response.Status.UNAUTHORIZED);\n      }\n    }\n  }\n\n  /**\n   * A simple listener to signal that {@link HttpChannel.Listener} has completed its work, since its onComplete() is on\n   * a different thread from the one that sends the response, creating a race condition between the listener and the\n   * test assertions\n   */\n  static class TestListener implements HttpChannel.Listener, Container.Listener, LifeCycle.Listener {\n\n    private final AtomicReference<CountDownLatch> completableFutureAtomicReference;\n\n    TestListener(AtomicReference<CountDownLatch> countDownLatchReference) {\n\n      this.completableFutureAtomicReference = countDownLatchReference;\n    }\n\n    @Override\n    public void onComplete(final Request request) {\n      completableFutureAtomicReference.get().countDown();\n    }\n\n    @Override\n    public void beanAdded(final Container parent, final Object child) {\n      if (child instanceof Connector connector) {\n          connector.addBean(this);\n      }\n    }\n\n    @Override\n    public void beanRemoved(final Container parent, final Object child) {\n\n    }\n\n  }\n\n  @Path(\"/v1/test\")\n  public static class TestResource {\n\n    static final String GREET_FORMAT = \"Hello, %s!\";\n\n\n    @GET\n    @Path(\"/hello\")\n    public String testGetHello() {\n      return \"Hello!\";\n    }\n\n    @GET\n    @Path(\"/greet/{name}\")\n    public String testGreetByName(@PathParam(\"name\") String name, @Context ContainerRequestContext context) {\n\n      if (\"exception\".equals(name)) {\n        throw new InternalServerErrorException();\n      }\n\n      return String.format(GREET_FORMAT, name);\n    }\n  }\n\n  public static class TestPrincipal implements Principal {\n\n    // Principal implementation\n\n    @Override\n    public String getName() {\n      return null;\n    }\n\n    @Override\n    public boolean implies(final Subject subject) {\n      return false;\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsHttpChannelListenerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Set;\nimport org.eclipse.jetty.http.HttpURI;\nimport org.eclipse.jetty.server.Request;\nimport org.eclipse.jetty.server.Response;\nimport org.glassfish.jersey.server.ExtendedUriInfo;\nimport org.glassfish.jersey.uri.UriTemplate;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\n\nclass MetricsHttpChannelListenerTest {\n\n  private MeterRegistry meterRegistry;\n  private Counter requestCounter;\n  private Counter requestsByVersionCounter;\n  private Counter responseBytesCounter;\n  private Counter requestBytesCounter;\n  private ClientReleaseManager clientReleaseManager;\n  private MetricsHttpChannelListener listener;\n\n  @BeforeEach\n  void setup() {\n    meterRegistry = mock(MeterRegistry.class);\n    requestCounter = mock(Counter.class);\n    requestsByVersionCounter = mock(Counter.class);\n    responseBytesCounter = mock(Counter.class);\n    requestBytesCounter = mock(Counter.class);\n\n    when(meterRegistry.counter(eq(MetricsHttpChannelListener.REQUEST_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(requestCounter);\n\n    when(meterRegistry.counter(eq(MetricsHttpChannelListener.REQUESTS_BY_VERSION_COUNTER_NAME), any(Tags.class)))\n        .thenReturn(requestsByVersionCounter);\n\n    when(meterRegistry.counter(eq(MetricsHttpChannelListener.RESPONSE_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(responseBytesCounter);\n\n    when(meterRegistry.counter(eq(MetricsHttpChannelListener.REQUEST_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(requestBytesCounter);\n\n    clientReleaseManager = mock(ClientReleaseManager.class);\n\n    listener = new MetricsHttpChannelListener(meterRegistry, clientReleaseManager, Collections.emptySet());\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  @SuppressWarnings(\"unchecked\")\n  void testRequests(final boolean versionActive) {\n    final String path = \"/test\";\n    final String method = \"GET\";\n    final int statusCode = 200;\n\n    final HttpURI httpUri = mock(HttpURI.class);\n    when(httpUri.getPath()).thenReturn(path);\n\n    final Request request = mock(Request.class);\n    when(request.getMethod()).thenReturn(method);\n    when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn(\"Signal-Android/6.53.7 (Android 8.1)\");\n    when(request.getHttpURI()).thenReturn(httpUri);\n\n    final Response response = mock(Response.class);\n    when(response.getStatus()).thenReturn(statusCode);\n    when(clientReleaseManager.isVersionActive(any(), any())).thenReturn(versionActive);\n\n    when(response.getContentCount()).thenReturn(1024L);\n    when(request.getResponse()).thenReturn(response);\n    when(request.getContentRead()).thenReturn(512L);\n    final ExtendedUriInfo extendedUriInfo = mock(ExtendedUriInfo.class);\n    when(request.getAttribute(MetricsHttpChannelListener.URI_INFO_PROPERTY_NAME)).thenReturn(extendedUriInfo);\n    when(extendedUriInfo.getMatchedTemplates()).thenReturn(List.of(new UriTemplate(path)));\n\n    final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n\n    listener.onComplete(request);\n\n    verify(requestCounter).increment();\n\n    verify(responseBytesCounter).increment(1024L);\n    verify(requestBytesCounter).increment(512L);\n\n    verify(meterRegistry).counter(eq(MetricsHttpChannelListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n\n    final Set<Tag> tags = new HashSet<>();\n    for (final Tag tag : tagCaptor.getValue()) {\n      tags.add(tag);\n    }\n\n    final Set<Tag> expectedTags = new HashSet<>(Set.of(\n        Tag.of(MetricsHttpChannelListener.PATH_TAG, path),\n        Tag.of(MetricsHttpChannelListener.METHOD_TAG, method),\n        Tag.of(MetricsHttpChannelListener.STATUS_CODE_TAG, String.valueOf(statusCode)),\n        Tag.of(MetricsHttpChannelListener.TRAFFIC_SOURCE_TAG, TrafficSource.HTTP.name().toLowerCase()),\n        Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")));\n\n    if (versionActive) {\n      expectedTags.add(Tag.of(UserAgentTagUtil.VERSION_TAG, \"6.53.7\"));\n    }\n\n    assertEquals(expectedTags, tags);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  @SuppressWarnings(\"unchecked\")\n  void testRequestsByVersion(final boolean versionActive) {\n    when(clientReleaseManager.isVersionActive(any(), any())).thenReturn(versionActive);\n    final String path = \"/test\";\n    final String method = \"GET\";\n    final int statusCode = 200;\n\n    final HttpURI httpUri = mock(HttpURI.class);\n    when(httpUri.getPath()).thenReturn(path);\n\n    final Request request = mock(Request.class);\n    when(request.getMethod()).thenReturn(method);\n    when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn(\"Signal-Android/6.53.7 (Android 8.1)\");\n    when(request.getHttpURI()).thenReturn(httpUri);\n\n    final Response response = mock(Response.class);\n    when(response.getStatus()).thenReturn(statusCode);\n    when(response.getContentCount()).thenReturn(1024L);\n    when(request.getResponse()).thenReturn(response);\n    when(request.getContentRead()).thenReturn(512L);\n    final ExtendedUriInfo extendedUriInfo = mock(ExtendedUriInfo.class);\n    when(request.getAttribute(MetricsHttpChannelListener.URI_INFO_PROPERTY_NAME)).thenReturn(extendedUriInfo);\n    when(extendedUriInfo.getMatchedTemplates()).thenReturn(List.of(new UriTemplate(path)));\n\n    listener.onComplete(request);\n\n    if (versionActive) {\n      final ArgumentCaptor<Tags> tagCaptor = ArgumentCaptor.forClass(Tags.class);\n      verify(meterRegistry).counter(eq(MetricsHttpChannelListener.REQUESTS_BY_VERSION_COUNTER_NAME),\n          tagCaptor.capture());\n      final Set<Tag> tags = new HashSet<>();\n      tags.clear();\n      for (final Tag tag : tagCaptor.getValue()) {\n        tags.add(tag);\n      }\n\n      assertEquals(2, tags.size());\n      assertTrue(tags.contains(Tag.of(UserAgentTagUtil.VERSION_TAG, \"6.53.7\")));\n      assertTrue(tags.contains(Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")));\n    } else {\n      verifyNoInteractions(requestsByVersionCounter);\n    }\n\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsRequestEventListenerTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.google.common.net.HttpHeaders;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.dropwizard.jersey.DropwizardResourceConfig;\nimport io.dropwizard.jersey.jackson.JacksonMessageBodyProvider;\nimport io.micrometer.core.instrument.Counter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.time.Duration;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport org.eclipse.jetty.websocket.api.RemoteEndpoint;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.UpgradeRequest;\nimport org.eclipse.jetty.websocket.api.WriteCallback;\nimport org.glassfish.jersey.server.ApplicationHandler;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.ContainerResponse;\nimport org.glassfish.jersey.server.ExtendedUriInfo;\nimport org.glassfish.jersey.server.ResourceConfig;\nimport org.glassfish.jersey.server.monitoring.RequestEvent;\nimport org.glassfish.jersey.uri.UriTemplate;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.tests.util.TestPrincipal;\nimport org.whispersystems.websocket.WebSocketResourceProvider;\nimport org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider;\nimport org.whispersystems.websocket.logging.WebsocketRequestLog;\nimport org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.protobuf.SubProtocol;\nimport org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider;\n\nclass MetricsRequestEventListenerTest {\n\n  private MeterRegistry meterRegistry;\n  private Counter counter;\n  private Counter responseBytesCounter;\n  private Counter requestBytesCounter;\n  private Counter requestsByVersionCounter;\n  private ClientReleaseManager clientReleaseManager;\n  private MetricsRequestEventListener listener;\n\n  private static final TrafficSource TRAFFIC_SOURCE = TrafficSource.HTTP;\n  private static final int LISTEN_PORT = 1234;\n\n  @BeforeEach\n  void setup() {\n    meterRegistry = mock(MeterRegistry.class);\n    counter = mock(Counter.class);\n    responseBytesCounter = mock(Counter.class);\n    requestBytesCounter = mock(Counter.class);\n    requestsByVersionCounter = mock(Counter.class);\n    clientReleaseManager = mock(ClientReleaseManager.class);\n\n    listener = new MetricsRequestEventListener(TRAFFIC_SOURCE, meterRegistry, clientReleaseManager);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  @SuppressWarnings(\"unchecked\")\n  void testOnEvent(final boolean versionActive) {\n    final String path = \"/test\";\n    final String method = \"GET\";\n    final int statusCode = 200;\n\n    when(clientReleaseManager.isVersionActive(any(), any())).thenReturn(versionActive);\n\n    final ExtendedUriInfo uriInfo = mock(ExtendedUriInfo.class);\n    when(uriInfo.getMatchedTemplates()).thenReturn(Collections.singletonList(new UriTemplate(path)));\n\n    final ContainerRequest request = mock(ContainerRequest.class);\n    when(request.getMethod()).thenReturn(method);\n    when(request.getRequestHeader(HttpHeaders.USER_AGENT)).thenReturn(\n        Collections.singletonList(\"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\"));\n    when(request.getProperty(WebSocketResourceProvider.REQUEST_LENGTH_PROPERTY)).thenReturn(512);\n    when(request.getProperty(WebSocketResourceProvider.RESPONSE_LENGTH_PROPERTY)).thenReturn(1024);\n\n    final ContainerResponse response = mock(ContainerResponse.class);\n    when(response.getStatus()).thenReturn(statusCode);\n\n    final RequestEvent event = mock(RequestEvent.class);\n    when(event.getType()).thenReturn(RequestEvent.Type.FINISHED);\n    when(event.getUriInfo()).thenReturn(uriInfo);\n    when(event.getContainerRequest()).thenReturn(request);\n    when(event.getContainerResponse()).thenReturn(response);\n\n    final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(counter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.RESPONSE_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(responseBytesCounter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(requestBytesCounter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUESTS_BY_VERSION_COUNTER_NAME), any(Tags.class)))\n        .thenReturn(requestsByVersionCounter);\n\n    listener.onEvent(event);\n\n    verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n    verify(counter).increment();\n    verify(responseBytesCounter).increment(1024L);\n    verify(requestBytesCounter).increment(512L);\n    verify(requestsByVersionCounter, versionActive ? times(1) : never()).increment();\n\n    final Iterable<Tag> tagIterable = tagCaptor.getValue();\n    final Set<Tag> tags = new HashSet<>();\n\n    for (final Tag tag : tagIterable) {\n      tags.add(tag);\n    }\n\n    final Set<Tag> expectedTags = new HashSet<>(Set.of(\n        Tag.of(MetricsRequestEventListener.PATH_TAG, path),\n        Tag.of(MetricsRequestEventListener.METHOD_TAG, method),\n        Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(statusCode)),\n        Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()),\n        Tag.of(MetricsRequestEventListener.AUTHENTICATED_TAG, \"false\"),\n        Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")));\n\n    if (versionActive) {\n      expectedTags.add(Tag.of(UserAgentTagUtil.VERSION_TAG, \"7.6.2\"));\n    }\n\n    assertEquals(expectedTags, tags);\n  }\n\n  @Test\n  void testActualRouteMessageSuccess() throws IOException {\n    final MetricsApplicationEventListener applicationEventListener = mock(MetricsApplicationEventListener.class);\n    when(applicationEventListener.onRequest(any())).thenReturn(listener);\n\n    final ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(applicationEventListener);\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    final ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    final WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    final WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME, LISTEN_PORT, applicationHandler, requestLog, TestPrincipal.authenticatedTestPrincipal(\"foo\"),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    final Session session = mock(Session.class);\n    final RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    final UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n    when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn(\"Signal-Android/4.53.7 (Android 8.1)\");\n    when(request.getHeaders()).thenReturn(Map.of(HttpHeaders.USER_AGENT, List.of(\"Signal-Android/4.53.7 (Android 8.1)\")));\n\n    final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(counter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.RESPONSE_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(responseBytesCounter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(requestBytesCounter);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/hello\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    final ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getStatus()).isEqualTo(200);\n\n    verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n\n    final Iterable<Tag> tagIterable = tagCaptor.getValue();\n    final Set<Tag> tags = new HashSet<>();\n\n    for (final Tag tag : tagIterable) {\n      tags.add(tag);\n    }\n\n    assertEquals(Set.of(\n            Tag.of(MetricsRequestEventListener.PATH_TAG, \"/v1/test/hello\"),\n            Tag.of(MetricsRequestEventListener.METHOD_TAG, \"GET\"),\n            Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(200)),\n            Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()),\n            Tag.of(MetricsRequestEventListener.AUTHENTICATED_TAG, \"true\"),\n            Tag.of(MetricsRequestEventListener.LISTEN_PORT_TAG, Integer.toString(LISTEN_PORT)),\n            Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        tags);\n  }\n\n  @Test\n  void testActualRouteMessageSuccessNoUserAgent() throws IOException {\n    final MetricsApplicationEventListener applicationEventListener = mock(MetricsApplicationEventListener.class);\n    when(applicationEventListener.onRequest(any())).thenReturn(listener);\n\n    final ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(applicationEventListener);\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    final ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    final WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    final WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME, LISTEN_PORT, applicationHandler, requestLog, TestPrincipal.authenticatedTestPrincipal(\"foo\"),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    final Session session = mock(Session.class);\n    final RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    final UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(\n        counter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.RESPONSE_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(responseBytesCounter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(requestBytesCounter);\n\n    provider.onWebSocketConnect(session);\n\n    final byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/hello\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    final ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getStatus()).isEqualTo(200);\n\n    verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n\n    final Iterable<Tag> tagIterable = tagCaptor.getValue();\n    final Set<Tag> tags = new HashSet<>();\n\n    for (final Tag tag : tagIterable) {\n      tags.add(tag);\n    }\n\n    assertEquals(Set.of(\n            Tag.of(MetricsRequestEventListener.PATH_TAG, \"/v1/test/hello\"),\n            Tag.of(MetricsRequestEventListener.METHOD_TAG, \"GET\"),\n            Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(200)),\n            Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()),\n            Tag.of(MetricsRequestEventListener.AUTHENTICATED_TAG, \"true\"),\n            Tag.of(MetricsRequestEventListener.LISTEN_PORT_TAG, Integer.toString(LISTEN_PORT)),\n            Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"unrecognized\")),\n        tags);\n  }\n\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testAuthenticated(final boolean authenticated) throws IOException {\n    final MetricsApplicationEventListener applicationEventListener = mock(MetricsApplicationEventListener.class);\n    when(applicationEventListener.onRequest(any())).thenReturn(listener);\n\n    final ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(applicationEventListener);\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    final ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    final WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    final Optional<TestPrincipal> maybePrincipal = authenticated ? TestPrincipal.authenticatedTestPrincipal(\"foo\") : Optional.empty();\n    final WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME, LISTEN_PORT, applicationHandler, requestLog, maybePrincipal,\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    final Session session = mock(Session.class);\n    final RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    final UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    final ArgumentCaptor<Iterable<Tag>> tagCaptor = ArgumentCaptor.forClass(Iterable.class);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), any(Iterable.class))).thenReturn(\n        counter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.RESPONSE_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(responseBytesCounter);\n    when(meterRegistry.counter(eq(MetricsRequestEventListener.REQUEST_BYTES_COUNTER_NAME), any(Iterable.class)))\n        .thenReturn(requestBytesCounter);\n\n    provider.onWebSocketConnect(session);\n\n    final byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/hello\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    final ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getStatus()).isEqualTo(200);\n\n    verify(meterRegistry).counter(eq(MetricsRequestEventListener.REQUEST_COUNTER_NAME), tagCaptor.capture());\n\n    final Iterable<Tag> tagIterable = tagCaptor.getValue();\n    final Set<Tag> tags = new HashSet<>();\n\n    for (final Tag tag : tagIterable) {\n      tags.add(tag);\n    }\n\n    assertEquals(Set.of(\n            Tag.of(MetricsRequestEventListener.PATH_TAG, \"/v1/test/hello\"),\n            Tag.of(MetricsRequestEventListener.METHOD_TAG, \"GET\"),\n            Tag.of(MetricsRequestEventListener.STATUS_CODE_TAG, String.valueOf(200)),\n            Tag.of(MetricsRequestEventListener.TRAFFIC_SOURCE_TAG, TRAFFIC_SOURCE.name().toLowerCase()),\n            Tag.of(MetricsRequestEventListener.AUTHENTICATED_TAG, String.valueOf(authenticated)),\n            Tag.of(MetricsRequestEventListener.LISTEN_PORT_TAG, Integer.toString(LISTEN_PORT)),\n            Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"unrecognized\")),\n        tags);\n  }\n\n  private static SubProtocol.WebSocketResponseMessage getResponse(ArgumentCaptor<ByteBuffer> responseCaptor)\n      throws InvalidProtocolBufferException {\n\n    return SubProtocol.WebSocketMessage.parseFrom(responseCaptor.getValue().array()).getResponse();\n  }\n\n\n  @Path(\"/v1/test\")\n  public static class TestResource {\n\n    @GET\n    @Path(\"/hello\")\n    public String testGetHello() {\n      return \"Hello!\";\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/MetricsUtilTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.micrometer.core.instrument.Meter;\nimport io.micrometer.core.instrument.MeterRegistry;\nimport io.micrometer.core.instrument.simple.SimpleMeterRegistry;\nimport java.util.List;\nimport java.util.Set;\n\nimport org.assertj.core.api.AbstractStringAssert;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicMetricsConfiguration;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\n\nclass MetricsUtilTest {\n\n  @Test\n  void name() {\n\n    assertEquals(\"chat.MetricsUtilTest.metric\", MetricsUtil.name(MetricsUtilTest.class, \"metric\"));\n    assertEquals(\"chat.MetricsUtilTest.namespace.metric\",\n        MetricsUtil.name(MetricsUtilTest.class, \"namespace\", \"metric\"));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void lettuceTagRejection(final boolean enableLettuceRemoteTag) {\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    final DynamicMetricsConfiguration metricsConfiguration = new DynamicMetricsConfiguration(enableLettuceRemoteTag, false);\n    when(dynamicConfiguration.getMetricsConfiguration()).thenReturn(metricsConfiguration);\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n\n    final MeterRegistry registry = new SimpleMeterRegistry();\n    MetricsUtil.configureMeterFilters(registry.config(), dynamicConfigurationManager);\n\n    registry.counter(\"lettuce.command.completion.max\", \"command\", \"hello\", \"remote\", \"world\", \"allowed\", \"!\").increment();\n    final List<Meter> meters = registry.getMeters();\n    assertThat(meters).hasSize(1);\n\n    final Meter meter = meters.getFirst();\n    assertThat(meter.getId().getName()).isEqualTo(\"chat.lettuce.command.completion.max\");\n    assertThat(meter.getId().getTag(\"command\")).isNull();\n    AbstractStringAssert<?> remoteTag = assertThat(meter.getId().getTag(\"remote\"));\n\n    if (enableLettuceRemoteTag) {\n      remoteTag.isNotNull();\n    } else {\n      remoteTag.isNull();\n    }\n    assertThat(meter.getId().getTag(\"allowed\")).isNotNull();\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void awsSdkMetricRejection(final boolean enableAwsSdkMetrics) {\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n    final DynamicMetricsConfiguration metricsConfiguration = new DynamicMetricsConfiguration(false, enableAwsSdkMetrics);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n    when(dynamicConfiguration.getMetricsConfiguration()).thenReturn(metricsConfiguration);\n\n    final MeterRegistry registry = new SimpleMeterRegistry();\n    MetricsUtil.configureMeterFilters(registry.config(), dynamicConfigurationManager);\n    registry.counter(\"chat.MicrometerAwsSdkMetricPublisher.days_since_last_incident\").increment();\n\n    assertThat(registry.getMeters()).hasSize(enableAwsSdkMetrics ? 1 : 0);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/TlsCertificateExpirationUtilTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.security.KeyStore;\nimport java.time.Instant;\nimport java.util.Map;\nimport org.eclipse.jetty.util.resource.Resource;\nimport org.eclipse.jetty.util.security.CertificateUtils;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.jetty.TestResource;\n\nclass TlsCertificateExpirationUtilTest {\n\n  // Please note that this certificate is used only for testing and is not used anywhere outside of this test.\n  // It was generated with:\n  //\n  // ```shell\n  // #!/bin/bash\n  //\n  // export PASSWORD=test123\n  //\n  // openssl req -newkey rsa:2048 -keyout test-root.key -nodes -x509 -days 36500 -out test-root.crt \\\n  //     -subj \"/CN=test-root\" \\\n  //     -addext \"basicConstraints=critical,CA:TRUE\"\n  //\n  // openssl req -new -newkey rsa:2048 -keyout test-rsa.key -out test-rsa.csr -passout env:PASSWORD \\\n  //     -subj \"/CN=localhost\" \\\n  //     -addext \"subjectAltName = DNS:localhost\"\n  //\n  // openssl req -new -newkey ED25519 -keyout test-ed25519.key -out test-ed25519.csr -passout env:PASSWORD \\\n  //     -subj \"/CN=localhost\" \\\n  //     -addext \"subjectAltName = DNS:localhost\"\n  //\n  // openssl x509 -req  -CAkey test-root.key -CA test-root.crt -days 36500  -copy_extensions copyall \\\n  //     -in test-rsa.csr \\\n  //     -out test-rsa.crt\n  //\n  // # create unique timestamps\n  // sleep 3\n  //\n  // openssl x509 -req  -CAkey test-root.key -CA test-root.crt -days 36500  -copy_extensions copyall \\\n  //     -in test-ed25519.csr \\\n  //     -out test-ed25519.crt\n  //\n  // cat test-root.crt >> test-rsa.crt\n  //\n  // openssl pkcs12 -export -in test-ed25519.crt -inkey test-ed25519.key -name 'ed25519' -out keystore.p12 \\\n  //     -passin env:PASSWORD -passout env:PASSWORD\n  //\n  // openssl pkcs12 -export -in test-rsa.crt -inkey test-rsa.key -name 'rsa' -out keystore-rsa.p12 \\\n  //     -passin env:PASSWORD -passout env:PASSWORD\n  //\n  // keytool -importkeystore -noprompt \\\n  //     -srckeystore keystore-rsa.p12 \\\n  //     -srcstoretype PKCS12 \\\n  //     -srcstorepass $PASSWORD \\\n  //     -destkeystore keystore.p12 \\\n  //     -deststoretype PKCS12 \\\n  //     -deststorepass $PASSWORD\n  //\n  // base64 -b 80 -i keystore.p12\n  // ```\n  private static final String KEYSTORE_BASE64 = \"\"\"\n      MIIRLQIBAzCCENcGCSqGSIb3DQEHAaCCEMgEghDEMIIQwDCCBqcGCSqGSIb3DQEHAaCCBpgEggaUMIIG\n      kDCB/AYLKoZIhvcNAQwKAQKggaYwgaMwXwYJKoZIhvcNAQUNMFIwMQYJKoZIhvcNAQUMMCQEEOqLCxfn\n      oI9s+ZaUjxBYAvMCAggAMAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBBhbXGVtsxJtJvyJmkGjkHF\n      BEAu6JKEuzF8GnRJ8M8War9gQQloZrsShZBC0FCqQ8+JTx4eY0G8EZp1yuwh8BzJx5mGvPPEuL1K4htC\n      I/nm8yC5MUQwHQYJKoZIhvcNAQkUMRAeDgBlAGQAMgA1ADUAMQA5MCMGCSqGSIb3DQEJFTEWBBTnVegw\n      3d6k2KrtH5o3O4OeO4chnDCCBY0GCyqGSIb3DQEMCgECoIIFQDCCBTwwZgYJKoZIhvcNAQUNMFkwOAYJ\n      KoZIhvcNAQUMMCsEFOInbF9RzXA4Hv/2CNxLQHeJkZnaAgInEAIBIDAMBggqhkiG9w0CCQUAMB0GCWCG\n      SAFlAwQBKgQQHvmbAQBd3U4y9DYJsfqPkASCBNC2dllFGSuQYkdNxpxC+xunpgYN9UPKCmmrmnmw7g5t\n      sSUwp0fhPv1GeHqD8dCuKJPJnEHj4eJb+VNLfPddpMimeB8/RoD6pry0acjr9YEPdCyZhqYuto1XAw2H\n      AZ9joanjs7Vcm1jUSopLbOmNfJmlzgXrNAE0piGnomSsIS/if27WEvd0ydx/R8p2p2MQYt/pNRMy6QD/\n      DgvPqWBcxljry2j5e70EJiPONtCHBwqDLlksPSMV0K5xkjaekV7NJn8n4QlVGE4mMt08pCxkMGqLGQX7\n      VYIIGMxX0xn8WJJxiOeLGucTy0xxO0h04bCVKZOFoD6ld4CPVySQ25pLL7SB13R87T+79rQpCrf0u7Wm\n      CAJSFhVO9Lo30YclYr+t6LHaq7sJRdEGcJr57i/0DfDBiw/gNE2G3z+hwNzMBgxlxQrnZVRnTC7bDeL0\n      LSnPgh0xQ+YPmWJK/frMs1auTNvTKpg6HmuN46CuF7/Js34A1//IT+x6YWelHm+nF7I6cYiIgcw5HFye\n      u6HzRZ74QGJzfkbCYLRWGcaSrofINdcgEXsxAxHkqAYFJrOBR4fChpkHCTJjYVMi7WG0UpE8tnvDHFn+\n      5GrpWuxU7rcCbYZppT8nOLOE4TORXUrOkw9C5Lfp6m9DWKW/hUb6HYzfYg8ps8wabNzs3seitqLg5uV9\n      /aRflJQxr2ga/Suk/W/0nLvvypkSO2umJ0LG8Sk3akpaYwArmtKN8jEdijngS3wc3iJlcRslJJlU+YnD\n      7W0/TQ38LYWM4mjEQo+6Rym8jXn8YxwE5Srt+1RlnoFw/UxMmf6lL+pJXKUl23wgNTM6fgkgf5TX3cO7\n      I66TylFQE9AQa0OLG1z9X5ga2qeet6/jkDI1eBHmRObUjtvloS/sQeSgOorAjq8wzb8vfmIH1x3iKQ7I\n      uE5Ckatrf926cSFY9DgSm/9oxPiGbRlzkrTLTatZ5QBhhB8GrWct36Q5gS0Bb632k2jQt+dmm4NOHfXj\n      FAk/Et5pMsuXXbtLB3HgzthybkGWYkdyTpEXhuaHD7r5jfBV0bNL0V8pr/Wumi/+LN++/xmW1LZ8wZwM\n      sGGVsge/Kt8VSCbodjm6p1hVKwZtY5nwctxTMANAjmr09gMIJRyQZKxWobhWim07mSu/3U8avtZm+Yeo\n      wrCv/K3B9kpvydAi3HFfswemBS3S5KSOT6HK0kfeZmr8LNkGwrxkoJwI3sh5DcGPaEUT2ez238zXTGNM\n      dreCDY5ESr5kNcfLxvI/AY/lR02hO31PZhSAU7wH8QLuresi6wnJhGK7qC7oaOPz01SsCnGKRmI2jucM\n      hLoEFnWkuMO9lbtE327UtCsuz9vj/ISN2P9OfgrqpfN0dhGnOOg3I1iwO9T/49Z5P3XhRDfef7nD9RmM\n      7Yc/ObWlptTb3BZBd8yQmQ7QKV1nTeTSCgrcmXzSDaP0fnfNuTLnzS8vrsVxkBnKrrPq7MnFYBg7nLlv\n      FiA4VKSZSymxzYzECVoNTk3bUjkoqhyaK77x/KKI9Sgr5xiKVlOvKAqTCuO2ZlF2D5V2MuYq41JyQSKv\n      F/Nx5a6lbN+OqV00Jm3GR2+QI7VckaFGLadj5FpvIzxr4X4jPBQxQGAqfhhBhRuVZ7MCp7Z2KmChiY39\n      uTE6MBUGCSqGSIb3DQEJFDEIHgYAcgBzAGEwIQYJKoZIhvcNAQkVMRQEElRpbWUgMTc0MzYxNTE2Mzcx\n      MDCCChEGCSqGSIb3DQEHBqCCCgIwggn+AgEAMIIJ9wYJKoZIhvcNAQcBMGYGCSqGSIb3DQEFDTBZMDgG\n      CSqGSIb3DQEFDDArBBTvCpZW0rVd0RGgTXwKrzvDuZFtcAICCAACASAwDAYIKoZIhvcNAgkFADAdBglg\n      hkgBZQMEASoEEJDkZ8lF+7ObiTcgnwtoO5GAggmA+NyAz99Ux+/UA8H9UpdoCb29R/xSOT7gQOD5STzR\n      6HTPPlrAXFJGdUUshBFIgyRZKd9DzqgR6GR549iITqWpik+qLG9l/9ZPzm6KZ8E4J3a9BP3P4O03jrUf\n      QIM68+G8o0ejdd6pz7R3higRo9wyMb08DNTN3Z+mc1HiGTrmK+5KRCPFhChrgxHb1S9b8IVjGwyCefPr\n      WiiBJuDIePXnZVBpjnYhqVVGTiKyXfSdIClZLZfcnaveVa+sULowZCXXPThJBb3Y+CO9WS+YoC6GR6M0\n      dvRxtXYrXkElgBYvkXTAwyBQbKO775nkmNJw3xzEQPUSEmKcCMUv2+A+DxY3ybdvXLVsYIYVZVWpcQhM\n      gGNq2St66kFm+adBLNOBliAEsWET6ka9m2n4e0JMzl0/rzKfihKUQrJtnhbDqpa2KnG4dWcThPVMC722\n      mDheehhvhf3Q+YVyl6uTxIM2V0ZfvLdcGWaZkfaS8EXjF31j4Xd/1eeXO3ukCjT8Jy2YqTSwiP5pxJ+/\n      o6SF6ij8BdjdLptrSy00YVzFmK5o7fdZBBxZgeSy2xuhB5atZm2BZXNQqgxK6i6bmUI03iAgyM0MDUDR\n      GYRE1IZib98QluMpHuHRWDLwUYMmM2cqtdPcNiDUUsd3022B9EXbbut8HrWHJxeDseHye9qvafIzoclw\n      IR71hqbkarSAunIdQwBkJN99DP//q9ECTt8xI/5SQwsYInpCRaX7fEizYQGlzSHDb+uwOy2MaxWYsrEM\n      kfmrGAgQ7iyUl4nwYuJWyPT0YbgpFREfLXEJnpqUAGtqWmK0l/nUHfksMqTCnAVTS+YxC50yVbWLi+Ki\n      umk5b6EkmO9ySyTG/w1xkiSmumg2bNPF/5Mk+DwVXF0rwABMS4RaubM1RPm7GvfUKKhiTkJFxKSyAdr7\n      GIZupUpJcMLkqJeav3m247jzTzFqIMazPKpKFq86vs6Faa1+zDJJyQ8JyG+YwItTcy0aVhszBb5b33Xy\n      7FZk8/Zf39OQvFgLb92TFyZG+cX7jPNsRo+2lpvCLum8ntc4UB47a0POWqDm+L5OT1FujDzvw5aZxvnI\n      IY/0PRBUlpURdQ3XEgeCKuRJGPt6MGJ/Kh9g8rD+KV7gz8mu/X4rilrHRBAjptQMW8xQg49n3LzCAu44\n      TD1l5ptV864vY9KSZURfXHOWfBHvmUeU4f580ZwA7OzEdyoEJsZBPvum7VNjYAw4TU4tQo2naoqIf0cg\n      KsLBCsYzzRclh1ar5sxKHOyhyU/RkRILO+i7FwoEgzII3M1rmuw4FaKl6GyNP+3w6MGijJUM7EXEZTPI\n      2QKc3ri4gWRYH2mIytUgzrv9vTz+V/uRqMF4+AQrDdhAzagBAnfInssJ20h+kXdoHexUjmj8kV4m6zO7\n      ebeC2CjOt0Ruy29EsG7u2hrcyZ6s8tcTEk7AMf3pZWZh7fFnMqUE+RvJjnkQrxklqWnCbh8TSQQaU9Jk\n      x2DAw2WFIbA8uweguXAlCKm1DyJDdlmEdjBNP7xMQ6ieJJQg+dSIpw9BrPMLDNZVFC8LCnmDAusjG80S\n      TgcuebNjk0xDOM46DDSmVdsTAFCNuN0OMqilC12V5wA15tLHVFV0T2TplrKI/VNCm0+z8dAX7yoFe43R\n      /YqSfPjLX/MvLb7Ni6ASXebzp3xXJTTygxEFx1aJgD2XpBliJB3flsEY8dCD2dt30CCBUf/8Qpyfl5AN\n      xmaJBBRigSBYddTs3mng6w+Xr/c5bcZC9xybTAKdQtl+5knF28rwVGKa21wo7aurqI4oIY8EqPdufCLZ\n      TaMtB2CPvmhAlLHgTRUlkOXBlzZMJqv7TEZiReTGOkZoXGdXfqN1xO4cuwRcf2CyyqV8VojbZ8R0LBRN\n      /CPZdzR8Yr8ICR1jKZHKMxqOgjMhYGFzImHVO2NiPYWgC+MwUhrh9uiQ8vRCk/KpREvfsf7Q4KWDtQfB\n      pYvay8ptsrr9O/5yNU0BY5W43ciBcjzW0C8YQIKXy3a/kZ9ZCwr+nFLZPzJHMAIZNoNtnlZIA20g1q7E\n      5IYQhBlSdwWU9ajm22PSG2udMyxG363uNXTtls70VJLyTqbEWgwuI1U1NjJLga8DQw8j7bTiTmBSEF7U\n      kkiAxNe/bNm8upoqU3NEvqyti14WnGQQgycbUEEucTDzq8Pyfiu+qCoOruCsPEfESbcU4WqyXTd9gA40\n      6rY9vyL46CkI1E3WRvbU5BdZVSXyT8Xort+z/Edpb7MoKyb2Y39PZ+IVzlJvZ9QsNEpR985V1UHaYZnu\n      GHlMVHyY+nhlQBpyKj/4Eajow+saNYhQm1TMA9xKQckqOoFHvQvM2Cy60/XbknqFCJshAOg2nvqMspMg\n      F/M+yANnTMSooU9HPF3RMUN2ZkZYsXvrfZO2C+8tpP3ZzsigCgfnvwVqT024C/8Q//OBvn/PriG6j5UA\n      208nRwqwICdG0IQg2j9e7m0z1OxU0oKZXfxC6fXkj7R/B/yKf/aNByq6bRebsD8QRNPW7P1YE+jvfya9\n      5SVOLuc8cyiqoMBqWm4rwT+/cxqp7zYVj9kLVvaAYqh1QtGgHvIVarCsSnwbEdEq9S7m9HwRmgogJ+80\n      UnMhrhdFI0buVSCFIlEHA9Wn1ipxcv4hZjR6GRcw3NY8I8DTs8vdU+DPXktUVg2B9Q/hMZnr1MxPe5Nx\n      +fQf8fIyQz+V9gPE9Z7PzcuMD2b1sgLxMnHnuXVSPdb8DZ+Knzl08rvk8ECKKqhk4OeSH5dEjRDbUgMM\n      Tgkol04+4Wp6S+7hgxpzZqJdfsb4nHiSV/95jAOyN1ezh+AykdmjlXS6V0p4NrQuoujCbFn2KeqwhLj/\n      byqoX4A6X4toTUZjg4+PgJr3TieJ2vSEPTL5j6jfqDI6frQaTZzzRKz8Mol5JI4zmcHckXLRLnoJf6Hx\n      ebgLG1xSU37M+WOve1MRjwFiO3pUBHJbGEWGlBtRbOY/W5alNFS/206ac2C8YZK/bgcTjRf7WVCJ72+y\n      utlSQzIiY43e+aWfR42dkNyDdIwrwnx3w3stOvEZCa7CCNwHSCHdV5WsVfFDqcPac6+rQFq+hMDB4C1D\n      CEgn2VV7Y1Sn35Gl7h90GZHRr0dei4N0qN0jM5NevdyLVYVEyU8XeZAUuY7yP1ZhExBFZllVuwOhbu6v\n      2CnOhTdyXY/NKRYl0eqNgZeIMg6VMgO8nE27nR5XJaPIUcTdyoFLSXPjIfdPCbeKSk16hGmq+N2xfF0t\n      8tkwTTAxMA0GCWCGSAFlAwQCAQUABCClumOxCp5GRbcwwR9luMgQJ8ktlYmxQzrK8VHftiuoEAQUFqd9\n      N6VjWLZbEYq1VlM8QpdcaaoCAggA\n      \"\"\";\n  private static final String KEYSTORE_PASSWORD = \"test123\";\n\n  private static final Instant EDDSA_EXPIRATION = Instant.ofEpochSecond(4897215163L);\n  private static final Instant RSA_EXPIRATION = Instant.ofEpochSecond(4897215160L);\n\n  @Test\n  void test() throws Exception {\n    try (Resource keystore = TestResource.fromBase64Mime(\"keystore\", KEYSTORE_BASE64)) {\n\n      final KeyStore keyStore = CertificateUtils.getKeyStore(keystore, \"PKCS12\", null, KEYSTORE_PASSWORD);\n\n      final Map<String, Instant> expected = Map.of(\n          \"localhost:EdDSA\", EDDSA_EXPIRATION,\n          \"localhost:RSA\", RSA_EXPIRATION);\n      assertEquals(expected, TlsCertificateExpirationUtil.getIdentifiersAndExpirations(keyStore, KEYSTORE_PASSWORD));\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/metrics/UserAgentTagUtilTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.metrics;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.micrometer.core.instrument.Tag;\nimport io.micrometer.core.instrument.Tags;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\n\nclass UserAgentTagUtilTest {\n\n  @ParameterizedTest\n  @MethodSource\n  void getPlatformTag(final String userAgent, final Tag expectedTag) {\n    assertEquals(expectedTag, UserAgentTagUtil.getPlatformTag(userAgent));\n  }\n\n  private static Stream<Arguments> getPlatformTag() {\n    return Stream.of(\n        Arguments.of(\"This is obviously not a reasonable User-Agent string.\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"unrecognized\")),\n        Arguments.of(null, Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"unrecognized\")),\n        Arguments.of(\"Signal-Android/4.53.7 (Android 8.1)\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        Arguments.of(\"Signal-Desktop/1.2.3\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"desktop\")),\n        Arguments.of(\"Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"ios\")),\n        Arguments.of(\"Signal-Android/1.2.3 (Android 8.1)\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        Arguments.of(\"Signal-Desktop/3.9.0\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"desktop\")),\n        Arguments.of(\"Signal-iOS/4.53.7 (iPhone; iOS 12.2; Scale/3.00)\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"ios\")),\n        Arguments.of(\"Signal-Android/4.68.3 (Android 9)\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        Arguments.of(\"Signal-Android/1.2.3 (Android 4.3)\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        Arguments.of(\"Signal-Android/4.68.3.0-bobsbootlegclient\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"android\")),\n        Arguments.of(\"Signal-Desktop/1.22.45-foo-0\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"desktop\")),\n        Arguments.of(\"Signal-Desktop/1.34.5-beta.1-fakeclientemporium\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"desktop\")),\n        Arguments.of(\"Signal-Desktop/1.32.0-beta.3\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"desktop\")),\n        Arguments.of(UserAgentTagUtil.SERVER_UA, Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"server\")),\n        Arguments.of(\"Signal-Server/1.2.3 (\" + UUID.randomUUID() + \")\", Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"unrecognized\"))\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void getClientVersionTag(final String userAgent, final boolean isVersionLive, final Optional<Tag> expectedTag) {\n    final ClientReleaseManager clientReleaseManager = mock(ClientReleaseManager.class);\n    when(clientReleaseManager.isVersionActive(any(), any())).thenReturn(isVersionLive);\n\n    assertEquals(expectedTag, UserAgentTagUtil.getClientVersionTag(userAgent, clientReleaseManager));\n  }\n\n  private static Stream<Arguments> getClientVersionTag() {\n    return Stream.of(\n        Arguments.of(\"Signal-Android/1.2.3 (Android 9)\",\n            true,\n            Optional.of(Tag.of(UserAgentTagUtil.VERSION_TAG, \"1.2.3\"))),\n\n        Arguments.of(\"Signal-Android/1.2.3 (Android 9)\",\n            false,\n            Optional.empty())\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getAdditionalSpecifierTags(@Nullable final String userAgent, final Tags expectedTags) {\n    assertEquals(expectedTags, UserAgentTagUtil.getAdditionalSpecifierTags(userAgent));\n  }\n\n  private static List<Arguments> getAdditionalSpecifierTags() {\n    return List.of(\n        Arguments.argumentSet(\"null UA\", null, Tags.empty()),\n        Arguments.argumentSet(\"nonsense UA\", \"This is not a valid User-Agent string\", Tags.empty()),\n        Arguments.argumentSet(\"no additional specifiers\", \"Signal-Desktop/7.84.0\", Tags.empty()),\n        Arguments.argumentSet(\"nonstandard additional specifiers\", \"Signal-Desktop/7.84.0 superfluous information\", Tags.empty()),\n        Arguments.argumentSet(\"desktop standard additional specifiers\", \"Signal-Desktop/7.84.0 macOS 21.6.0 libsignal/0.86.3\",\n            Tags.of(\n                UserAgentTagUtil.OPERATING_SYSTEM_TAG, \"macOS\",\n                UserAgentTagUtil.OPERATING_SYSTEM_VERSION_TAG, \"21.6.0\",\n                UserAgentTagUtil.LIBSIGNAL_VERSION_TAG, \"0.86.3\")),\n        Arguments.argumentSet(\"android standard additional specifiers\", \"Signal-Android/7.63.3 Android/34 libsignal/0.85.1\",\n            Tags.of(\n                UserAgentTagUtil.OPERATING_SYSTEM_TAG, \"Android\",\n                UserAgentTagUtil.OPERATING_SYSTEM_VERSION_TAG, \"34\",\n                UserAgentTagUtil.LIBSIGNAL_VERSION_TAG, \"0.85.1\")),\n        Arguments.argumentSet(\"ios standard additional specifiers\", \"Signal-iOS/7.89.0.1253 iOS/26.1 libsignal/0.86.7\",\n            Tags.of(\n                UserAgentTagUtil.OPERATING_SYSTEM_TAG, \"iOS\",\n                UserAgentTagUtil.OPERATING_SYSTEM_VERSION_TAG, \"26.1\",\n                UserAgentTagUtil.LIBSIGNAL_VERSION_TAG, \"0.86.7\"))\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/APNSenderTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.mockito.Mockito.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.eatthepath.pushy.apns.ApnsClient;\nimport com.eatthepath.pushy.apns.ApnsPushNotification;\nimport com.eatthepath.pushy.apns.DeliveryPriority;\nimport com.eatthepath.pushy.apns.PushNotificationResponse;\nimport com.eatthepath.pushy.apns.PushType;\nimport com.eatthepath.pushy.apns.util.SimpleApnsPushNotification;\nimport com.eatthepath.pushy.apns.util.concurrent.PushNotificationFuture;\nimport java.io.IOException;\nimport java.util.Optional;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.stubbing.Answer;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService;\n\nclass APNSenderTest {\n\n  private static final String DESTINATION_DEVICE_TOKEN = RandomStringUtils.secure().nextAlphanumeric(32);\n  private static final String BUNDLE_ID = \"org.signal.test\";\n\n  private Account destinationAccount;\n  private Device destinationDevice;\n\n  private ApnsClient apnsClient;\n  private APNSender apnSender;\n\n  @BeforeEach\n  void setup() {\n    destinationAccount = mock(Account.class);\n    destinationDevice = mock(Device.class);\n\n    apnsClient = mock(ApnsClient.class);\n    apnSender = new APNSender(new SynchronousExecutorService(), apnsClient, BUNDLE_ID);\n\n    when(destinationAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(destinationDevice));\n    when(destinationDevice.getApnId()).thenReturn(DESTINATION_DEVICE_TOKEN);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testSendApns(final boolean urgent) {\n    PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);\n    when(response.isAccepted()).thenReturn(true);\n\n    when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))\n        .thenAnswer(\n            (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));\n\n    PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN,\n        PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, urgent);\n\n    final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join();\n\n    ArgumentCaptor<SimpleApnsPushNotification> notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);\n    verify(apnsClient).sendNotification(notification.capture());\n\n    assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN);\n    assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION);\n    assertThat(notification.getValue().getPayload())\n        .isEqualTo(urgent ? APNSender.APN_NSE_NOTIFICATION_PAYLOAD : APNSender.APN_BACKGROUND_PAYLOAD);\n\n    assertThat(notification.getValue().getPriority())\n        .isEqualTo(urgent ? DeliveryPriority.IMMEDIATE : DeliveryPriority.CONSERVE_POWER);\n\n    assertThat(notification.getValue().getTopic()).isEqualTo(BUNDLE_ID);\n    assertThat(notification.getValue().getPushType())\n        .isEqualTo(urgent ? PushType.ALERT : PushType.BACKGROUND);\n\n    if (urgent) {\n      assertThat(notification.getValue().getCollapseId()).isNotNull();\n    } else {\n      assertThat(notification.getValue().getCollapseId()).isNull();\n    }\n\n    assertThat(result.accepted()).isTrue();\n    assertThat(result.errorCode()).isEmpty();\n    assertThat(result.unregistered()).isFalse();\n\n    verifyNoMoreInteractions(apnsClient);\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"Unregistered\", \"BadDeviceToken\", \"ExpiredToken\"})\n  void testUnregisteredUser(final String rejectionReason) {\n    PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);\n    when(response.isAccepted()).thenReturn(false);\n    when(response.getRejectionReason()).thenReturn(Optional.of(rejectionReason));\n\n    when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))\n        .thenAnswer(\n            (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));\n\n    PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN,\n        PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true);\n\n    when(destinationDevice.getApnId()).thenReturn(DESTINATION_DEVICE_TOKEN);\n    when(destinationDevice.getPushTimestamp()).thenReturn(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(11));\n\n    final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join();\n\n    ArgumentCaptor<SimpleApnsPushNotification> notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);\n    verify(apnsClient).sendNotification(notification.capture());\n\n    assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN);\n    assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION);\n    assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_NSE_NOTIFICATION_PAYLOAD);\n    assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE);\n\n    assertThat(result.accepted()).isFalse();\n    assertThat(result.errorCode()).hasValue(rejectionReason);\n    assertThat(result.unregistered()).isTrue();\n  }\n\n  @Test\n  void testGenericFailure() {\n    PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);\n    when(response.isAccepted()).thenReturn(false);\n    when(response.getRejectionReason()).thenReturn(Optional.of(\"BadTopic\"));\n\n    when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))\n        .thenAnswer(\n            (Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0), response));\n\n    PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN,\n        PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true);\n\n    final SendPushNotificationResult result = apnSender.sendNotification(pushNotification).join();\n\n    ArgumentCaptor<SimpleApnsPushNotification> notification = ArgumentCaptor.forClass(SimpleApnsPushNotification.class);\n    verify(apnsClient).sendNotification(notification.capture());\n\n    assertThat(notification.getValue().getToken()).isEqualTo(DESTINATION_DEVICE_TOKEN);\n    assertThat(notification.getValue().getExpiration()).isEqualTo(APNSender.MAX_EXPIRATION);\n    assertThat(notification.getValue().getPayload()).isEqualTo(APNSender.APN_NSE_NOTIFICATION_PAYLOAD);\n    assertThat(notification.getValue().getPriority()).isEqualTo(DeliveryPriority.IMMEDIATE);\n\n    assertThat(result.accepted()).isFalse();\n    assertThat(result.errorCode()).hasValue(\"BadTopic\");\n    assertThat(result.unregistered()).isFalse();\n  }\n\n  @Test\n  void testFailure() {\n    PushNotificationResponse<SimpleApnsPushNotification> response = mock(PushNotificationResponse.class);\n    when(response.isAccepted()).thenReturn(true);\n\n    when(apnsClient.sendNotification(any(SimpleApnsPushNotification.class)))\n        .thenAnswer((Answer) invocationOnMock -> new MockPushNotificationFuture<>(invocationOnMock.getArgument(0),\n            new IOException(\"lost connection\")));\n\n    PushNotification pushNotification = new PushNotification(DESTINATION_DEVICE_TOKEN, PushNotification.TokenType.APN,\n        PushNotification.NotificationType.NOTIFICATION, null, destinationAccount, destinationDevice, true);\n\n    assertThatThrownBy(() -> apnSender.sendNotification(pushNotification).join())\n        .isInstanceOf(CompletionException.class)\n        .hasCauseInstanceOf(IOException.class);\n\n    verify(apnsClient).sendNotification(any());\n\n    verifyNoMoreInteractions(apnsClient);\n  }\n\n  private static class MockPushNotificationFuture<P extends ApnsPushNotification, V> extends\n      PushNotificationFuture<P, V> {\n\n    MockPushNotificationFuture(final P pushNotification, final V response) {\n      super(pushNotification);\n      complete(response);\n    }\n\n    MockPushNotificationFuture(final P pushNotification, final Exception exception) {\n      super(pushNotification);\n      completeExceptionally(exception);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/FcmSenderTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.api.core.SettableApiFuture;\nimport com.google.firebase.messaging.FirebaseMessaging;\nimport com.google.firebase.messaging.FirebaseMessagingException;\nimport com.google.firebase.messaging.Message;\nimport com.google.firebase.messaging.MessagingErrorCode;\nimport java.io.IOException;\nimport java.util.Optional;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.tests.util.SynchronousExecutorService;\n\nclass FcmSenderTest {\n\n  private ExecutorService executorService;\n  private FirebaseMessaging firebaseMessaging;\n\n  private FcmSender fcmSender;\n\n  @BeforeEach\n  void setUp() {\n    executorService = new SynchronousExecutorService();\n    firebaseMessaging = mock(FirebaseMessaging.class);\n\n    fcmSender = new FcmSender(executorService, firebaseMessaging);\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    executorService.shutdown();\n\n    //noinspection ResultOfMethodCallIgnored\n    executorService.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void testSendMessage() {\n    final PushNotification pushNotification = new PushNotification(\"foo\", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true);\n\n    final SettableApiFuture<String> sendFuture = SettableApiFuture.create();\n    sendFuture.set(\"message-id\");\n\n    when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture);\n\n    final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join();\n\n    verify(firebaseMessaging).sendAsync(any(Message.class));\n    assertTrue(result.accepted());\n    assertTrue(result.errorCode().isEmpty());\n    assertFalse(result.unregistered());\n  }\n\n  @Test\n  void testSendMessageRejected() {\n    final PushNotification pushNotification = new PushNotification(\"foo\", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true);\n\n    final FirebaseMessagingException invalidArgumentException = mock(FirebaseMessagingException.class);\n    when(invalidArgumentException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.INVALID_ARGUMENT);\n\n    final SettableApiFuture<String> sendFuture = SettableApiFuture.create();\n    sendFuture.setException(invalidArgumentException);\n\n    when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture);\n\n    final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join();\n\n    verify(firebaseMessaging).sendAsync(any(Message.class));\n    assertFalse(result.accepted());\n    assertEquals(Optional.of(\"INVALID_ARGUMENT\"), result.errorCode());\n    assertFalse(result.unregistered());\n  }\n\n  @Test\n  void testSendMessageUnregistered() {\n    final PushNotification pushNotification = new PushNotification(\"foo\", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true);\n\n    final FirebaseMessagingException unregisteredException = mock(FirebaseMessagingException.class);\n    when(unregisteredException.getMessagingErrorCode()).thenReturn(MessagingErrorCode.UNREGISTERED);\n\n    final SettableApiFuture<String> sendFuture = SettableApiFuture.create();\n    sendFuture.setException(unregisteredException);\n\n    when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture);\n\n    final SendPushNotificationResult result = fcmSender.sendNotification(pushNotification).join();\n\n    verify(firebaseMessaging).sendAsync(any(Message.class));\n    assertFalse(result.accepted());\n    assertEquals(Optional.of(\"UNREGISTERED\"), result.errorCode());\n    assertTrue(result.unregistered());\n  }\n\n  @Test\n  void testSendMessageException() {\n    final PushNotification pushNotification = new PushNotification(\"foo\", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, null, null, true);\n\n    final SettableApiFuture<String> sendFuture = SettableApiFuture.create();\n    sendFuture.setException(new IOException());\n\n    when(firebaseMessaging.sendAsync(any())).thenReturn(sendFuture);\n\n    final CompletionException completionException =\n        assertThrows(CompletionException.class, () -> fcmSender.sendNotification(pushNotification).join());\n\n    verify(firebaseMessaging).sendAsync(any(Message.class));\n    assertTrue(completionException.getCause() instanceof IOException);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/IdleDeviceNotificationSchedulerTest.java",
    "content": "package org.whispersystems.textsecuregcm.push;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\nclass IdleDeviceNotificationSchedulerTest {\n\n  private AccountsManager accountsManager;\n  private PushNotificationManager pushNotificationManager;\n\n  private IdleDeviceNotificationScheduler idleDeviceNotificationScheduler;\n\n  private static final Instant CURRENT_TIME = Instant.now();\n\n  @BeforeEach\n  void setUp() {\n    accountsManager = mock(AccountsManager.class);\n    pushNotificationManager = mock(PushNotificationManager.class);\n\n    idleDeviceNotificationScheduler = new IdleDeviceNotificationScheduler(\n        accountsManager,\n        pushNotificationManager,\n        mock(DynamoDbAsyncClient.class),\n        \"test-idle-device-notifications\",\n        Duration.ofDays(7),\n        Clock.fixed(CURRENT_TIME, ZoneId.systemDefault()));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void processJob(final boolean accountPresent,\n      final boolean devicePresent,\n      final boolean tokenPresent,\n      final boolean lastSeenChanged,\n      final String expectedOutcome) throws JsonProcessingException, NotPushRegisteredException {\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(device.getLastSeen()).thenReturn(0L);\n\n    final Account account = mock(Account.class);\n    when(account.getDevice(deviceId)).thenReturn(devicePresent ? Optional.of(device) : Optional.empty());\n\n    when(accountsManager.getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.completedFuture(accountPresent ? Optional.of(account) : Optional.empty()));\n\n    if (tokenPresent) {\n      when(pushNotificationManager.sendNewMessageNotification(any(), anyByte(), anyBoolean()))\n          .thenReturn(CompletableFuture.completedFuture(\n              Optional.of(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty()))));\n    } else {\n      when(pushNotificationManager.sendNewMessageNotification(any(), anyByte(), anyBoolean()))\n          .thenThrow(NotPushRegisteredException.class);\n    }\n\n    final byte[] jobData = SystemMapper.jsonMapper().writeValueAsBytes(\n        new IdleDeviceNotificationScheduler.JobDescriptor(accountIdentifier, deviceId, lastSeenChanged ? 1 : 0));\n\n    assertEquals(expectedOutcome, idleDeviceNotificationScheduler.processJob(jobData).join());\n  }\n\n  private static List<Arguments> processJob() {\n    return List.of(\n        // Account present, device present, device has tokens, device is idle\n        Arguments.of(true, true, true, false, \"sent\"),\n\n        // Account present, device present, device has tokens, but device is active\n        Arguments.of(true, true, true, true, \"deviceSeenRecently\"),\n\n        // Account present, device present, device is idle, but missing tokens\n        Arguments.of(true, true, false, false, \"deviceTokenDeleted\"),\n\n        // Account present, but device missing\n        Arguments.of(true, false, true, false, \"deviceDeleted\"),\n\n        // Account missing\n        Arguments.of(false, true, true, false, \"accountDeleted\")\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/MessageSenderTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport io.micrometer.core.instrument.Tag;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.function.Executable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.libsignal.protocol.InvalidMessageException;\nimport org.signal.libsignal.protocol.InvalidVersionException;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevices;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.controllers.MultiRecipientMismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.metrics.UserAgentTagUtil;\nimport org.whispersystems.textsecuregcm.spam.MessageDeliveryListener;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.tests.util.MultiRecipientMessageHelper;\nimport org.whispersystems.textsecuregcm.tests.util.TestRecipient;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass MessageSenderTest {\n\n  private MessagesManager messagesManager;\n  private PushNotificationManager pushNotificationManager;\n  private MessageDeliveryListener messageDeliveryListener;\n\n  private MessageSender messageSender;\n\n  @BeforeEach\n  void setUp() {\n    messagesManager = mock(MessagesManager.class);\n    pushNotificationManager = mock(PushNotificationManager.class);\n    messageDeliveryListener = mock(MessageDeliveryListener.class);\n\n    messageSender = new MessageSender(messagesManager, pushNotificationManager);\n    messageSender.addMessageDeliveryListener(messageDeliveryListener);\n  }\n\n\n  @CartesianTest\n  void sendMessage(@CartesianTest.Values(booleans = {true, false}) final boolean clientPresent,\n      @CartesianTest.Values(booleans = {true, false}) final boolean ephemeral,\n      @CartesianTest.Values(booleans = {true, false}) final boolean urgent,\n      @CartesianTest.Values(booleans = {true, false}) final boolean hasPushToken) throws NotPushRegisteredException {\n\n    final boolean expectPushNotificationAttempt = !clientPresent && !ephemeral;\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(accountIdentifier);\n    final byte deviceId = Device.PRIMARY_ID;\n    final int registrationId = 17;\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final MessageProtos.Envelope message = MessageProtos.Envelope.newBuilder()\n        .setEphemeral(ephemeral)\n        .setUrgent(urgent)\n        .build();\n\n    when(account.getUuid()).thenReturn(accountIdentifier);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.isIdentifiedBy(serviceIdentifier)).thenReturn(true);\n    when(account.getDevices()).thenReturn(List.of(device));\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n\n    if (hasPushToken) {\n      when(device.getApnId()).thenReturn(\"apns-token\");\n    } else {\n      doThrow(NotPushRegisteredException.class)\n          .when(pushNotificationManager).sendNewMessageNotification(any(), anyByte(), anyBoolean());\n    }\n\n    when(messagesManager.insert(any(), any())).thenReturn(Map.of(deviceId, clientPresent));\n\n    assertDoesNotThrow(() -> messageSender.sendMessages(account,\n        serviceIdentifier,\n        Map.of(device.getId(), message),\n        Map.of(device.getId(), registrationId),\n        Optional.empty(),\n        null));\n\n    final MessageProtos.Envelope expectedMessage = ephemeral\n        ? message.toBuilder().setEphemeral(true).build()\n        : message.toBuilder().build();\n\n    verify(messagesManager).insert(accountIdentifier, Map.of(deviceId, expectedMessage));\n\n    if (expectPushNotificationAttempt) {\n      verify(pushNotificationManager).sendNewMessageNotification(account, deviceId, urgent);\n    } else {\n      verifyNoInteractions(pushNotificationManager);\n    }\n\n    verify(messageDeliveryListener).handleMessageDelivered(account,\n        deviceId,\n        ephemeral,\n        urgent,\n        false,\n        true,\n        false,\n        false);\n  }\n\n  @Test\n  void sendMessageMismatchedDevices() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(accountIdentifier);\n    final byte deviceId = Device.PRIMARY_ID;\n    final int registrationId = 17;\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final MessageProtos.Envelope message = MessageProtos.Envelope.newBuilder().build();\n\n    when(account.getUuid()).thenReturn(accountIdentifier);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.isIdentifiedBy(serviceIdentifier)).thenReturn(true);\n    when(account.getDevices()).thenReturn(List.of(device));\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n    when(device.getApnId()).thenReturn(\"apns-token\");\n\n    final MismatchedDevicesException mismatchedDevicesException =\n        assertThrows(MismatchedDevicesException.class, () -> messageSender.sendMessages(account,\n            serviceIdentifier,\n            Map.of(device.getId(), message),\n            Map.of(device.getId(), registrationId + 1),\n            Optional.empty(),\n            null));\n\n    assertEquals(new MismatchedDevices(Collections.emptySet(), Collections.emptySet(), Set.of(deviceId)),\n        mismatchedDevicesException.getMismatchedDevices());\n\n    verify(messageDeliveryListener, never()).handleMessageDelivered(any(),\n        anyByte(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean());\n  }\n\n  @CartesianTest\n  void sendMultiRecipientMessage(@CartesianTest.Values(booleans = {true, false}) final boolean clientPresent,\n      @CartesianTest.Values(booleans = {true, false}) final boolean ephemeral,\n      @CartesianTest.Values(booleans = {true, false}) final boolean urgent,\n      @CartesianTest.Values(booleans = {true, false}) final boolean hasPushToken)\n      throws NotPushRegisteredException, InvalidMessageException, InvalidVersionException {\n\n    final boolean expectPushNotificationAttempt = !clientPresent && !ephemeral;\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(accountIdentifier);\n    final byte deviceId = Device.PRIMARY_ID;\n    final int registrationId = 17;\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    when(account.getUuid()).thenReturn(accountIdentifier);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.isIdentifiedBy(serviceIdentifier)).thenReturn(true);\n    when(account.getDevices()).thenReturn(List.of(device));\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n    when(device.getApnId()).thenReturn(\"apns-token\");\n\n    if (hasPushToken) {\n      when(device.getApnId()).thenReturn(\"apns-token\");\n    } else {\n      doThrow(NotPushRegisteredException.class)\n          .when(pushNotificationManager).sendNewMessageNotification(any(), anyByte(), anyBoolean());\n    }\n\n    when(messagesManager.insertMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean()))\n        .thenReturn(CompletableFuture.completedFuture(Map.of(account, Map.of(deviceId, clientPresent))));\n\n    final SealedSenderMultiRecipientMessage multiRecipientMessage =\n        SealedSenderMultiRecipientMessage.parse(MultiRecipientMessageHelper.generateMultiRecipientMessage(\n            List.of(new TestRecipient(serviceIdentifier, deviceId, registrationId, new byte[48]))));\n\n    final SealedSenderMultiRecipientMessage.Recipient recipient =\n        multiRecipientMessage.getRecipients().values().iterator().next();\n\n    assertDoesNotThrow(() -> messageSender.sendMultiRecipientMessage(multiRecipientMessage,\n            Map.of(recipient, account),\n            System.currentTimeMillis(),\n            false,\n            ephemeral,\n            urgent,\n            null)\n        .join());\n\n    if (expectPushNotificationAttempt) {\n      verify(pushNotificationManager).sendNewMessageNotification(account, deviceId, urgent);\n    } else {\n      verifyNoInteractions(pushNotificationManager);\n    }\n\n    verify(messageDeliveryListener).handleMessageDelivered(account,\n        deviceId,\n        ephemeral,\n        urgent,\n        false,\n        true,\n        true,\n        false);\n  }\n\n  @Test\n  void sendMultiRecipientMessageMismatchedDevices() throws InvalidMessageException, InvalidVersionException {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(accountIdentifier);\n    final byte deviceId = Device.PRIMARY_ID;\n    final int registrationId = 17;\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    when(account.getUuid()).thenReturn(accountIdentifier);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.isIdentifiedBy(serviceIdentifier)).thenReturn(true);\n    when(account.getDevices()).thenReturn(List.of(device));\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n    when(device.getApnId()).thenReturn(\"apns-token\");\n\n    final SealedSenderMultiRecipientMessage multiRecipientMessage =\n        SealedSenderMultiRecipientMessage.parse(MultiRecipientMessageHelper.generateMultiRecipientMessage(\n            List.of(new TestRecipient(serviceIdentifier, deviceId, registrationId + 1, new byte[48]))));\n\n    final SealedSenderMultiRecipientMessage.Recipient recipient =\n        multiRecipientMessage.getRecipients().values().iterator().next();\n\n    when(messagesManager.insertMultiRecipientMessage(any(), any(), anyLong(), anyBoolean(), anyBoolean(), anyBoolean()))\n        .thenReturn(CompletableFuture.completedFuture(Map.of(account, Map.of(deviceId, true))));\n\n    final MultiRecipientMismatchedDevicesException mismatchedDevicesException =\n        assertThrows(MultiRecipientMismatchedDevicesException.class,\n            () -> messageSender.sendMultiRecipientMessage(multiRecipientMessage,\n                    Map.of(recipient, account),\n                    System.currentTimeMillis(),\n                    false,\n                    false,\n                    true,\n                    null)\n                .join());\n\n    assertEquals(Map.of(serviceIdentifier, new MismatchedDevices(Collections.emptySet(), Collections.emptySet(), Set.of(deviceId))),\n        mismatchedDevicesException.getMismatchedDevicesByServiceIdentifier());\n\n    verify(messageDeliveryListener, never()).handleMessageDelivered(any(),\n        anyByte(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean(),\n        anyBoolean());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void validateIndividualMessageBundle(final Account destination,\n      final ServiceIdentifier destinationIdentifier,\n      final Map<Byte, MessageProtos.Envelope> messagesByDeviceId,\n      final Map<Byte, Integer> registrationIdsByDeviceId,\n      @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\") final Optional<Byte> syncMessageSenderDeviceId,\n      @Nullable final Class<? extends Exception> expectedExceptionClass) {\n\n    final Executable validateIndividualMessageBundle = () -> MessageSender.validateIndividualMessageBundle(destination,\n        destinationIdentifier,\n        messagesByDeviceId,\n        registrationIdsByDeviceId,\n        syncMessageSenderDeviceId,\n        \"Signal/Test\");\n\n    if (expectedExceptionClass != null) {\n      assertThrows(expectedExceptionClass, validateIndividualMessageBundle);\n    } else {\n      assertDoesNotThrow(validateIndividualMessageBundle);\n    }\n  }\n\n  private static List<Arguments> validateIndividualMessageBundle() {\n    final ServiceIdentifier destinationIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    final byte primaryDeviceId = Device.PRIMARY_ID;\n    final byte linkedDeviceId = primaryDeviceId + 1;\n\n    final int primaryDeviceRegistrationId = 17;\n    final int linkedDeviceRegistrationId = primaryDeviceRegistrationId + 1;\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(primaryDeviceId);\n    when(primaryDevice.getRegistrationId(IdentityType.ACI)).thenReturn(primaryDeviceRegistrationId);\n\n    final Device linkedDevice = mock(Device.class);\n    when(linkedDevice.getId()).thenReturn(linkedDeviceId);\n    when(linkedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(linkedDeviceRegistrationId);\n\n    final Account destination = mock(Account.class);\n    when(destination.isIdentifiedBy(any())).thenReturn(false);\n    when(destination.isIdentifiedBy(destinationIdentifier)).thenReturn(true);\n    when(destination.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n    when(destination.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(destination.getDevice(primaryDeviceId)).thenReturn(Optional.of(primaryDevice));\n    when(destination.getDevice(linkedDeviceId)).thenReturn(Optional.of(linkedDevice));\n\n    return List.of(\n        Arguments.argumentSet(\"Valid\",\n            destination,\n            destinationIdentifier,\n            Map.of(\n                primaryDeviceId, generateEnvelope(null, 16),\n                linkedDeviceId, generateEnvelope(null, 16)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.empty(),\n            null),\n\n        Arguments.argumentSet(\"Mismatched service ID\",\n            destination,\n            new AciServiceIdentifier(UUID.randomUUID()),\n            Map.of(\n                primaryDeviceId, generateEnvelope(null, 16),\n                linkedDeviceId, generateEnvelope(null, 16)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.empty(),\n            IllegalArgumentException.class),\n\n        Arguments.argumentSet(\"Sync message without source on all messages\",\n            destination,\n            destinationIdentifier,\n            Map.of(linkedDeviceId, generateEnvelope(null, 16)),\n            Map.of(linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.of(primaryDevice),\n            IllegalArgumentException.class),\n\n        Arguments.argumentSet(\"Sync message to other account\",\n            destination,\n            destinationIdentifier,\n            Map.of(linkedDeviceId, generateEnvelope(new AciServiceIdentifier(UUID.randomUUID()), 16)),\n            Map.of(linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.of(primaryDevice),\n            IllegalArgumentException.class),\n\n        Arguments.argumentSet(\"Sync message to other account\",\n            destination,\n            destinationIdentifier,\n            Map.of(linkedDeviceId, generateEnvelope(new AciServiceIdentifier(UUID.randomUUID()), 16)),\n            Map.of(linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.of(primaryDevice),\n            IllegalArgumentException.class),\n\n        Arguments.argumentSet(\"Non-sync message addressed to sender\",\n            destination,\n            destinationIdentifier,\n            Map.of(\n                primaryDeviceId, generateEnvelope(destinationIdentifier, 16),\n                linkedDeviceId, generateEnvelope(destinationIdentifier, 16)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.empty(),\n            IllegalArgumentException.class),\n\n        Arguments.argumentSet(\"Non-sync message addressed to sender\",\n            destination,\n            destinationIdentifier,\n            Map.of(\n                primaryDeviceId, generateEnvelope(destinationIdentifier, 16),\n                linkedDeviceId, generateEnvelope(destinationIdentifier, 16)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.empty(),\n            IllegalArgumentException.class),\n\n        Arguments.argumentSet(\"Mismatched devices in message set\",\n            destination,\n            destinationIdentifier,\n            Map.of(\n                primaryDeviceId, generateEnvelope(null, 16),\n                linkedDeviceId + 1, generateEnvelope(null, 16)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId + 1, linkedDeviceRegistrationId),\n            Optional.empty(),\n            MismatchedDevicesException.class),\n\n        Arguments.argumentSet(\"Mismatched registration IDs\",\n            destination,\n            destinationIdentifier,\n            Map.of(\n                primaryDeviceId, generateEnvelope(null, 16),\n                linkedDeviceId, generateEnvelope(null, 16)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId, linkedDeviceRegistrationId + 1),\n            Optional.empty(),\n            MismatchedDevicesException.class),\n\n        Arguments.argumentSet(\"Oversized message\",\n            destination,\n            destinationIdentifier,\n            Map.of(\n                primaryDeviceId, generateEnvelope(null, MessageSender.MAX_MESSAGE_SIZE + 1),\n                linkedDeviceId, generateEnvelope(null, MessageSender.MAX_MESSAGE_SIZE + 1)),\n            Map.of(\n                primaryDeviceId, primaryDeviceRegistrationId,\n                linkedDeviceId, linkedDeviceRegistrationId),\n            Optional.empty(),\n            MessageTooLargeException.class)\n    );\n  }\n\n  private static MessageProtos.Envelope generateEnvelope(@Nullable ServiceIdentifier sourceIdentifier, final int contentLength) {\n    final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()\n        .setContent(ByteString.copyFrom(TestRandomUtil.nextBytes(contentLength)));\n\n    if (sourceIdentifier != null) {\n      envelopeBuilder.setSourceServiceId(sourceIdentifier.toServiceIdentifierString());\n    }\n\n    return envelopeBuilder.build();\n  }\n\n  @Test\n  void validateContentLength() {\n    assertThrows(MessageTooLargeException.class, () ->\n        MessageSender.validateContentLength(MessageSender.MAX_MESSAGE_SIZE + 1, false, false, false, Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"test\")));\n\n    assertDoesNotThrow(() ->\n        MessageSender.validateContentLength(MessageSender.MAX_MESSAGE_SIZE, false, false, false, Tag.of(UserAgentTagUtil.PLATFORM_TAG, \"test\")));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getMismatchedDevices(final Account account,\n      final ServiceIdentifier serviceIdentifier,\n      final Map<Byte, Integer> registrationIdsByDeviceId,\n      final byte excludedDeviceId,\n      @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\") final Optional<MismatchedDevices> expectedMismatchedDevices) {\n\n    assertEquals(expectedMismatchedDevices,\n        MessageSender.getMismatchedDevices(account, serviceIdentifier, registrationIdsByDeviceId, excludedDeviceId));\n  }\n\n  private static List<Arguments> getMismatchedDevices() {\n    final byte primaryDeviceId = Device.PRIMARY_ID;\n    final byte linkedDeviceId = primaryDeviceId + 1;\n    final byte extraDeviceId = linkedDeviceId + 1;\n\n    final int primaryDeviceAciRegistrationId = 2;\n    final int primaryDevicePniRegistrationId = 3;\n    final int linkedDeviceAciRegistrationId = 5;\n    final int linkedDevicePniRegistrationId = 7;\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(primaryDeviceId);\n    when(primaryDevice.getRegistrationId(IdentityType.ACI)).thenReturn(primaryDeviceAciRegistrationId);\n    when(primaryDevice.getRegistrationId(IdentityType.PNI)).thenReturn(primaryDevicePniRegistrationId);\n\n    final Device linkedDevice = mock(Device.class);\n    when(linkedDevice.getId()).thenReturn(linkedDeviceId);\n    when(linkedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(linkedDeviceAciRegistrationId);\n    when(linkedDevice.getRegistrationId(IdentityType.PNI)).thenReturn(linkedDevicePniRegistrationId);\n\n    final Account account = mock(Account.class);\n    when(account.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n    when(account.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(account.getDevice(primaryDeviceId)).thenReturn(Optional.of(primaryDevice));\n    when(account.getDevice(linkedDeviceId)).thenReturn(Optional.of(linkedDevice));\n\n    final AciServiceIdentifier aciServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final PniServiceIdentifier pniServiceIdentifier = new PniServiceIdentifier(UUID.randomUUID());\n\n    return List.of(\n        Arguments.argumentSet(\"Complete device list for ACI, no devices excluded\",\n            account,\n            aciServiceIdentifier,\n            Map.of(\n                primaryDeviceId, primaryDeviceAciRegistrationId,\n                linkedDeviceId, linkedDeviceAciRegistrationId\n            ),\n            MessageSender.NO_EXCLUDED_DEVICE_ID,\n            Optional.empty()),\n\n        Arguments.argumentSet(\"Complete device list for PNI, no devices excluded\",\n            account,\n            pniServiceIdentifier,\n            Map.of(\n                primaryDeviceId, primaryDevicePniRegistrationId,\n                linkedDeviceId, linkedDevicePniRegistrationId\n            ),\n            MessageSender.NO_EXCLUDED_DEVICE_ID,\n            Optional.empty()),\n\n        Arguments.argumentSet(\"Complete device list, device excluded\",\n            account,\n            aciServiceIdentifier,\n            Map.of(\n                linkedDeviceId, linkedDeviceAciRegistrationId\n            ),\n            primaryDeviceId,\n            Optional.empty()),\n\n        Arguments.argumentSet(\"Mismatched devices\",\n            account,\n            aciServiceIdentifier,\n            Map.of(\n                linkedDeviceId, linkedDeviceAciRegistrationId + 1,\n                extraDeviceId, 17\n            ),\n            MessageSender.NO_EXCLUDED_DEVICE_ID,\n            Optional.of(new MismatchedDevices(Set.of(primaryDeviceId), Set.of(extraDeviceId), Set.of(linkedDeviceId))))\n    );\n  }\n\n  @Test\n  void sendMessageEmptyMessageList() {\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    final Account account = mock(Account.class);\n    when(account.getDevices()).thenReturn(List.of(device));\n    when(account.isIdentifiedBy(serviceIdentifier)).thenReturn(true);\n\n    assertThrows(MismatchedDevicesException.class, () -> messageSender.sendMessages(account,\n        serviceIdentifier,\n        Collections.emptyMap(),\n        Collections.emptyMap(),\n        Optional.empty(),\n        null));\n\n    assertDoesNotThrow(() -> messageSender.sendMessages(account,\n        serviceIdentifier,\n        Collections.emptyMap(),\n        Collections.emptyMap(),\n        Optional.of(Device.PRIMARY_ID),\n        null));\n  }\n\n  @Test\n  void sendSyncMessageMismatchedAddressing() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(accountIdentifier);\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Account account = mock(Account.class);\n    when(account.getUuid()).thenReturn(accountIdentifier);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.isIdentifiedBy(serviceIdentifier)).thenReturn(true);\n\n    final Account nonSyncDestination = mock(Account.class);\n    when(nonSyncDestination.isIdentifiedBy(any())).thenReturn(true);\n\n    assertThrows(IllegalArgumentException.class, () -> messageSender.sendMessages(nonSyncDestination,\n            new AciServiceIdentifier(UUID.randomUUID()),\n            Map.of(deviceId, MessageProtos.Envelope.newBuilder().build()),\n            Map.of(deviceId, 17),\n            Optional.of(deviceId),\n            null),\n        \"Should throw an IllegalArgumentException for inter-account messages with a sync message device ID\");\n\n    assertThrows(IllegalArgumentException.class, () -> messageSender.sendMessages(account,\n        serviceIdentifier,\n        Map.of(deviceId, MessageProtos.Envelope.newBuilder()\n            .setSourceServiceId(serviceIdentifier.toServiceIdentifierString())\n            .setSourceDevice(deviceId)\n            .build()),\n        Map.of(deviceId, 17),\n        Optional.empty(),\n        null),\n        \"Should throw an IllegalArgumentException for self-addressed messages without a sync message device ID\");\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/ProvisioningManagerTest.java",
    "content": "package org.whispersystems.textsecuregcm.push;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.after;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\n\nimport com.google.protobuf.ByteString;\nimport java.util.UUID;\nimport java.util.function.Consumer;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.redis.RedisServerExtension;\nimport org.whispersystems.textsecuregcm.storage.PubSubProtos;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass ProvisioningManagerTest {\n\n  private ProvisioningManager provisioningManager;\n\n  @RegisterExtension\n  static final RedisServerExtension REDIS_EXTENSION = RedisServerExtension.builder().build();\n\n  private static final long PUBSUB_TIMEOUT_MILLIS = 1_000;\n\n  @BeforeEach\n  void setUp() throws Exception {\n    provisioningManager = new ProvisioningManager(REDIS_EXTENSION.getRedisClient());\n    provisioningManager.start();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    provisioningManager.stop();\n  }\n\n  @Test\n  void sendProvisioningMessage() {\n    final String provisioningAddress = UUID.randomUUID().toString();\n    final byte[] content = TestRandomUtil.nextBytes(16);\n\n    @SuppressWarnings(\"unchecked\") final Consumer<PubSubProtos.PubSubMessage> subscribedConsumer = mock(Consumer.class);\n\n    provisioningManager.addListener(provisioningAddress, subscribedConsumer);\n    provisioningManager.sendProvisioningMessage(provisioningAddress, content);\n\n    final ArgumentCaptor<PubSubProtos.PubSubMessage> messageCaptor =\n        ArgumentCaptor.forClass(PubSubProtos.PubSubMessage.class);\n\n    verify(subscribedConsumer, timeout(PUBSUB_TIMEOUT_MILLIS)).accept(messageCaptor.capture());\n\n    assertEquals(PubSubProtos.PubSubMessage.Type.DELIVER, messageCaptor.getValue().getType());\n    assertEquals(ByteString.copyFrom(content), messageCaptor.getValue().getContent());\n  }\n\n  @Test\n  void removeListener() {\n    final String provisioningAddress = UUID.randomUUID().toString();\n    final byte[] content = TestRandomUtil.nextBytes(16);\n\n    @SuppressWarnings(\"unchecked\") final Consumer<PubSubProtos.PubSubMessage> subscribedConsumer = mock(Consumer.class);\n\n    provisioningManager.addListener(provisioningAddress, subscribedConsumer);\n    provisioningManager.removeListener(provisioningAddress);\n    provisioningManager.sendProvisioningMessage(provisioningAddress, content);\n\n    // Make sure that we give the message enough time to show up (if it was going to) before declaring victory\n    verify(subscribedConsumer, after(PUBSUB_TIMEOUT_MILLIS).never()).accept(any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationManagerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\n\nclass PushNotificationManagerTest {\n\n  private AccountsManager accountsManager;\n  private APNSender apnSender;\n  private FcmSender fcmSender;\n  private PushNotificationScheduler pushNotificationScheduler;\n\n  private PushNotificationManager pushNotificationManager;\n\n  @BeforeEach\n  void setUp() {\n    accountsManager = mock(AccountsManager.class);\n    apnSender = mock(APNSender.class);\n    fcmSender = mock(FcmSender.class);\n    pushNotificationScheduler = mock(PushNotificationScheduler.class);\n\n    AccountsHelper.setupMockUpdate(accountsManager);\n\n    pushNotificationManager = new PushNotificationManager(accountsManager, apnSender, fcmSender,\n        pushNotificationScheduler);\n  }\n\n  @Test\n  void sendNewUrgentMessageNotification() throws NotPushRegisteredException {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    final String deviceToken = \"token\";\n\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(device.getGcmId()).thenReturn(deviceToken);\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n\n      when(fcmSender.sendNotification(any()))\n          .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n    pushNotificationManager.sendNewMessageNotification(account, Device.PRIMARY_ID, true);\n    verify(fcmSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, true));\n  }\n\n  @Test\n  void sendNewNonUrgentMessageNotification() throws NotPushRegisteredException {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    final String deviceToken = \"token\";\n\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(device.getGcmId()).thenReturn(deviceToken);\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n\n    when(pushNotificationScheduler.scheduleBackgroundNotification(any(), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n    pushNotificationManager.sendNewMessageNotification(account, Device.PRIMARY_ID, false);\n    verify(pushNotificationScheduler).scheduleBackgroundNotification(PushNotification.TokenType.FCM, account, device);\n  }\n\n\n  @Test\n  void sendRegistrationChallengeNotification() {\n    final String deviceToken = \"token\";\n    final String challengeToken = \"challenge\";\n\n    when(apnSender.sendNotification(any()))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n\n    pushNotificationManager.sendRegistrationChallengeNotification(deviceToken, PushNotification.TokenType.APN, challengeToken);\n    verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.CHALLENGE, challengeToken, null, null, true));\n  }\n\n  @Test\n  void sendRateLimitChallengeNotification() throws NotPushRegisteredException {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    final String deviceToken = \"token\";\n    final String challengeToken = \"challenge\";\n\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(device.getApnId()).thenReturn(deviceToken);\n    when(account.getPrimaryDevice()).thenReturn(device);\n\n    when(apnSender.sendNotification(any()))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n\n    pushNotificationManager.sendRateLimitChallengeNotification(account, challengeToken);\n    verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN, PushNotification.NotificationType.RATE_LIMIT_CHALLENGE, challengeToken, account, device, true));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void sendAttemptLoginNotification(final boolean isApn) throws NotPushRegisteredException {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    final String deviceToken = \"token\";\n\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    if (isApn) {\n      when(device.getApnId()).thenReturn(deviceToken);\n      when(apnSender.sendNotification(any()))\n          .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n    } else {\n      when(device.getGcmId()).thenReturn(deviceToken);\n      when(fcmSender.sendNotification(any()))\n          .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n    }\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n\n    pushNotificationManager.sendAttemptLoginNotification(account, \"someContext\");\n\n    if (isApn){\n      verify(apnSender).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.APN,\n          PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, \"someContext\", account, device, true));\n    } else {\n      verify(fcmSender, times(1)).sendNotification(new PushNotification(deviceToken, PushNotification.TokenType.FCM,\n          PushNotification.NotificationType.ATTEMPT_LOGIN_NOTIFICATION_HIGH_PRIORITY, \"someContext\", account, device, true));\n    }\n  }\n\n  @Test\n  void testSendNotificationFcm() {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n\n    final PushNotification pushNotification = new PushNotification(\n        \"token\", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, true);\n\n    when(fcmSender.sendNotification(pushNotification))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n\n    pushNotificationManager.sendNotification(pushNotification);\n\n    verify(fcmSender).sendNotification(pushNotification);\n    verifyNoInteractions(apnSender);\n    verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any());\n    verify(device, never()).setGcmId(any());\n    verifyNoInteractions(pushNotificationScheduler);\n  }\n\n  @CartesianTest\n  void testSendOrScheduleNotification(\n      @CartesianTest.Enum(PushNotification.TokenType.class) PushNotification.TokenType tokenType,\n      @CartesianTest.Values(booleans = {false, true}) final boolean urgent) {\n\n    final boolean expectSchedule = !urgent;\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final UUID aci = UUID.randomUUID();\n\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n    when(account.getUuid()).thenReturn(aci);\n\n    final PushNotification pushNotification = new PushNotification(\n        \"token\", tokenType, PushNotification.NotificationType.NOTIFICATION, null, account, device, urgent);\n\n    final PushNotificationSender sender = switch (tokenType) {\n      case FCM -> fcmSender;\n      case APN -> apnSender;\n    };\n    when(sender.sendNotification(pushNotification))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n\n    if (expectSchedule) {\n      when(pushNotificationScheduler.scheduleBackgroundNotification(tokenType, account, device))\n          .thenReturn(CompletableFuture.completedFuture(null));\n    }\n\n    pushNotificationManager.sendNotification(pushNotification);\n\n    if (!expectSchedule) {\n      verify(sender).sendNotification(pushNotification);\n      verifyNoInteractions(pushNotificationScheduler);\n    } else {\n      verifyNoInteractions(sender);\n      verify(pushNotificationScheduler).scheduleBackgroundNotification(tokenType, account, device);\n    }\n  }\n\n  @Test\n  void testSendNotificationUnregisteredFcm() {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final UUID aci = UUID.randomUUID();\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(device.getGcmId()).thenReturn(\"token\");\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n    when(account.getUuid()).thenReturn(aci);\n    when(accountsManager.getByAccountIdentifier(aci)).thenReturn(Optional.of(account));\n\n    final PushNotification pushNotification = new PushNotification(\n        \"token\", PushNotification.TokenType.FCM, PushNotification.NotificationType.NOTIFICATION, null, account, device, true);\n\n    when(fcmSender.sendNotification(pushNotification))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.empty())));\n\n    pushNotificationManager.sendNotification(pushNotification);\n\n    verify(accountsManager).updateDevice(eq(account), eq(Device.PRIMARY_ID), any());\n    verify(device).setGcmId(null);\n    verifyNoInteractions(apnSender);\n    verifyNoInteractions(pushNotificationScheduler);\n  }\n\n  @Test\n  void testSendNotificationUnregisteredApn() {\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final UUID aci = UUID.randomUUID();\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(device.getApnId()).thenReturn(\"apns-token\");\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n    when(account.getUuid()).thenReturn(aci);\n    when(accountsManager.getByAccountIdentifier(aci)).thenReturn(Optional.of(account));\n\n    final PushNotification pushNotification = new PushNotification(\n        \"token\", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true);\n\n    when(apnSender.sendNotification(pushNotification))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.empty())));\n\n    when(pushNotificationScheduler.cancelScheduledNotifications(account, device))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    pushNotificationManager.sendNotification(pushNotification);\n\n    verifyNoInteractions(fcmSender);\n    verify(accountsManager).updateDevice(eq(account), eq(Device.PRIMARY_ID), any());\n    verify(device).setApnId(null);\n    verify(pushNotificationScheduler).cancelScheduledNotifications(account, device);\n  }\n\n  @Test\n  void testSendNotificationUnregisteredApnTokenUpdated() {\n    final Instant tokenTimestamp = Instant.now();\n\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final UUID aci = UUID.randomUUID();\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n    when(device.getApnId()).thenReturn(\"apns-token\");\n    when(device.getPushTimestamp()).thenReturn(tokenTimestamp.toEpochMilli());\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n    when(account.getUuid()).thenReturn(aci);\n    when(accountsManager.getByAccountIdentifier(aci)).thenReturn(Optional.of(account));\n\n    final PushNotification pushNotification = new PushNotification(\n        \"token\", PushNotification.TokenType.APN, PushNotification.NotificationType.NOTIFICATION, null, account, device, true);\n\n    when(apnSender.sendNotification(pushNotification))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(false, Optional.empty(), true, Optional.of(tokenTimestamp.minusSeconds(60)))));\n\n    when(pushNotificationScheduler.cancelScheduledNotifications(account, device))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    pushNotificationManager.sendNotification(pushNotification);\n\n    verifyNoInteractions(fcmSender);\n    verify(accountsManager, never()).updateDevice(eq(account), eq(Device.PRIMARY_ID), any());\n    verify(device, never()).setApnId(any());\n    verify(pushNotificationScheduler, never()).cancelScheduledNotifications(account, device);\n  }\n\n  @Test\n  void testHandleMessagesRetrieved() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final Account account = mock(Account.class);\n    final Device device = mock(Device.class);\n    final String userAgent = HttpHeaders.USER_AGENT;\n\n    when(account.getUuid()).thenReturn(accountIdentifier);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    when(pushNotificationScheduler.cancelScheduledNotifications(account, device))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    pushNotificationManager.handleMessagesRetrieved(account, device, userAgent);\n\n    verify(pushNotificationScheduler).cancelScheduledNotifications(account, device);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/PushNotificationSchedulerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.atLeastOnce;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.cluster.SlotHash;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass PushNotificationSchedulerTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private Account account;\n  private Device device;\n\n  private APNSender apnSender;\n  private FcmSender fcmSender;\n  private TestClock clock;\n\n  private PushNotificationScheduler pushNotificationScheduler;\n\n  private static final UUID ACCOUNT_UUID = UUID.randomUUID();\n  private static final String ACCOUNT_NUMBER = \"+18005551234\";\n  private static final byte DEVICE_ID = 1;\n  private static final String APN_ID = RandomStringUtils.secure().nextAlphanumeric(32);\n  private static final String GCM_ID = RandomStringUtils.secure().nextAlphanumeric(32);\n\n  @BeforeEach\n  void setUp() throws Exception {\n\n    device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n    when(device.getApnId()).thenReturn(APN_ID);\n    when(device.getGcmId()).thenReturn(GCM_ID);\n    when(device.getLastSeen()).thenReturn(System.currentTimeMillis());\n\n    account = mock(Account.class);\n    when(account.getUuid()).thenReturn(ACCOUNT_UUID);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_UUID);\n    when(account.getNumber()).thenReturn(ACCOUNT_NUMBER);\n    when(account.getDevice(DEVICE_ID)).thenReturn(Optional.of(device));\n\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n    when(accountsManager.getByE164(ACCOUNT_NUMBER)).thenReturn(Optional.of(account));\n    when(accountsManager.getByAccountIdentifierAsync(ACCOUNT_UUID))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    apnSender = mock(APNSender.class);\n    fcmSender = mock(FcmSender.class);\n    clock = TestClock.now();\n\n    when(apnSender.sendNotification(any()))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n\n    when(fcmSender.sendNotification(any()))\n        .thenReturn(CompletableFuture.completedFuture(new SendPushNotificationResult(true, Optional.empty(), false, Optional.empty())));\n\n    pushNotificationScheduler = new PushNotificationScheduler(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        apnSender, fcmSender, accountsManager, clock, 1, 1, mock(ScheduledExecutorService.class));\n  }\n\n  @ParameterizedTest\n  @EnumSource(PushNotification.TokenType.class)\n  void testScheduleBackgroundNotificationWithNoRecentApnsNotification(PushNotification.TokenType tokenType) throws ExecutionException, InterruptedException {\n    final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n    clock.pin(now);\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getLastBackgroundApnsNotificationTimestamp(account, device));\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(tokenType, account, device));\n\n    pushNotificationScheduler.scheduleBackgroundNotification(tokenType, account, device).toCompletableFuture().get();\n\n    assertEquals(Optional.of(now),\n        pushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(tokenType, account, device));\n  }\n\n  @ParameterizedTest\n  @EnumSource(PushNotification.TokenType.class)\n  void testScheduleBackgroundNotificationWithRecentNotification(PushNotification.TokenType tokenType) throws ExecutionException, InterruptedException {\n    final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n    final Instant recentNotificationTimestamp =\n        now.minus(PushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD.dividedBy(2));\n\n    // Insert a timestamp for a recently-sent background push notification\n    clock.pin(Instant.ofEpochMilli(recentNotificationTimestamp.toEpochMilli()));\n    pushNotificationScheduler.sendBackgroundNotification(tokenType, account, device);\n\n    clock.pin(now);\n    pushNotificationScheduler.scheduleBackgroundNotification(tokenType, account, device).toCompletableFuture().get();\n\n    final Instant expectedScheduledTimestamp =\n        recentNotificationTimestamp.plus(PushNotificationScheduler.BACKGROUND_NOTIFICATION_PERIOD);\n\n    assertEquals(Optional.of(expectedScheduledTimestamp),\n        pushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(tokenType, account, device));\n  }\n\n  @ParameterizedTest\n  @EnumSource(PushNotification.TokenType.class)\n  void testCancelBackgroundApnsNotifications(PushNotification.TokenType tokenType) {\n    final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n    clock.pin(now);\n\n    pushNotificationScheduler.scheduleBackgroundNotification(tokenType, account, device).toCompletableFuture().join();\n    pushNotificationScheduler.cancelBackgroundNotifications(tokenType, account, device).join();\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getLastBackgroundApnsNotificationTimestamp(account, device));\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(tokenType, account, device));\n  }\n\n  @ParameterizedTest\n  @EnumSource(PushNotification.TokenType.class)\n  void testProcessScheduledBackgroundNotifications(PushNotification.TokenType tokenType) {\n    final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1);\n\n    final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n\n    clock.pin(Instant.ofEpochMilli(now.toEpochMilli()));\n    pushNotificationScheduler.scheduleBackgroundNotification(tokenType, account, device).toCompletableFuture().join();\n\n    final int slot =\n        SlotHash.getSlot(PushNotificationScheduler.getPendingBackgroundNotificationQueueKey(tokenType, account, device));\n\n    clock.pin(Instant.ofEpochMilli(now.minusMillis(1).toEpochMilli()));\n    assertEquals(0, worker.processScheduledBackgroundNotifications(tokenType, slot));\n\n    clock.pin(now);\n    assertEquals(1, worker.processScheduledBackgroundNotifications(tokenType, slot));\n\n    final ArgumentCaptor<PushNotification> notificationCaptor = ArgumentCaptor.forClass(PushNotification.class);\n    verify(switch (tokenType) {\n      case FCM -> fcmSender;\n      case APN -> apnSender;\n    }).sendNotification(notificationCaptor.capture());\n\n    final PushNotification pushNotification = notificationCaptor.getValue();\n\n    assertEquals(tokenType, pushNotification.tokenType());\n    assertEquals(switch (tokenType) {\n      case FCM -> GCM_ID;\n      case APN -> APN_ID;\n    }, pushNotification.deviceToken());\n    assertEquals(account, pushNotification.destination());\n    assertEquals(device, pushNotification.destinationDevice());\n    assertEquals(PushNotification.NotificationType.NOTIFICATION, pushNotification.notificationType());\n    assertFalse(pushNotification.urgent());\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getNextScheduledBackgroundNotificationTimestamp(tokenType, account, device));\n  }\n\n  @ParameterizedTest\n  @EnumSource(PushNotification.TokenType.class)\n  void testProcessScheduledBackgroundNotificationsCancelled(PushNotification.TokenType tokenType) throws ExecutionException, InterruptedException {\n    final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1);\n\n    final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS);\n\n    clock.pin(now);\n    pushNotificationScheduler.scheduleBackgroundNotification(tokenType, account, device).toCompletableFuture().get();\n    pushNotificationScheduler.cancelScheduledNotifications(account, device).toCompletableFuture().get();\n\n    final int slot =\n        SlotHash.getSlot(PushNotificationScheduler.getPendingBackgroundNotificationQueueKey(tokenType, account, device));\n\n    assertEquals(0, worker.processScheduledBackgroundNotifications(tokenType, slot));\n\n    verify(apnSender, never()).sendNotification(any());\n  }\n\n  @Test\n  void testScheduleDelayedNotification() {\n    clock.pin(Instant.now());\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device));\n\n    pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(1)).join();\n\n    assertEquals(Optional.of(clock.instant().truncatedTo(ChronoUnit.MILLIS).plus(Duration.ofMinutes(1))),\n        pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device));\n\n    pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(2)).join();\n\n    assertEquals(Optional.of(clock.instant().truncatedTo(ChronoUnit.MILLIS).plus(Duration.ofMinutes(2))),\n        pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device));\n  }\n\n  @Test\n  void testCancelDelayedNotification() {\n    pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(1)).join();\n    pushNotificationScheduler.cancelDelayedNotifications(account, device).join();\n\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device));\n  }\n\n  @Test\n  void testProcessScheduledDelayedNotifications() {\n    final PushNotificationScheduler.NotificationWorker worker = pushNotificationScheduler.new NotificationWorker(1);\n    final int slot = SlotHash.getSlot(PushNotificationScheduler.getDelayedNotificationQueueKey(account, device));\n\n    clock.pin(Instant.now());\n\n    pushNotificationScheduler.scheduleDelayedNotification(account, device, Duration.ofMinutes(1)).join();\n\n    assertEquals(0, worker.processScheduledDelayedNotifications(slot));\n\n    clock.pin(clock.instant().plus(Duration.ofMinutes(1)));\n\n    assertEquals(1, worker.processScheduledDelayedNotifications(slot));\n    assertEquals(Optional.empty(),\n        pushNotificationScheduler.getNextScheduledDelayedNotificationTimestamp(account, device));\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"1, true\",\n      \"0, false\",\n  })\n  void testDedicatedProcessDynamicConfiguration(final int dedicatedThreadCount, final boolean expectActivity)\n      throws Exception {\n\n    final FaultTolerantRedisClusterClient redisCluster = mock(FaultTolerantRedisClusterClient.class);\n    when(redisCluster.withCluster(any())).thenReturn(0L);\n\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n\n    pushNotificationScheduler = new PushNotificationScheduler(redisCluster, apnSender, fcmSender,\n        accountsManager, dedicatedThreadCount, 1, mock(ScheduledExecutorService.class));\n\n    pushNotificationScheduler.start();\n    pushNotificationScheduler.stop();\n\n    if (expectActivity) {\n      verify(redisCluster, atLeastOnce()).withCluster(any());\n    } else {\n      verifyNoInteractions(redisCluster);\n      verifyNoInteractions(accountsManager);\n      verifyNoInteractions(apnSender);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/push/RedisMessageAvailabilityManagerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.push;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.cluster.SlotHash;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.lettuce.core.cluster.pubsub.api.async.RedisClusterPubSubAsyncCommands;\nimport io.lettuce.core.cluster.pubsub.api.sync.RedisClusterPubSubCommands;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.AfterAll;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.tests.util.MockRedisFuture;\nimport org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;\n\n@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass RedisMessageAvailabilityManagerTest {\n\n  private RedisMessageAvailabilityManager localEventManager;\n  private RedisMessageAvailabilityManager remoteEventManager;\n\n  private static ExecutorService webSocketConnectionEventExecutor;\n  private static ExecutorService asyncOperationQueueingExecutor;\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private static class MessageAvailabilityAdapter implements MessageAvailabilityListener {\n\n    @Override\n    public void handleNewMessageAvailable() {\n    }\n\n    @Override\n    public void handleMessagesPersisted() {\n    }\n\n    @Override\n    public void handleConflictingMessageConsumer() {\n    }\n  }\n\n  @BeforeAll\n  static void setUpBeforeAll() {\n    webSocketConnectionEventExecutor = Executors.newVirtualThreadPerTaskExecutor();\n    asyncOperationQueueingExecutor = Executors.newSingleThreadExecutor();\n  }\n\n  @BeforeEach\n  void setUp() {\n    localEventManager = new RedisMessageAvailabilityManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        webSocketConnectionEventExecutor,\n        asyncOperationQueueingExecutor);\n\n    remoteEventManager = new RedisMessageAvailabilityManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        webSocketConnectionEventExecutor,\n        asyncOperationQueueingExecutor);\n\n    localEventManager.start();\n    remoteEventManager.start();\n  }\n\n  @AfterEach\n  void tearDown() {\n    localEventManager.stop();\n    remoteEventManager.stop();\n  }\n\n  @AfterAll\n  static void tearDownAfterAll() {\n    webSocketConnectionEventExecutor.shutdown();\n    asyncOperationQueueingExecutor.shutdown();\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void handleClientConnected(final boolean displaceRemotely) throws InterruptedException {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final AtomicBoolean firstListenerDisplaced = new AtomicBoolean(false);\n\n    final AtomicBoolean secondListenerDisplaced = new AtomicBoolean(false);\n\n    localEventManager.handleClientConnected(accountIdentifier, deviceId, new MessageAvailabilityAdapter() {\n      @Override\n      public void handleConflictingMessageConsumer() {\n        synchronized (firstListenerDisplaced) {\n          firstListenerDisplaced.set(true);\n          firstListenerDisplaced.notifyAll();\n        }\n      }\n    }).toCompletableFuture().join();\n\n    assertFalse(firstListenerDisplaced.get());\n    assertFalse(secondListenerDisplaced.get());\n\n    final RedisMessageAvailabilityManager displacingManager =\n        displaceRemotely ? remoteEventManager : localEventManager;\n\n    displacingManager.handleClientConnected(accountIdentifier, deviceId, new MessageAvailabilityAdapter() {\n      @Override\n      public void handleConflictingMessageConsumer() {\n        secondListenerDisplaced.set(true);\n      }\n    }).toCompletableFuture().join();\n\n    synchronized (firstListenerDisplaced) {\n      while (!firstListenerDisplaced.get()) {\n        firstListenerDisplaced.wait();\n      }\n    }\n\n    assertTrue(firstListenerDisplaced.get());\n    assertFalse(secondListenerDisplaced.get());\n  }\n\n  @Test\n  void isLocallyPresent() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    assertFalse(localEventManager.isLocallyPresent(accountIdentifier, deviceId));\n    assertFalse(remoteEventManager.isLocallyPresent(accountIdentifier, deviceId));\n\n    localEventManager.handleClientConnected(accountIdentifier, deviceId, new MessageAvailabilityAdapter())\n        .toCompletableFuture()\n        .join();\n\n    assertTrue(localEventManager.isLocallyPresent(accountIdentifier, deviceId));\n    assertFalse(remoteEventManager.isLocallyPresent(accountIdentifier, deviceId));\n\n    localEventManager.handleClientDisconnected(accountIdentifier, deviceId)\n        .toCompletableFuture()\n        .join();\n\n    assertFalse(localEventManager.isLocallyPresent(accountIdentifier, deviceId));\n    assertFalse(remoteEventManager.isLocallyPresent(accountIdentifier, deviceId));\n  }\n\n  @Test\n  void resubscribe() {\n    @SuppressWarnings(\"unchecked\") final RedisClusterPubSubCommands<byte[], byte[]> pubSubCommands =\n        mock(RedisClusterPubSubCommands.class);\n\n    @SuppressWarnings(\"unchecked\") final RedisClusterPubSubAsyncCommands<byte[], byte[]> pubSubAsyncCommands =\n        mock(RedisClusterPubSubAsyncCommands.class);\n\n    when(pubSubAsyncCommands.ssubscribe(any())).thenReturn(MockRedisFuture.completedFuture(null));\n\n    final FaultTolerantRedisClusterClient clusterClient = RedisClusterHelper.builder()\n        .binaryPubSubCommands(pubSubCommands)\n        .binaryPubSubAsyncCommands(pubSubAsyncCommands)\n        .build();\n\n    final RedisMessageAvailabilityManager eventManager = new RedisMessageAvailabilityManager(\n        clusterClient,\n        Runnable::run,\n        Runnable::run);\n\n    eventManager.start();\n\n    final UUID firstAccountIdentifier = UUID.randomUUID();\n    final byte firstDeviceId = Device.PRIMARY_ID;\n    final int firstSlot = SlotHash.getSlot(RedisMessageAvailabilityManager.getClientEventChannel(firstAccountIdentifier, firstDeviceId));\n\n    final UUID secondAccountIdentifier;\n    final byte secondDeviceId = firstDeviceId + 1;\n\n    // Make sure that the two subscriptions wind up in different slots\n    {\n      UUID candidateIdentifier;\n\n      do {\n        candidateIdentifier = UUID.randomUUID();\n      } while (SlotHash.getSlot(RedisMessageAvailabilityManager.getClientEventChannel(candidateIdentifier, secondDeviceId)) == firstSlot);\n\n      secondAccountIdentifier = candidateIdentifier;\n    }\n\n    eventManager.handleClientConnected(firstAccountIdentifier, firstDeviceId, new MessageAvailabilityAdapter()).toCompletableFuture().join();\n    eventManager.handleClientConnected(secondAccountIdentifier, secondDeviceId, new MessageAvailabilityAdapter()).toCompletableFuture().join();\n\n    final int secondSlot = SlotHash.getSlot(RedisMessageAvailabilityManager.getClientEventChannel(secondAccountIdentifier, secondDeviceId));\n\n    final String firstNodeId = UUID.randomUUID().toString();\n\n    final RedisClusterNode firstBeforeNode = mock(RedisClusterNode.class);\n    when(firstBeforeNode.getNodeId()).thenReturn(firstNodeId);\n    when(firstBeforeNode.getSlots()).thenReturn(IntStream.range(0, SlotHash.SLOT_COUNT).boxed().toList());\n\n    final RedisClusterNode firstAfterNode = mock(RedisClusterNode.class);\n    when(firstAfterNode.getNodeId()).thenReturn(firstNodeId);\n    when(firstAfterNode.getSlots()).thenReturn(IntStream.range(0, SlotHash.SLOT_COUNT)\n        .filter(slot -> slot != secondSlot)\n        .boxed()\n        .toList());\n\n    final RedisClusterNode secondAfterNode = mock(RedisClusterNode.class);\n    when(secondAfterNode.getNodeId()).thenReturn(UUID.randomUUID().toString());\n    when(secondAfterNode.getSlots()).thenReturn(List.of(secondSlot));\n\n    eventManager.resubscribe(new ClusterTopologyChangedEvent(\n        List.of(firstBeforeNode),\n        List.of(firstAfterNode, secondAfterNode)));\n\n    verify(pubSubCommands).ssubscribe(RedisMessageAvailabilityManager.getClientEventChannel(secondAccountIdentifier, secondDeviceId));\n    verify(pubSubCommands, never()).ssubscribe(RedisMessageAvailabilityManager.getClientEventChannel(firstAccountIdentifier, firstDeviceId));\n  }\n\n  @Test\n  void unsubscribeIfMissingListener() {\n    @SuppressWarnings(\"unchecked\") final RedisClusterPubSubAsyncCommands<byte[], byte[]> pubSubAsyncCommands =\n        mock(RedisClusterPubSubAsyncCommands.class);\n\n    when(pubSubAsyncCommands.ssubscribe(any())).thenReturn(MockRedisFuture.completedFuture(null));\n\n    final FaultTolerantRedisClusterClient clusterClient = RedisClusterHelper.builder()\n        .binaryPubSubAsyncCommands(pubSubAsyncCommands)\n        .build();\n\n    final RedisMessageAvailabilityManager eventManager = new RedisMessageAvailabilityManager(\n        clusterClient,\n        Runnable::run,\n        Runnable::run);\n\n    eventManager.start();\n\n    final UUID listenerAccountIdentifier = UUID.randomUUID();\n    final byte listenerDeviceId = Device.PRIMARY_ID;\n\n    final UUID noListenerAccountIdentifier = UUID.randomUUID();\n    final byte noListenerDeviceId = listenerDeviceId + 1;\n\n    eventManager.handleClientConnected(listenerAccountIdentifier, listenerDeviceId, new MessageAvailabilityAdapter())\n        .toCompletableFuture()\n        .join();\n\n    eventManager.unsubscribeIfMissingListener(\n        new RedisMessageAvailabilityManager.AccountAndDeviceIdentifier(listenerAccountIdentifier, listenerDeviceId));\n\n    eventManager.unsubscribeIfMissingListener(\n        new RedisMessageAvailabilityManager.AccountAndDeviceIdentifier(noListenerAccountIdentifier, noListenerDeviceId));\n\n    verify(pubSubAsyncCommands, never())\n        .sunsubscribe(RedisMessageAvailabilityManager.getClientEventChannel(listenerAccountIdentifier, listenerDeviceId));\n\n    verify(pubSubAsyncCommands)\n        .sunsubscribe(RedisMessageAvailabilityManager.getClientEventChannel(noListenerAccountIdentifier, noListenerDeviceId));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/ClusterLuaScriptTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.FlushMode;\nimport io.lettuce.core.RedisFuture;\nimport io.lettuce.core.RedisNoScriptException;\nimport io.lettuce.core.ScriptOutputType;\nimport io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;\nimport io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport io.lettuce.core.protocol.AsyncCommand;\nimport io.lettuce.core.protocol.RedisCommand;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;\nimport reactor.core.publisher.Flux;\n\nclass ClusterLuaScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @Test\n  void testExecute() {\n    final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);\n    final FaultTolerantRedisClusterClient mockCluster = RedisClusterHelper.builder().stringCommands(commands).build();\n\n    final String script = \"return redis.call(\\\"SET\\\", KEYS[1], ARGV[1])\";\n    final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;\n    final List<String> keys = List.of(\"key\");\n    final List<String> values = List.of(\"value\");\n\n    when(commands.evalsha(any(), any(), any(), any())).thenReturn(\"OK\");\n\n    final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);\n    luaScript.execute(keys, values);\n\n    verify(commands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new String[0]), values.toArray(new String[0]));\n    verify(commands, never()).eval(anyString(), any(), any(), any());\n  }\n\n  @Test\n  void testExecuteScriptNotLoaded() {\n    final RedisAdvancedClusterCommands<String, String> commands = mock(RedisAdvancedClusterCommands.class);\n    final FaultTolerantRedisClusterClient mockCluster = RedisClusterHelper.builder().stringCommands(commands).build();\n\n    final String script = \"return redis.call(\\\"SET\\\", KEYS[1], ARGV[1])\";\n    final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;\n    final List<String> keys = List.of(\"key\");\n    final List<String> values = List.of(\"value\");\n\n    when(commands.evalsha(any(), any(), any(), any())).thenThrow(new RedisNoScriptException(\"OH NO\"));\n\n    final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);\n    luaScript.execute(keys, values);\n\n    verify(commands).eval(script, scriptOutputType, keys.toArray(new String[0]), values.toArray(new String[0]));\n    verify(commands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new String[0]), values.toArray(new String[0]));\n  }\n\n  @Test\n  void testExecuteBinaryScriptNotLoaded() {\n    final RedisAdvancedClusterCommands<String, String> stringCommands = mock(RedisAdvancedClusterCommands.class);\n    final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands = mock(RedisAdvancedClusterCommands.class);\n    final FaultTolerantRedisClusterClient mockCluster = RedisClusterHelper.builder()\n        .stringCommands(stringCommands)\n        .binaryCommands(binaryCommands)\n        .build();\n\n    final String script = \"return redis.call(\\\"SET\\\", KEYS[1], ARGV[1])\";\n    final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;\n    final List<byte[]> keys = List.of(\"key\".getBytes(StandardCharsets.UTF_8));\n    final List<byte[]> values = List.of(\"value\".getBytes(StandardCharsets.UTF_8));\n\n    when(binaryCommands.evalsha(any(), any(), any(), any())).thenThrow(new RedisNoScriptException(\"OH NO\"));\n\n    final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);\n    luaScript.executeBinary(keys, values);\n\n    verify(binaryCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]), values.toArray(new byte[0][]));\n    verify(binaryCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]),\n        values.toArray(new byte[0][]));\n  }\n\n  @Test\n  void testExecuteBinaryAsyncScriptNotLoaded() throws Exception {\n    final RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands =\n        mock(RedisAdvancedClusterAsyncCommands.class);\n    final FaultTolerantRedisClusterClient mockCluster =\n        RedisClusterHelper.builder().binaryAsyncCommands(binaryAsyncCommands).build();\n\n    final String script = \"return redis.call(\\\"SET\\\", KEYS[1], ARGV[1])\";\n    final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;\n    final List<byte[]> keys = List.of(\"key\".getBytes(StandardCharsets.UTF_8));\n    final List<byte[]> values = List.of(\"value\".getBytes(StandardCharsets.UTF_8));\n\n    final AsyncCommand<?, ?, ?> evalShaFailure = new AsyncCommand<>(mock(RedisCommand.class));\n    evalShaFailure.completeExceptionally(new RedisNoScriptException(\"OH NO\"));\n\n    final AsyncCommand<?, ?, ?> evalSuccess = new AsyncCommand<>(mock(RedisCommand.class));\n    evalSuccess.complete();\n\n    when(binaryAsyncCommands.evalsha(any(), any(), any(), any())).thenReturn((RedisFuture<Object>) evalShaFailure);\n    when(binaryAsyncCommands.eval(anyString(), any(), any(), any())).thenReturn((RedisFuture<Object>) evalSuccess);\n\n    final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);\n    luaScript.executeBinaryAsync(keys, values).get(5, TimeUnit.SECONDS);\n\n    verify(binaryAsyncCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]),\n        values.toArray(new byte[0][]));\n    verify(binaryAsyncCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]),\n        values.toArray(new byte[0][]));\n  }\n\n  @Test\n  void testExecuteBinaryReactiveScriptNotLoaded() {\n    final RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands =\n        mock(RedisAdvancedClusterReactiveCommands.class);\n    final FaultTolerantRedisClusterClient mockCluster = RedisClusterHelper.builder()\n        .binaryReactiveCommands(binaryReactiveCommands).build();\n\n    final String script = \"return redis.call(\\\"SET\\\", KEYS[1], ARGV[1])\";\n    final ScriptOutputType scriptOutputType = ScriptOutputType.VALUE;\n    final List<byte[]> keys = List.of(\"key\".getBytes(StandardCharsets.UTF_8));\n    final List<byte[]> values = List.of(\"value\".getBytes(StandardCharsets.UTF_8));\n\n    when(binaryReactiveCommands.evalsha(any(), any(), any(), any()))\n        .thenReturn(Flux.error(new RedisNoScriptException(\"OH NO\")));\n    when(binaryReactiveCommands.eval(anyString(), any(), any(), any())).thenReturn(Flux.just(\"ok\"));\n\n    final ClusterLuaScript luaScript = new ClusterLuaScript(mockCluster, script, scriptOutputType);\n    luaScript.executeBinaryReactive(keys, values).blockLast(Duration.ofSeconds(5));\n\n    verify(binaryReactiveCommands).eval(script, scriptOutputType, keys.toArray(new byte[0][]),\n        values.toArray(new byte[0][]));\n    verify(binaryReactiveCommands).evalsha(luaScript.getSha(), scriptOutputType, keys.toArray(new byte[0][]),\n        values.toArray(new byte[0][]));\n  }\n\n  @ParameterizedTest\n  @EnumSource(ExecuteMode.class)\n  void testExecuteRealCluster(final ExecuteMode mode) throws Exception {\n    REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(c -> c.sync().scriptFlush(FlushMode.SYNC));\n    REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(c -> c.sync().configResetstat());\n\n    final ClusterLuaScript script = new ClusterLuaScript(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        \"return 2;\",\n        ScriptOutputType.INTEGER);\n\n    for (int i = 0; i < 7; i++) {\n      final long actual = switch (mode) {\n        case SYNC -> (long) script.execute(Collections.emptyList(), Collections.emptyList());\n        case ASYNC ->\n            (long) script.executeAsync(Collections.emptyList(), Collections.emptyList()).get(5, TimeUnit.SECONDS);\n        case REACTIVE -> (long) script.executeReactive(Collections.emptyList(), Collections.emptyList())\n            .blockLast(Duration.ofSeconds(5));\n      };\n\n      assertEquals(2L, actual);\n    }\n\n    final int evalCount = REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> {\n      final String commandStats = connection.sync().info(\"commandstats\");\n\n      // We're looking for (and parsing) a line in the command stats that looks like:\n      //\n      // ```\n      // cmdstat_eval:calls=1,usec=44,usec_per_call=44.00\n      // ```\n      return Arrays.stream(commandStats.split(\"\\\\n\"))\n          .filter(line -> line.startsWith(\"cmdstat_eval:\"))\n          .map(String::trim)\n          .map(evalLine -> Arrays.stream(evalLine.substring(evalLine.indexOf(':') + 1).split(\",\"))\n              .filter(pair -> pair.startsWith(\"calls=\"))\n              .map(callsPair -> Integer.parseInt(callsPair.substring(callsPair.indexOf('=') + 1)))\n              .findFirst()\n              .orElse(0))\n          .findFirst()\n          .orElse(0);\n    });\n\n    assertEquals(1, evalCount);\n  }\n\n  private enum ExecuteMode {\n    SYNC,\n    ASYNC,\n    REACTIVE\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantPubSubClusterConnectionTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.atLeastOnce;\nimport static org.mockito.Mockito.clearInvocations;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.github.resilience4j.core.IntervalFunction;\nimport io.github.resilience4j.retry.Retry;\nimport io.github.resilience4j.retry.RetryConfig;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.models.partitions.Partitions;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;\nimport io.lettuce.core.cluster.pubsub.api.sync.RedisClusterPubSubCommands;\nimport io.lettuce.core.event.Event;\nimport io.lettuce.core.event.EventBus;\nimport io.lettuce.core.resource.ClientResources;\nimport java.util.List;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.function.Consumer;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.configuration.RetryConfiguration;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.test.publisher.TestPublisher;\n\nclass FaultTolerantPubSubClusterConnectionTest {\n\n  private StatefulRedisClusterPubSubConnection<String, String> pubSubConnection;\n  private RedisClusterPubSubCommands<String, String> pubSubCommands;\n  private FaultTolerantPubSubClusterConnection<String, String> faultTolerantPubSubConnection;\n\n  private TestPublisher<Event> eventPublisher;\n\n  private Consumer<ClusterTopologyChangedEvent> resubscribe;\n\n  private AtomicInteger resubscribeCounter;\n  private CountDownLatch resubscribeFailure;\n  private CountDownLatch resubscribeSuccess;\n\n  private RedisClusterNode nodeInCluster;\n\n  @SuppressWarnings(\"unchecked\")\n  @BeforeEach\n  public void setUp() {\n    pubSubConnection = mock(StatefulRedisClusterPubSubConnection.class);\n    pubSubCommands = mock(RedisClusterPubSubCommands.class);\n    nodeInCluster = mock(RedisClusterNode.class);\n\n    final ClientResources clientResources = mock(ClientResources.class);\n\n    final Partitions partitions = new Partitions();\n    partitions.add(nodeInCluster);\n\n    when(pubSubConnection.sync()).thenReturn(pubSubCommands);\n    when(pubSubConnection.getResources()).thenReturn(clientResources);\n    when(pubSubConnection.getPartitions()).thenReturn(partitions);\n\n    final RetryConfiguration retryConfiguration = new RetryConfiguration();\n    retryConfiguration.setMaxAttempts(3);\n    retryConfiguration.setWaitDuration(10);\n\n    final RetryConfig resubscribeRetryConfiguration = RetryConfig.custom()\n        .maxAttempts(Integer.MAX_VALUE)\n        .intervalFunction(IntervalFunction.ofExponentialBackoff(5))\n        .build();\n    final Retry resubscribeRetry = Retry.of(\"test-resubscribe\", resubscribeRetryConfiguration);\n\n    faultTolerantPubSubConnection = new FaultTolerantPubSubClusterConnection<>(\"test\", pubSubConnection,\n        resubscribeRetry, Schedulers.newSingle(\"test\"));\n\n    eventPublisher = TestPublisher.createCold();\n\n    final EventBus eventBus = mock(EventBus.class);\n    when(clientResources.eventBus()).thenReturn(eventBus);\n\n    final Flux<Event> eventFlux = Flux.from(eventPublisher);\n    when(eventBus.get()).thenReturn(eventFlux);\n\n    resubscribeCounter = new AtomicInteger();\n\n    resubscribe = event -> {\n      try {\n        resubscribeCounter.incrementAndGet();\n        pubSubConnection.sync().nodes((ignored) -> true);\n        resubscribeSuccess.countDown();\n      } catch (final RuntimeException e) {\n        resubscribeFailure.countDown();\n        throw e;\n      }\n    };\n\n    resubscribeSuccess = new CountDownLatch(1);\n    resubscribeFailure = new CountDownLatch(1);\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  @Test\n  void testSubscribeToClusterTopologyChangedEvents() throws Exception {\n\n    when(pubSubConnection.sync())\n        .thenThrow(new RedisException(\"Cluster unavailable\"));\n\n    eventPublisher.next(new ClusterTopologyChangedEvent(List.of(nodeInCluster), List.of(nodeInCluster)));\n\n    faultTolerantPubSubConnection.subscribeToClusterTopologyChangedEvents(resubscribe);\n\n    assertTrue(resubscribeFailure.await(1, TimeUnit.SECONDS));\n\n    // simulate cluster recovery - no more exceptions, run the retry\n    reset(pubSubConnection);\n    clearInvocations(pubSubCommands);\n    when(pubSubConnection.sync())\n        .thenReturn(pubSubCommands);\n\n    assertTrue(resubscribeSuccess.await(1, TimeUnit.SECONDS));\n\n    assertTrue(resubscribeCounter.get() >= 2, String.format(\"resubscribe called %d times\", resubscribeCounter.get()));\n    verify(pubSubCommands).nodes(any());\n  }\n\n  @Test\n  void testFilterClusterTopologyChangeEvents() throws InterruptedException {\n    final CountDownLatch topologyEventLatch = new CountDownLatch(1);\n\n    faultTolerantPubSubConnection.subscribeToClusterTopologyChangedEvents(event -> topologyEventLatch.countDown());\n\n    final RedisClusterNode nodeFromDifferentCluster = mock(RedisClusterNode.class);\n\n    eventPublisher.next(new ClusterTopologyChangedEvent(List.of(nodeFromDifferentCluster), List.of(nodeFromDifferentCluster)));\n\n    assertFalse(topologyEventLatch.await(1, TimeUnit.SECONDS));\n  }\n\n  @Test\n  @SuppressWarnings(\"unchecked\")\n  void testMultipleEventsWithPendingRetries() throws Exception {\n    // more complicated scenario: multiple events while retries are pending\n\n    // cluster is down\n    when(pubSubConnection.sync())\n        .thenThrow(new RedisException(\"Cluster unavailable\"));\n\n    // publish multiple topology changed events\n    final ClusterTopologyChangedEvent clusterTopologyChangedEvent =\n        new ClusterTopologyChangedEvent(List.of(nodeInCluster), List.of(nodeInCluster));\n\n    eventPublisher.next(clusterTopologyChangedEvent);\n    eventPublisher.next(clusterTopologyChangedEvent);\n    eventPublisher.next(clusterTopologyChangedEvent);\n    eventPublisher.next(clusterTopologyChangedEvent);\n\n    faultTolerantPubSubConnection.subscribeToClusterTopologyChangedEvents(resubscribe);\n\n    assertTrue(resubscribeFailure.await(1, TimeUnit.SECONDS));\n\n    // simulate cluster recovery - no more exceptions, run the retry\n    reset(pubSubConnection);\n    clearInvocations(pubSubCommands);\n    when(pubSubConnection.sync())\n        .thenReturn(pubSubCommands);\n\n    assertTrue(resubscribeSuccess.await(1, TimeUnit.SECONDS));\n\n    verify(pubSubCommands, atLeastOnce()).nodes(any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClientTest.java",
    "content": "package org.whispersystems.textsecuregcm.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport io.github.resilience4j.circuitbreaker.CallNotPermittedException;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.resource.ClientResources;\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.concurrent.ExecutionException;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\n\n// ThreadMode.SEPARATE_THREAD protects against hangs in the remote Redis calls, as this mode allows the test code to be\n// preempted by the timeout check\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass FaultTolerantRedisClientTest {\n\n  private static final Duration TIMEOUT = Duration.ofMillis(50);\n\n  @RegisterExtension\n  static final RedisServerExtension REDIS_SERVER_EXTENSION = RedisServerExtension.builder().build();\n\n  private FaultTolerantRedisClient faultTolerantRedisClient;\n\n  private static FaultTolerantRedisClient buildRedisClient(\n      @Nullable final CircuitBreakerConfiguration circuitBreakerConfiguration,\n      final ClientResources.Builder clientResourcesBuilder) {\n\n    final CircuitBreaker circuitBreaker = CircuitBreaker.of(\"test\", Optional.ofNullable(circuitBreakerConfiguration)\n            .orElseGet(CircuitBreakerConfiguration::new).toCircuitBreakerConfig());\n\n    return new FaultTolerantRedisClient(\"test\",\n        clientResourcesBuilder,\n        RedisServerExtension.getRedisURI(),\n        TIMEOUT,\n        circuitBreaker);\n  }\n\n  @AfterEach\n  void tearDown() {\n    faultTolerantRedisClient.shutdown();\n  }\n\n  @Test\n  void testTimeout() {\n    faultTolerantRedisClient = buildRedisClient(null, ClientResources.builder());\n\n    final ExecutionException asyncException = assertThrows(ExecutionException.class,\n        () -> faultTolerantRedisClient.withConnection(connection -> connection.async().blpop(10 * TIMEOUT.toMillis() / 1000d, \"key\"))\n            .get());\n\n    assertInstanceOf(RedisCommandTimeoutException.class, asyncException.getCause());\n\n    assertThrows(RedisCommandTimeoutException.class,\n        () -> faultTolerantRedisClient.withConnection(connection -> connection.sync().blpop(10 * TIMEOUT.toMillis() / 1000d, \"key\")));\n  }\n\n  @Test\n  void testTimeoutCircuitBreaker() throws Exception {\n    // because we’re using a single key, and blpop involves *Redis* also blocking, the breaker wait duration must be\n    // longer than the sum of the remote timeouts\n    final Duration breakerWaitDuration = TIMEOUT.multipliedBy(5);\n\n    final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();\n    circuitBreakerConfig.setFailureRateThreshold(1);\n    circuitBreakerConfig.setSlidingWindowMinimumNumberOfCalls(1);\n    circuitBreakerConfig.setSlidingWindowSize(1);\n    circuitBreakerConfig.setWaitDurationInOpenState(breakerWaitDuration);\n\n    faultTolerantRedisClient = buildRedisClient(circuitBreakerConfig, ClientResources.builder());\n\n    final String key = \"key\";\n\n    // the first call should time out and open the breaker\n    assertThrows(RedisCommandTimeoutException.class,\n        () -> faultTolerantRedisClient.withConnection(connection -> connection.sync().blpop(10 * TIMEOUT.toMillis() / 1000d, key)));\n\n    // the second call gets blocked by the breaker\n    final RedisException e = assertThrows(RedisException.class,\n        () -> faultTolerantRedisClient.withConnection(connection -> connection.sync().blpop(10 * TIMEOUT.toMillis() / 1000d, key)));\n    assertInstanceOf(CallNotPermittedException.class, e.getCause());\n\n    // wait for breaker to be half-open\n    Thread.sleep(breakerWaitDuration.toMillis() * 2);\n\n    assertEquals(0, (Long) faultTolerantRedisClient.withConnection(connection -> connection.sync().llen(key)));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/FaultTolerantRedisClusterClientTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport io.github.resilience4j.circuitbreaker.CallNotPermittedException;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.RedisURI;\nimport io.lettuce.core.cluster.models.partitions.ClusterPartitionParser;\nimport io.lettuce.core.cluster.models.partitions.Partitions;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.lettuce.core.cluster.pubsub.RedisClusterPubSubAdapter;\nimport io.lettuce.core.event.EventBus;\nimport io.lettuce.core.event.EventPublisherOptions;\nimport io.lettuce.core.metrics.CommandLatencyCollectorOptions;\nimport io.lettuce.core.metrics.CommandLatencyRecorder;\nimport io.lettuce.core.resource.ClientResources;\nimport io.lettuce.core.resource.Delay;\nimport io.lettuce.core.resource.DnsResolver;\nimport io.lettuce.core.resource.EventLoopGroupProvider;\nimport io.lettuce.core.resource.NettyCustomizer;\nimport io.lettuce.core.resource.SocketAddressResolver;\nimport io.lettuce.core.resource.ThreadFactoryProvider;\nimport io.lettuce.core.tracing.Tracing;\nimport io.netty.bootstrap.Bootstrap;\nimport io.netty.channel.Channel;\nimport io.netty.channel.ChannelDuplexHandler;\nimport io.netty.channel.ChannelHandler;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.channel.ChannelPromise;\nimport io.netty.resolver.AddressResolverGroup;\nimport io.netty.util.Timer;\nimport io.netty.util.concurrent.EventExecutorGroup;\nimport java.net.InetSocketAddress;\nimport java.net.SocketAddress;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Supplier;\nimport javax.annotation.Nullable;\nimport org.apache.commons.lang3.StringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.RedisClusterUtil;\n\n// ThreadMode.SEPARATE_THREAD protects against hangs in the remote Redis calls, as this mode allows the test code to be\n// preempted by the timeout check\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass FaultTolerantRedisClusterClientTest {\n\n  private static final Duration TIMEOUT = Duration.ofMillis(200);\n\n  private static int circuitBreakerConfigurationCount = 0;\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder()\n      .timeout(TIMEOUT)\n      .build();\n\n  private FaultTolerantRedisClusterClient cluster;\n\n  private static FaultTolerantRedisClusterClient buildCluster(\n      final String name,\n      @Nullable final CircuitBreakerConfiguration circuitBreakerConfiguration,\n      final ClientResources.Builder clientResourcesBuilder) {\n\n    final String circuitBreakerConfigurationName;\n\n    if (circuitBreakerConfiguration != null) {\n      circuitBreakerConfigurationName = FaultTolerantRedisClusterClientTest.class.getSimpleName() + \"-\" + circuitBreakerConfigurationCount++;\n      ResilienceUtil.getCircuitBreakerRegistry().addConfiguration(circuitBreakerConfigurationName, circuitBreakerConfiguration.toCircuitBreakerConfig());\n    } else {\n      circuitBreakerConfigurationName = null;\n    }\n\n    return new FaultTolerantRedisClusterClient(name,\n        clientResourcesBuilder.socketAddressResolver(REDIS_CLUSTER_EXTENSION.getSocketAddressResolver()),\n        RedisClusterExtension.getRedisURIs(),\n        TIMEOUT,\n        circuitBreakerConfigurationName);\n  }\n\n  @AfterEach\n  void tearDown() {\n    cluster.shutdown();\n  }\n\n  @Test\n  void testTimeout() {\n    cluster = buildCluster(\"testTimeout\", null, ClientResources.builder());\n\n    final ExecutionException asyncException = assertThrows(ExecutionException.class,\n        () -> cluster.withCluster(connection -> connection.async().blpop(10 * TIMEOUT.toMillis() / 1000d, \"key\"))\n            .get());\n\n    assertInstanceOf(RedisCommandTimeoutException.class, asyncException.getCause());\n\n    assertThrows(RedisCommandTimeoutException.class,\n        () -> cluster.withCluster(connection -> connection.sync().blpop(10 * TIMEOUT.toMillis() / 1000d, \"key\")));\n  }\n\n  @Test\n  void testTimeoutCircuitBreaker() throws Exception {\n    // because we’re using a single key, and blpop involves *Redis* also blocking, the breaker wait duration must be\n    // longer than the sum of the remote timeouts\n    final Duration breakerWaitDuration = TIMEOUT.multipliedBy(5);\n\n    final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();\n    circuitBreakerConfig.setFailureRateThreshold(1);\n    circuitBreakerConfig.setSlidingWindowMinimumNumberOfCalls(1);\n    circuitBreakerConfig.setSlidingWindowSize(1);\n    circuitBreakerConfig.setWaitDurationInOpenState(breakerWaitDuration);\n\n    cluster = buildCluster(\"testTimeoutCircuitBreaker\", circuitBreakerConfig, ClientResources.builder());\n\n    final String key = \"key\";\n\n    // the first call should time out and open the breaker\n    assertThrows(RedisCommandTimeoutException.class,\n        () -> cluster.withCluster(connection -> connection.sync().blpop(2 * TIMEOUT.toMillis() / 1000d, key)));\n\n    // the second call gets blocked by the breaker\n    final RedisException e = assertThrows(RedisException.class,\n        () -> cluster.withCluster(connection -> connection.sync().blpop(2 * TIMEOUT.toMillis() / 1000d, key)));\n    assertInstanceOf(CallNotPermittedException.class, e.getCause());\n\n    // wait for breaker to be half-open\n    Thread.sleep(breakerWaitDuration.toMillis() * 2);\n\n    assertEquals(0, (Long) cluster.withCluster(connection -> connection.sync().llen(key)));\n  }\n\n  @Test\n  void testShardUnavailable() {\n    final TestBreakerManager testBreakerManager = new TestBreakerManager();\n    final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();\n    circuitBreakerConfig.setFailureRateThreshold(1);\n    circuitBreakerConfig.setSlidingWindowMinimumNumberOfCalls(2);\n    circuitBreakerConfig.setSlidingWindowSize(5);\n\n    final ClientResources.Builder builder = CompositeNettyCustomizerClientResourcesBuilder.builder()\n        .nettyCustomizer(testBreakerManager);\n\n    cluster = buildCluster(\"testShardUnavailable\", circuitBreakerConfig, builder);\n\n    // this test will open the breaker on one shard and check that other shards are still available,\n    // so we get two nodes and a slot+key on each to test\n    final Pair<RedisClusterNode, RedisClusterNode> nodePair =\n        cluster.withCluster(connection -> {\n          Partitions partitions = ClusterPartitionParser.parse(connection.sync().clusterNodes());\n\n          assertTrue(partitions.size() >= 2);\n\n          return new Pair<>(partitions.getPartition(0), partitions.getPartition(1));\n        });\n\n    final RedisClusterNode unavailableNode = nodePair.first();\n    final int unavailableSlot = unavailableNode.getSlots().getFirst();\n    final String unavailableKey = \"key::{%s}\".formatted(RedisClusterUtil.getMinimalHashTag(unavailableSlot));\n\n    final int availableSlot = nodePair.second().getSlots().getFirst();\n    final String availableKey = \"key::{%s}\".formatted(RedisClusterUtil.getMinimalHashTag(availableSlot));\n\n    cluster.useCluster(connection -> {\n      connection.sync().set(unavailableKey, \"unavailable\");\n      connection.sync().set(availableKey, \"available\");\n\n      assertEquals(\"unavailable\", connection.sync().get(unavailableKey));\n      assertEquals(\"available\", connection.sync().get(availableKey));\n    });\n\n    // shard is now unavailable\n    testBreakerManager.openBreaker(unavailableNode.getUri());\n    final RedisException e = assertThrows(RedisException.class, () ->\n        cluster.useCluster(connection -> connection.sync().get(unavailableKey)));\n    assertInstanceOf(CallNotPermittedException.class, e.getCause());\n\n    // other shard is still available\n    assertEquals(\"available\", cluster.withCluster(connection -> connection.sync().get(availableKey)));\n\n    // shard is available again\n    testBreakerManager.closeBreaker(unavailableNode.getUri());\n    assertEquals(\"unavailable\", cluster.withCluster(connection -> connection.sync().get(unavailableKey)));\n  }\n\n  @Test\n  void testShardUnavailablePubSub() throws Exception {\n    final TestBreakerManager testBreakerManager = new TestBreakerManager();\n    final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();\n    circuitBreakerConfig.setFailureRateThreshold(1);\n    circuitBreakerConfig.setSlidingWindowMinimumNumberOfCalls(2);\n    circuitBreakerConfig.setSlidingWindowSize(5);\n\n    final ClientResources.Builder builder = CompositeNettyCustomizerClientResourcesBuilder.builder()\n        .nettyCustomizer(testBreakerManager);\n\n    cluster = buildCluster(\"testShardUnavailablePubSub\", circuitBreakerConfig, builder);\n\n    cluster.useCluster(\n        connection -> connection.sync().upstream().commands().configSet(\"notify-keyspace-events\", \"K$glz\"));\n\n    // this test will open the breaker on one shard and check that other shards are still available,\n    // so we get two nodes and a slot+key on each to test\n    final Pair<RedisClusterNode, RedisClusterNode> nodePair =\n        cluster.withCluster(connection -> {\n          Partitions partitions = ClusterPartitionParser.parse(connection.sync().clusterNodes());\n\n          assertTrue(partitions.size() >= 2);\n\n          return new Pair<>(partitions.getPartition(0), partitions.getPartition(1));\n        });\n\n    final RedisClusterNode unavailableNode = nodePair.first();\n    final int unavailableSlot = unavailableNode.getSlots().getFirst();\n    final String unavailableKey = \"key::{%s}\".formatted(RedisClusterUtil.getMinimalHashTag(unavailableSlot));\n\n    final RedisClusterNode availableNode = nodePair.second();\n    final int availableSlot = availableNode.getSlots().getFirst();\n    final String availableKey = \"key::{%s}\".formatted(RedisClusterUtil.getMinimalHashTag(availableSlot));\n\n    final FaultTolerantPubSubClusterConnection<String, String> pubSubConnection = cluster.createPubSubConnection();\n\n    // Keyspace notifications are delivered on a different thread, so we use a CountDownLatch to wait for the\n    // expected number of notifications to arrive\n    final AtomicReference<CountDownLatch> countDownLatchRef = new AtomicReference<>();\n\n    final Map<String, AtomicInteger> channelMessageCounts = new ConcurrentHashMap<>();\n    final String keyspacePrefix = \"__keyspace@0__:\";\n    final RedisClusterPubSubAdapter<String, String> listener = new RedisClusterPubSubAdapter<>() {\n      @Override\n      public void message(final RedisClusterNode node, final String channel, final String message) {\n        channelMessageCounts.computeIfAbsent(StringUtils.substringAfter(channel, keyspacePrefix),\n                k -> new AtomicInteger(0))\n            .incrementAndGet();\n\n        countDownLatchRef.get().countDown();\n      }\n    };\n\n    countDownLatchRef.set(new CountDownLatch(2));\n    pubSubConnection.usePubSubConnection(c -> {\n      c.addListener(listener);\n      c.sync().nodes(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM) && node.hasSlot(availableSlot))\n          .commands()\n          .subscribe(keyspacePrefix + availableKey);\n      c.sync().nodes(node -> node.is(RedisClusterNode.NodeFlag.UPSTREAM) && node.hasSlot(unavailableSlot))\n          .commands()\n          .subscribe(keyspacePrefix + unavailableKey);\n    });\n\n    cluster.useCluster(connection -> {\n      connection.sync().set(availableKey, \"ping1\");\n      connection.sync().set(unavailableKey, \"ping1\");\n    });\n\n    countDownLatchRef.get().await();\n\n    assertEquals(1, channelMessageCounts.get(availableKey).get());\n    assertEquals(1, channelMessageCounts.get(unavailableKey).get());\n\n    // shard is now unavailable\n    testBreakerManager.openBreaker(unavailableNode.getUri());\n\n    final RedisException e = assertThrows(RedisException.class, () ->\n        cluster.useCluster(connection -> connection.sync().set(unavailableKey, \"ping2\")));\n    assertInstanceOf(CallNotPermittedException.class, e.getCause());\n    assertEquals(1, channelMessageCounts.get(unavailableKey).get());\n    assertEquals(1, channelMessageCounts.get(availableKey).get());\n\n    countDownLatchRef.set(new CountDownLatch(1));\n    pubSubConnection.usePubSubConnection(connection -> connection.sync().set(availableKey, \"ping2\"));\n\n    countDownLatchRef.get().await();\n\n    assertEquals(1, channelMessageCounts.get(unavailableKey).get());\n    assertEquals(2, channelMessageCounts.get(availableKey).get());\n\n    // shard is available again\n    testBreakerManager.closeBreaker(unavailableNode.getUri());\n\n    countDownLatchRef.set(new CountDownLatch(2));\n\n    cluster.useCluster(connection -> {\n      connection.sync().set(availableKey, \"ping3\");\n      connection.sync().set(unavailableKey, \"ping3\");\n    });\n\n    countDownLatchRef.get().await();\n\n    assertEquals(2, channelMessageCounts.get(unavailableKey).get());\n    assertEquals(3, channelMessageCounts.get(availableKey).get());\n  }\n\n  @ChannelHandler.Sharable\n  private static class TestBreakerManager extends ChannelDuplexHandler implements NettyCustomizer {\n\n    private final Map<RedisURI, Set<LettuceShardCircuitBreaker.ChannelCircuitBreakerHandler>> urisToChannelBreakers = new ConcurrentHashMap<>();\n    private final AtomicInteger counter = new AtomicInteger();\n\n    @Override\n    public void afterChannelInitialized(Channel channel) {\n      channel.pipeline().addFirst(\"TestBreakerManager#\" + counter.getAndIncrement(), this);\n    }\n\n    @Override\n    public void connect(final ChannelHandlerContext ctx, final SocketAddress remoteAddress,\n        final SocketAddress localAddress, final ChannelPromise promise) throws Exception {\n\n      super.connect(ctx, remoteAddress, localAddress, promise);\n\n      final LettuceShardCircuitBreaker.ChannelCircuitBreakerHandler channelCircuitBreakerHandler =\n          ctx.channel().pipeline().get(LettuceShardCircuitBreaker.ChannelCircuitBreakerHandler.class);\n\n      urisToChannelBreakers.computeIfAbsent(getRedisURI(remoteAddress), ignored -> new HashSet<>())\n          .add(channelCircuitBreakerHandler);\n    }\n\n    private static RedisURI getRedisURI(SocketAddress remoteAddress) {\n      final InetSocketAddress inetAddress = (InetSocketAddress) remoteAddress;\n      return RedisURI.create(inetAddress.getHostString(), inetAddress.getPort());\n    }\n\n    void openBreaker(final RedisURI redisURI) {\n      urisToChannelBreakers.get(REDIS_CLUSTER_EXTENSION.getExposedRedisURI(redisURI))\n          .forEach(handler -> handler.breaker.transitionToOpenState());\n    }\n\n    void closeBreaker(final RedisURI redisURI) {\n      urisToChannelBreakers.get(REDIS_CLUSTER_EXTENSION.getExposedRedisURI(redisURI))\n          .forEach(handler -> handler.breaker.transitionToClosedState());\n    }\n  }\n\n  static class CompositeNettyCustomizer implements NettyCustomizer {\n\n    private final List<NettyCustomizer> nettyCustomizers = new ArrayList<>();\n\n    @Override\n    public void afterBootstrapInitialized(final Bootstrap bootstrap) {\n      nettyCustomizers.forEach(nc -> nc.afterBootstrapInitialized(bootstrap));\n    }\n\n    @Override\n    public void afterChannelInitialized(final Channel channel) {\n      nettyCustomizers.forEach(nc -> nc.afterChannelInitialized(channel));\n    }\n\n    void add(NettyCustomizer customizer) {\n      nettyCustomizers.add(customizer);\n    }\n  }\n\n  static class CompositeNettyCustomizerClientResourcesBuilder implements ClientResources.Builder {\n\n    private final CompositeNettyCustomizer compositeNettyCustomizer;\n    private final ClientResources.Builder delegate;\n\n    static CompositeNettyCustomizerClientResourcesBuilder builder() {\n      return new CompositeNettyCustomizerClientResourcesBuilder();\n    }\n\n    private CompositeNettyCustomizerClientResourcesBuilder() {\n      this.compositeNettyCustomizer = new CompositeNettyCustomizer();\n      this.delegate = ClientResources.builder().nettyCustomizer(compositeNettyCustomizer);\n    }\n\n\n    @Override\n    public ClientResources.Builder addressResolverGroup(final AddressResolverGroup<?> addressResolverGroup) {\n      delegate.addressResolverGroup(addressResolverGroup);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder commandLatencyRecorder(final CommandLatencyRecorder latencyRecorder) {\n      delegate.commandLatencyRecorder(latencyRecorder);\n      return this;\n    }\n\n    @Override\n    @Deprecated\n    public ClientResources.Builder commandLatencyCollectorOptions(\n        final CommandLatencyCollectorOptions commandLatencyCollectorOptions) {\n      delegate.commandLatencyCollectorOptions(commandLatencyCollectorOptions);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder commandLatencyPublisherOptions(\n        final EventPublisherOptions commandLatencyPublisherOptions) {\n      delegate.commandLatencyPublisherOptions(commandLatencyPublisherOptions);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder computationThreadPoolSize(final int computationThreadPoolSize) {\n      delegate.computationThreadPoolSize(computationThreadPoolSize);\n      return this;\n    }\n\n    @Override\n    @Deprecated\n    public ClientResources.Builder dnsResolver(final DnsResolver dnsResolver) {\n      delegate.dnsResolver(dnsResolver);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder eventBus(final EventBus eventBus) {\n      delegate.eventBus(eventBus);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder eventExecutorGroup(final EventExecutorGroup eventExecutorGroup) {\n      delegate.eventExecutorGroup(eventExecutorGroup);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder eventLoopGroupProvider(final EventLoopGroupProvider eventLoopGroupProvider) {\n      delegate.eventLoopGroupProvider(eventLoopGroupProvider);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder ioThreadPoolSize(final int ioThreadPoolSize) {\n      delegate.ioThreadPoolSize(ioThreadPoolSize);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder nettyCustomizer(final NettyCustomizer nettyCustomizer) {\n      compositeNettyCustomizer.add(nettyCustomizer);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder reconnectDelay(final Delay reconnectDelay) {\n      delegate.reconnectDelay(reconnectDelay);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder reconnectDelay(final Supplier<Delay> reconnectDelay) {\n      delegate.reconnectDelay(reconnectDelay);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder socketAddressResolver(final SocketAddressResolver socketAddressResolver) {\n      delegate.socketAddressResolver(socketAddressResolver);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder threadFactoryProvider(final ThreadFactoryProvider threadFactoryProvider) {\n      delegate.threadFactoryProvider(threadFactoryProvider);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder timer(final Timer timer) {\n      delegate.timer(timer);\n      return this;\n    }\n\n    @Override\n    public ClientResources.Builder tracing(final Tracing tracing) {\n      delegate.tracing(tracing);\n      return this;\n    }\n\n    @Override\n    public ClientResources build() {\n      return delegate.build();\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/LettuceShardCircuitBreakerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\n\nimport io.github.resilience4j.circuitbreaker.CallNotPermittedException;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.lettuce.core.ClientOptions;\nimport io.lettuce.core.codec.StringCodec;\nimport io.lettuce.core.output.StatusOutput;\nimport io.lettuce.core.protocol.AsyncCommand;\nimport io.lettuce.core.protocol.Command;\nimport io.lettuce.core.protocol.CommandHandler;\nimport io.lettuce.core.protocol.CommandType;\nimport io.lettuce.core.protocol.Endpoint;\nimport io.lettuce.core.resource.ClientResources;\nimport io.netty.channel.Channel;\nimport io.netty.channel.ChannelHandlerContext;\nimport io.netty.channel.ChannelPromise;\nimport io.netty.channel.embedded.EmbeddedChannel;\nimport java.io.IOException;\nimport java.net.SocketAddress;\nimport java.util.List;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.stream.StreamSupport;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass LettuceShardCircuitBreakerTest {\n\n  private LettuceShardCircuitBreaker.ChannelCircuitBreakerHandler channelCircuitBreakerHandler;\n\n  @BeforeEach\n  void setUp() {\n    channelCircuitBreakerHandler = new LettuceShardCircuitBreaker.ChannelCircuitBreakerHandler(\"test\", null);\n  }\n\n  @Test\n  void testAfterChannelInitialized() {\n\n    final LettuceShardCircuitBreaker lettuceShardCircuitBreaker =\n        new LettuceShardCircuitBreaker(\"test\", null);\n\n    final Channel channel = new EmbeddedChannel(\n        new CommandHandler(ClientOptions.create(), ClientResources.create(), mock(Endpoint.class)));\n\n    lettuceShardCircuitBreaker.afterChannelInitialized(channel);\n\n    final AtomicBoolean foundCommandHandler = new AtomicBoolean(false);\n    final AtomicBoolean foundChannelCircuitBreakerHandler = new AtomicBoolean(false);\n    StreamSupport.stream(channel.pipeline().spliterator(), false)\n        .forEach(nameAndHandler -> {\n          if (nameAndHandler.getValue() instanceof CommandHandler) {\n            foundCommandHandler.set(true);\n          }\n          if (nameAndHandler.getValue() instanceof LettuceShardCircuitBreaker.ChannelCircuitBreakerHandler) {\n            foundChannelCircuitBreakerHandler.set(true);\n          }\n          if (foundCommandHandler.get()) {\n            assertTrue(foundChannelCircuitBreakerHandler.get(),\n                \"circuit breaker handler should be before the command handler\");\n          }\n        });\n\n    assertTrue(foundChannelCircuitBreakerHandler.get());\n    assertTrue(foundCommandHandler.get());\n  }\n\n  @Test\n  void testHandlerConnect() throws Exception {\n    channelCircuitBreakerHandler.connect(mock(ChannelHandlerContext.class), mock(SocketAddress.class),\n        mock(SocketAddress.class), mock(ChannelPromise.class));\n\n    assertNotNull(channelCircuitBreakerHandler.breaker);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testHandlerWriteBreakerClosed(final boolean completeExceptionally) throws Exception {\n    final CircuitBreaker breaker = mock(CircuitBreaker.class);\n    channelCircuitBreakerHandler.breaker = breaker;\n\n    final AsyncCommand<String, String, String> command = new AsyncCommand<>(\n        new Command<>(CommandType.PING, new StatusOutput<>(StringCodec.ASCII)));\n    final ChannelHandlerContext channelHandlerContext = mock(ChannelHandlerContext.class);\n    final ChannelPromise channelPromise = mock(ChannelPromise.class);\n    channelCircuitBreakerHandler.write(channelHandlerContext, command, channelPromise);\n\n    verify(breaker).acquirePermission();\n\n    if (completeExceptionally) {\n      final Throwable throwable = new IOException(\"timeout\");\n\n      command.completeExceptionally(throwable);\n      verify(breaker).onError(anyLong(), eq(TimeUnit.NANOSECONDS), eq(throwable));\n    } else {\n      command.complete(\"PONG\");\n      verify(breaker).onSuccess(anyLong(), eq(TimeUnit.NANOSECONDS));\n    }\n\n    // write should always be forwarded when the breaker is closed\n    verify(channelHandlerContext).write(command, channelPromise);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testHandlerWriteBatchBreakerClosed(final boolean completeExceptionally) throws Exception {\n    final CircuitBreaker breaker = mock(CircuitBreaker.class);\n    channelCircuitBreakerHandler.breaker = breaker;\n\n    final AsyncCommand<String, String, String> firstCommand = new AsyncCommand<>(\n        new Command<>(CommandType.PING, new StatusOutput<>(StringCodec.ASCII)));\n    final AsyncCommand<String, String, String> secondCommand = new AsyncCommand<>(\n        new Command<>(CommandType.PING, new StatusOutput<>(StringCodec.ASCII)));\n    final ChannelHandlerContext channelHandlerContext = mock(ChannelHandlerContext.class);\n    final ChannelPromise channelPromise = mock(ChannelPromise.class);\n    channelCircuitBreakerHandler.write(channelHandlerContext, List.of(firstCommand, secondCommand), channelPromise);\n\n    verify(breaker).acquirePermission();\n\n    if (completeExceptionally) {\n      final Throwable throwable = new IOException(\"timeout\");\n\n      firstCommand.completeExceptionally(throwable);\n      secondCommand.completeExceptionally(throwable);\n      verify(breaker).onError(anyLong(), eq(TimeUnit.NANOSECONDS), eq(throwable));\n    } else {\n      firstCommand.complete(\"PONG\");\n      secondCommand.complete(\"PONG\");\n      verify(breaker).onSuccess(anyLong(), eq(TimeUnit.NANOSECONDS));\n    }\n\n    // write should always be forwarded when the breaker is closed\n    verify(channelHandlerContext).write(List.of(firstCommand, secondCommand), channelPromise);\n  }\n\n  @Test\n  void testHandlerWriteBreakerOpen() throws Exception {\n    final CircuitBreaker breaker = mock(CircuitBreaker.class);\n    channelCircuitBreakerHandler.breaker = breaker;\n\n    final CallNotPermittedException callNotPermittedException = mock(CallNotPermittedException.class);\n    doThrow(callNotPermittedException).when(breaker).acquirePermission();\n\n    @SuppressWarnings(\"unchecked\") final AsyncCommand<String, String, String> command = mock(AsyncCommand.class);\n    final ChannelHandlerContext channelHandlerContext = mock(ChannelHandlerContext.class);\n    final ChannelPromise channelPromise = mock(ChannelPromise.class);\n    channelCircuitBreakerHandler.write(channelHandlerContext, command, channelPromise);\n\n    verify(command).completeExceptionally(callNotPermittedException);\n    verify(channelPromise).tryFailure(callNotPermittedException);\n\n    verifyNoInteractions(channelHandlerContext);\n  }\n\n  @Test\n  void testHandlerWriteBatchBreakerOpen() throws Exception {\n    final CircuitBreaker breaker = mock(CircuitBreaker.class);\n    channelCircuitBreakerHandler.breaker = breaker;\n\n    final CallNotPermittedException callNotPermittedException = mock(CallNotPermittedException.class);\n    doThrow(callNotPermittedException).when(breaker).acquirePermission();\n\n    @SuppressWarnings(\"unchecked\") final AsyncCommand<String, String, String> firstCommand = mock(AsyncCommand.class);\n    @SuppressWarnings(\"unchecked\") final AsyncCommand<String, String, String> secondCommand = mock(AsyncCommand.class);\n    final ChannelHandlerContext channelHandlerContext = mock(ChannelHandlerContext.class);\n    final ChannelPromise channelPromise = mock(ChannelPromise.class);\n    channelCircuitBreakerHandler.write(channelHandlerContext, List.of(firstCommand, secondCommand), channelPromise);\n\n    verify(firstCommand).completeExceptionally(callNotPermittedException);\n    verify(secondCommand).completeExceptionally(callNotPermittedException);\n    verify(channelPromise).tryFailure(callNotPermittedException);\n\n    verifyNoInteractions(channelHandlerContext);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/RedisClusterExtension.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport io.lettuce.core.FlushMode;\nimport io.lettuce.core.RedisClient;\nimport io.lettuce.core.RedisURI;\nimport io.lettuce.core.internal.HostAndPort;\nimport io.lettuce.core.resource.ClientResources;\nimport io.lettuce.core.resource.DnsResolvers;\nimport io.lettuce.core.resource.MappingSocketAddressResolver;\nimport io.lettuce.core.resource.SocketAddressResolver;\nimport java.io.File;\nimport java.io.FileOutputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.extension.AfterEachCallback;\nimport org.junit.jupiter.api.extension.BeforeAllCallback;\nimport org.junit.jupiter.api.extension.BeforeEachCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.testcontainers.containers.ComposeContainer;\nimport org.testcontainers.containers.wait.strategy.Wait;\nimport org.testcontainers.containers.wait.strategy.WaitStrategy;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.util.ResilienceUtil;\nimport org.whispersystems.textsecuregcm.util.TestcontainersImages;\n\npublic class RedisClusterExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, ExtensionContext.Store.CloseableResource {\n\n  private static ComposeContainer composeContainer;\n  private static Map<HostAndPort, HostAndPort> exposedAddressesByInternalAddress;\n  private static List<RedisURI> redisUris;\n\n  private final Duration timeout;\n\n  private ClientResources redisClientResources;\n  private FaultTolerantRedisClusterClient redisClusterClient;\n\n  private static final String CIRCUIT_BREAKER_CONFIGURATION_NAME =\n      RedisClusterExtension.class.getSimpleName() + \"-\" + RandomStringUtils.insecure().nextAlphanumeric(8);\n\n  private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(2);\n\n  private static final int REDIS_PORT = 6379;\n  private static final WaitStrategy WAIT_STRATEGY = Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(1));\n  private static final Duration CLUSTER_UP_DEADLINE = Duration.ofSeconds(15);\n\n  private static final String[] REDIS_SERVICE_NAMES = new String[] { \"redis-0-1\", \"redis-1-1\", \"redis-2-1\" };\n\n  private static final String CLUSTER_COMPOSE_FILE_CONTENTS = String.format(\"\"\"\n      services:\n        redis-0:\n          image: %1$s\n          environment:\n            - 'ALLOW_EMPTY_PASSWORD=yes'\n            - 'REDIS_NODES=redis-0 redis-1 redis-2'\n\n        redis-1:\n          image: %1$s\n          environment:\n            - 'ALLOW_EMPTY_PASSWORD=yes'\n            - 'REDIS_NODES=redis-0 redis-1 redis-2'\n\n        redis-2:\n          image: %1$s\n          depends_on:\n            - redis-0\n            - redis-1\n          environment:\n            - 'ALLOW_EMPTY_PASSWORD=yes'\n            - 'REDIS_CLUSTER_REPLICAS=0'\n            - 'REDIS_NODES=redis-0 redis-1 redis-2'\n            - 'REDIS_CLUSTER_CREATOR=yes'\n      \"\"\", TestcontainersImages.getRedisCluster());\n\n  public RedisClusterExtension(final Duration timeout) {\n    this.timeout = timeout;\n  }\n\n\n  public static Builder builder() {\n    return new Builder();\n  }\n\n  @Override\n  public void close() throws Throwable {\n    if (composeContainer != null) {\n      composeContainer.stop();\n      composeContainer = null;\n    }\n  }\n\n  @Override\n  public void beforeAll(final ExtensionContext context) throws Exception {\n    if (composeContainer == null) {\n      final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();\n      circuitBreakerConfig.setWaitDurationInOpenState(Duration.ofMillis(500));\n\n      ResilienceUtil.getCircuitBreakerRegistry().addConfiguration(CIRCUIT_BREAKER_CONFIGURATION_NAME, circuitBreakerConfig.toCircuitBreakerConfig());\n\n      final File clusterComposeFile = File.createTempFile(\"redis-cluster\", \".yml\");\n      clusterComposeFile.deleteOnExit();\n\n      try (final FileOutputStream fileOutputStream = new FileOutputStream(clusterComposeFile)) {\n        fileOutputStream.write(CLUSTER_COMPOSE_FILE_CONTENTS.getBytes(StandardCharsets.UTF_8));\n      }\n\n      // Unless we specify an explicit list of files to copy to the container, `ComposeContainer` will copy ALL files in\n      // the compose file's directory. Please see\n      // https://github.com/testcontainers/testcontainers-java/blob/main/docs/modules/docker_compose.md#build-working-directory.\n      composeContainer = new ComposeContainer(clusterComposeFile)\n          .withCopyFilesInContainer(clusterComposeFile.getName());\n\n      for (final String serviceName : REDIS_SERVICE_NAMES) {\n        composeContainer = composeContainer.withExposedService(serviceName, REDIS_PORT, WAIT_STRATEGY);\n      }\n\n      composeContainer.start();\n\n      exposedAddressesByInternalAddress = Arrays.stream(REDIS_SERVICE_NAMES)\n              .collect(Collectors.toMap(serviceName -> {\n                    final String internalIp = composeContainer.getContainerByServiceName(serviceName).orElseThrow()\n                        .getContainerInfo()\n                        .getNetworkSettings()\n                        .getNetworks().values().stream().findFirst().orElseThrow()\n                        .getIpAddress();\n\n                    if (internalIp == null) {\n                      throw new IllegalStateException(\"Could not determine internal IP address of service container: \" + serviceName);\n                    }\n\n                    return HostAndPort.of(internalIp, REDIS_PORT);\n                  },\n                  serviceName -> HostAndPort.of(\n                      composeContainer.getServiceHost(serviceName, REDIS_PORT),\n                      composeContainer.getServicePort(serviceName, REDIS_PORT))));\n\n      redisUris = Arrays.stream(REDIS_SERVICE_NAMES)\n          .map(serviceName -> RedisURI.create(\n              composeContainer.getServiceHost(serviceName, REDIS_PORT),\n              composeContainer.getServicePort(serviceName, REDIS_PORT)))\n          .toList();\n\n      // Wait for the cluster to be fully up; just having the containers running isn't enough since they still need to do\n      // some post-launch cluster setup work.\n      boolean allNodesUp;\n      final Instant deadline = Instant.now().plus(CLUSTER_UP_DEADLINE);\n\n      final ClientResources clientResources = ClientResources.builder()\n          .socketAddressResolver(getSocketAddressResolver())\n          .build();\n\n      try {\n        do {\n          allNodesUp = redisUris.stream()\n              .allMatch(redisUri -> {\n                try (final RedisClient redisClient = RedisClient.create(clientResources, redisUri)) {\n                  final String clusterInfo = redisClient.connect().sync().clusterInfo();\n                  return clusterInfo.contains(\"cluster_state:ok\") && clusterInfo.contains(\"cluster_slots_ok:16384\");\n                } catch (final Exception e) {\n                  return false;\n                }\n              });\n\n          if (Instant.now().isAfter(deadline)) {\n            throw new RuntimeException(\"Cluster did not start before deadline\");\n          }\n\n          if (!allNodesUp) {\n            Thread.sleep(100);\n          }\n        } while (!allNodesUp);\n      } finally {\n        clientResources.shutdown().await();\n      }\n    }\n  }\n\n  @Override\n  public void beforeEach(final ExtensionContext context) throws Exception {\n    redisClientResources = ClientResources.builder()\n        .socketAddressResolver(getSocketAddressResolver())\n        .build();\n\n    redisClusterClient = new FaultTolerantRedisClusterClient(\"test-cluster\",\n        redisClientResources.mutate(),\n        getRedisURIs(),\n        timeout,\n        CIRCUIT_BREAKER_CONFIGURATION_NAME);\n\n    redisClusterClient.useCluster(connection -> connection.sync().flushall(FlushMode.SYNC));\n  }\n\n  @Override\n  public void afterEach(final ExtensionContext context) throws InterruptedException {\n    redisClusterClient.shutdown();\n    redisClientResources.shutdown().await();\n  }\n\n  public static List<RedisURI> getRedisURIs() {\n    return redisUris;\n  }\n\n  public RedisURI getExposedRedisURI(final RedisURI internalRedisURI) {\n    final HostAndPort internalHostAndPort = HostAndPort.of(internalRedisURI.getHost(), internalRedisURI.getPort());\n    final HostAndPort exposedHostAndPort = exposedAddressesByInternalAddress.getOrDefault(internalHostAndPort, internalHostAndPort);\n\n    return RedisURI.create(exposedHostAndPort.getHostText(), exposedHostAndPort.getPort());\n  }\n\n  public SocketAddressResolver getSocketAddressResolver() {\n    return MappingSocketAddressResolver.create(DnsResolvers.UNRESOLVED,\n        hostAndPort -> exposedAddressesByInternalAddress.getOrDefault(hostAndPort, hostAndPort));\n  }\n\n  public FaultTolerantRedisClusterClient getRedisCluster() {\n    return redisClusterClient;\n  }\n\n  public static class Builder {\n\n    private Duration timeout = DEFAULT_TIMEOUT;\n\n    private Builder() {\n    }\n\n    Builder timeout(Duration timeout) {\n      this.timeout = timeout;\n      return this;\n    }\n\n    public RedisClusterExtension build() {\n      return new RedisClusterExtension(timeout);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/redis/RedisServerExtension.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.redis;\n\nimport com.redis.testcontainers.RedisContainer;\nimport io.github.resilience4j.circuitbreaker.CircuitBreaker;\nimport io.lettuce.core.FlushMode;\nimport io.lettuce.core.RedisURI;\nimport io.lettuce.core.resource.ClientResources;\nimport java.time.Duration;\nimport org.junit.jupiter.api.extension.AfterEachCallback;\nimport org.junit.jupiter.api.extension.BeforeAllCallback;\nimport org.junit.jupiter.api.extension.BeforeEachCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.testcontainers.utility.DockerImageName;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.util.TestcontainersImages;\n\npublic class RedisServerExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, ExtensionContext.Store.CloseableResource {\n\n  private static RedisContainer redisContainer;\n\n  private ClientResources redisClientResources;\n  private FaultTolerantRedisClient faultTolerantRedisClient;\n\n  private static final DockerImageName REDIS_IMAGE = DockerImageName.parse(TestcontainersImages.getRedis());\n\n  public static class RedisServerExtensionBuilder {\n\n    private RedisServerExtensionBuilder() {\n    }\n\n    public RedisServerExtension build() {\n      return new RedisServerExtension();\n    }\n  }\n\n  public static RedisServerExtensionBuilder builder() {\n    return new RedisServerExtensionBuilder();\n  }\n\n  @Override\n  public void beforeAll(final ExtensionContext context) {\n    if (redisContainer == null) {\n      redisContainer = new RedisContainer(REDIS_IMAGE);\n      redisContainer.start();\n    }\n  }\n\n  public static RedisURI getRedisURI() {\n    return RedisURI.create(redisContainer.getRedisURI());\n  }\n\n  @Override\n  public void beforeEach(final ExtensionContext context) {\n    final CircuitBreakerConfiguration circuitBreakerConfig = new CircuitBreakerConfiguration();\n    circuitBreakerConfig.setWaitDurationInOpenState(Duration.ofMillis(500));\n\n    redisClientResources = ClientResources.builder().build();\n\n    faultTolerantRedisClient = new FaultTolerantRedisClient(\"test-redis-client\",\n        redisClientResources.mutate(),\n        getRedisURI(),\n        Duration.ofSeconds(2),\n        CircuitBreaker.of(\"test\", circuitBreakerConfig.toCircuitBreakerConfig()));\n\n    faultTolerantRedisClient.useConnection(connection -> connection.sync().flushall(FlushMode.SYNC));\n  }\n\n  @Override\n  public void afterEach(final ExtensionContext context) throws InterruptedException {\n    faultTolerantRedisClient.shutdown();\n    redisClientResources.shutdown().await();\n  }\n\n  @Override\n  public void close() throws Throwable {\n    if (redisContainer != null) {\n      redisContainer.stop();\n      redisContainer = null;\n    }\n  }\n\n  public FaultTolerantRedisClient getRedisClient() {\n    return faultTolerantRedisClient;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/registration/IdentityTokenCallCredentialsTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.registration;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.auth.oauth2.GoogleCredentials;\nimport com.google.auth.oauth2.IdToken;\nimport com.google.auth.oauth2.ImpersonatedCredentials;\nimport io.github.resilience4j.core.IntervalFunction;\nimport io.github.resilience4j.retry.RetryConfig;\nimport io.grpc.CallCredentials;\nimport java.io.IOException;\nimport java.time.Duration;\nimport java.util.concurrent.Executors;\nimport org.junit.jupiter.api.Test;\n\npublic class IdentityTokenCallCredentialsTest {\n\n  @Test\n  public void retryErrors() throws IOException {\n    final ImpersonatedCredentials impersonatedCredentials = mock(ImpersonatedCredentials.class);\n    when(impersonatedCredentials.getSourceCredentials()).thenReturn(mock(GoogleCredentials.class));\n\n    final IdentityTokenCallCredentials creds = new IdentityTokenCallCredentials(\n        RetryConfig.custom()\n            .retryOnException(throwable -> true)\n            .maxAttempts(Integer.MAX_VALUE)\n            .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(\n                Duration.ofMillis(100), 1.5, Duration.ofSeconds(5)))\n            .build(),\n        impersonatedCredentials,\n        \"test\",\n        Executors.newSingleThreadScheduledExecutor());\n\n    final IdToken idToken = mock(IdToken.class);\n    when(idToken.getTokenValue()).thenReturn(\"testtoken\");\n\n    // throw exception first two calls, then succeed\n    when(impersonatedCredentials.idTokenWithAudience(anyString(), any()))\n        .thenThrow(new IOException(\"uh oh 1\"))\n        .thenThrow(new IOException(\"uh oh 2\"))\n        .thenReturn(idToken)\n        .thenThrow(new IOException(\"uh oh 3\"));\n\n    creds.refreshIdentityToken();\n    CallCredentials.MetadataApplier metadataApplier = mock(CallCredentials.MetadataApplier.class);\n    creds.applyRequestMetadata(null, null, metadataApplier);\n    verify(metadataApplier, times(1))\n        .apply(argThat(metadata -> \"Bearer testtoken\".equals(metadata.get(IdentityTokenCallCredentials.AUTHORIZATION_METADATA_KEY))));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/s3/PolicySignerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.time.Instant;\nimport java.time.ZoneOffset;\nimport java.time.ZonedDateTime;\nimport org.junit.jupiter.api.Test;\n\nclass PolicySignerTest {\n\n  @Test\n  void testSignature() {\n    final Instant time = Instant.parse(\"2015-12-29T00:00:00Z\");\n    final ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(time, ZoneOffset.UTC);\n    final String encodedPolicy = \"eyAiZXhwaXJhdGlvbiI6ICIyMDE1LTEyLTMwVDEyOjAwOjAwLjAwMFoiLA0KICAiY29uZGl0aW9ucyI6IFsNCiAgICB7ImJ1Y2tldCI6ICJzaWd2NGV4YW1wbGVidWNrZXQifSwNCiAgICBbInN0YXJ0cy13aXRoIiwgIiRrZXkiLCAidXNlci91c2VyMS8iXSwNCiAgICB7ImFjbCI6ICJwdWJsaWMtcmVhZCJ9LA0KICAgIHsic3VjY2Vzc19hY3Rpb25fcmVkaXJlY3QiOiAiaHR0cDovL3NpZ3Y0ZXhhbXBsZWJ1Y2tldC5zMy5hbWF6b25hd3MuY29tL3N1Y2Nlc3NmdWxfdXBsb2FkLmh0bWwifSwNCiAgICBbInN0YXJ0cy13aXRoIiwgIiRDb250ZW50LVR5cGUiLCAiaW1hZ2UvIl0sDQogICAgeyJ4LWFtei1tZXRhLXV1aWQiOiAiMTQzNjUxMjM2NTEyNzQifSwNCiAgICB7IngtYW16LXNlcnZlci1zaWRlLWVuY3J5cHRpb24iOiAiQUVTMjU2In0sDQogICAgWyJzdGFydHMtd2l0aCIsICIkeC1hbXotbWV0YS10YWciLCAiIl0sDQoNCiAgICB7IngtYW16LWNyZWRlbnRpYWwiOiAiQUtJQUlPU0ZPRE5ON0VYQU1QTEUvMjAxNTEyMjkvdXMtZWFzdC0xL3MzL2F3czRfcmVxdWVzdCJ9LA0KICAgIHsieC1hbXotYWxnb3JpdGhtIjogIkFXUzQtSE1BQy1TSEEyNTYifSwNCiAgICB7IngtYW16LWRhdGUiOiAiMjAxNTEyMjlUMDAwMDAwWiIgfQ0KICBdDQp9\";\n    final PolicySigner policySigner = new PolicySigner(\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\", \"us-east-1\");\n\n    assertEquals(\"8afdbf4008c03f22c2cd3cdb72e4afbb1f6a588f3255ac628749a66d7f09699e\",\n        policySigner.getSignature(zonedDateTime, encodedPolicy));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/s3/S3ObjectMonitorTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.s3;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Consumer;\nimport org.junit.jupiter.api.Test;\nimport software.amazon.awssdk.core.ResponseInputStream;\nimport software.amazon.awssdk.http.AbortableInputStream;\nimport software.amazon.awssdk.services.s3.S3Client;\nimport software.amazon.awssdk.services.s3.model.GetObjectRequest;\nimport software.amazon.awssdk.services.s3.model.GetObjectResponse;\nimport software.amazon.awssdk.services.s3.model.HeadObjectRequest;\nimport software.amazon.awssdk.services.s3.model.HeadObjectResponse;\n\nclass S3ObjectMonitorTest {\n\n  @Test\n  void refresh() {\n    final S3Client s3Client = mock(S3Client.class);\n\n    final String bucket = \"s3bucket\";\n    final String objectKey = \"greatest-smooth-jazz-hits-of-all-time.zip\";\n\n    //noinspection unchecked\n    final Consumer<InputStream> listener = mock(Consumer.class);\n\n    final S3ObjectMonitor objectMonitor = new S3ObjectMonitor(\n        s3Client,\n        bucket,\n        objectKey,\n        16 * 1024 * 1024,\n        mock(ScheduledExecutorService.class),\n        Duration.ofMinutes(1));\n\n    final String uuid = UUID.randomUUID().toString();\n    when(s3Client.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey).build())).thenReturn(\n        HeadObjectResponse.builder().eTag(uuid).build());\n    final ResponseInputStream<GetObjectResponse> ris = responseInputStreamFromString(\"abc\", uuid);\n    when(s3Client.getObject(GetObjectRequest.builder().bucket(bucket).key(objectKey).build())).thenReturn(ris);\n\n    objectMonitor.refresh(listener);\n    objectMonitor.refresh(listener);\n\n    verify(listener).accept(ris);\n  }\n\n  @Test\n  void refreshAfterGet() throws IOException {\n    final S3Client s3Client = mock(S3Client.class);\n\n    final String bucket = \"s3bucket\";\n    final String objectKey = \"greatest-smooth-jazz-hits-of-all-time.zip\";\n\n    //noinspection unchecked\n    final Consumer<InputStream> listener = mock(Consumer.class);\n\n    final S3ObjectMonitor objectMonitor = new S3ObjectMonitor(\n        s3Client,\n        bucket,\n        objectKey,\n        16 * 1024 * 1024,\n        mock(ScheduledExecutorService.class),\n        Duration.ofMinutes(1));\n\n    final String uuid = UUID.randomUUID().toString();\n    when(s3Client.headObject(HeadObjectRequest.builder().key(objectKey).bucket(bucket).build()))\n        .thenReturn(HeadObjectResponse.builder().eTag(uuid).build());\n    final ResponseInputStream<GetObjectResponse> responseInputStream = responseInputStreamFromString(\"abc\", uuid);\n    when(s3Client.getObject(GetObjectRequest.builder().key(objectKey).bucket(bucket).build())).thenReturn(responseInputStream);\n\n    objectMonitor.getObject();\n    objectMonitor.refresh(listener);\n\n    verify(listener, never()).accept(responseInputStream);\n  }\n\n  private ResponseInputStream<GetObjectResponse> responseInputStreamFromString(final String s, final String etag) {\n    final byte[] bytes = s.getBytes(StandardCharsets.UTF_8);\n    final AbortableInputStream ais = AbortableInputStream.create(new ByteArrayInputStream(bytes));\n    return new ResponseInputStream<>(GetObjectResponse.builder().contentLength((long) bytes.length).eTag(etag).build(), ais);\n  }\n\n  @Test\n  void refreshOversizedObject() {\n    final S3Client s3Client = mock(S3Client.class);\n\n    final String bucket = \"s3bucket\";\n    final String objectKey = \"greatest-smooth-jazz-hits-of-all-time.zip\";\n    final long maxObjectSize = 16 * 1024 * 1024;\n\n    //noinspection unchecked\n    final Consumer<InputStream> listener = mock(Consumer.class);\n\n    final S3ObjectMonitor objectMonitor = new S3ObjectMonitor(\n        s3Client,\n        bucket,\n        objectKey,\n        maxObjectSize,\n        mock(ScheduledExecutorService.class),\n        Duration.ofMinutes(1));\n\n    final String uuid = UUID.randomUUID().toString();\n    when(s3Client.headObject(HeadObjectRequest.builder().bucket(bucket).key(objectKey).build())).thenReturn(\n        HeadObjectResponse.builder().eTag(uuid).contentLength(maxObjectSize+1).build());\n    final ResponseInputStream<GetObjectResponse> ris = responseInputStreamFromString(\"a\".repeat((int) maxObjectSize+1), uuid);\n    when(s3Client.getObject(GetObjectRequest.builder().bucket(bucket).key(objectKey).build())).thenReturn(ris);\n\n    objectMonitor.refresh(listener);\n\n    verify(listener, never()).accept(any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/scheduler/JobSchedulerTest.java",
    "content": "package org.whispersystems.textsecuregcm.scheduler;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\n\nclass JobSchedulerTest {\n\n  private static final Instant CURRENT_TIME = Instant.now();\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION =\n      new DynamoDbExtension(DynamoDbExtensionSchema.Tables.SCHEDULED_JOBS);\n\n  private static class TestJobScheduler extends JobScheduler {\n\n    private final AtomicInteger jobsProcessed = new AtomicInteger(0);\n\n    protected TestJobScheduler(final DynamoDbAsyncClient dynamoDbAsyncClient,\n        final String tableName,\n        final Clock clock) {\n\n      super(dynamoDbAsyncClient, tableName, Duration.ofDays(7), clock);\n    }\n\n    @Override\n    public String getSchedulerName() {\n      return \"test\";\n    }\n\n    @Override\n    protected CompletableFuture<String> processJob(@Nullable final byte[] jobData) {\n      jobsProcessed.incrementAndGet();\n\n      return CompletableFuture.completedFuture(\"test\");\n    }\n  }\n\n  @Test\n  void scheduleJob() {\n    final TestJobScheduler scheduler = new TestJobScheduler(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.SCHEDULED_JOBS.tableName(),\n        Clock.fixed(CURRENT_TIME, ZoneId.systemDefault()));\n\n    assertDoesNotThrow(() ->\n        scheduler.scheduleJob(scheduler.buildRunAtAttribute(CURRENT_TIME, 0L), CURRENT_TIME, null).join());\n\n    final CompletionException completionException = assertThrows(CompletionException.class, () ->\n        scheduler.scheduleJob(scheduler.buildRunAtAttribute(CURRENT_TIME, 0L), CURRENT_TIME, null).join(),\n        \"Scheduling multiple jobs with identical sort keys should fail\");\n\n    assertInstanceOf(ConditionalCheckFailedException.class, completionException.getCause());\n  }\n\n  @Test\n  void processAvailableJobs() {\n    final TestClock testClock = TestClock.pinned(CURRENT_TIME);\n\n    final TestJobScheduler scheduler = new TestJobScheduler(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.SCHEDULED_JOBS.tableName(),\n        testClock);\n\n    scheduler.scheduleJob(scheduler.buildRunAtAttribute(CURRENT_TIME, 0L), CURRENT_TIME, null).join();\n\n    // Clock time is before scheduled job time\n    testClock.pin(CURRENT_TIME.minusMillis(1));\n\n    scheduler.processAvailableJobs().block();\n    assertEquals(0, scheduler.jobsProcessed.get());\n\n    // Clock time is after scheduled job time\n    testClock.pin(CURRENT_TIME.plusMillis(1));\n\n    scheduler.processAvailableJobs().block();\n    assertEquals(1, scheduler.jobsProcessed.get());\n\n    scheduler.processAvailableJobs().block();\n    assertEquals(1, scheduler.jobsProcessed.get(),\n        \"Jobs should be cleared after successful processing; job counter should not increment on second run\");\n  }\n\n  @Test\n  void processAvailableJobsWithError() {\n    final AtomicInteger jobsEncountered = new AtomicInteger(0);\n\n    final TestJobScheduler scheduler = new TestJobScheduler(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.SCHEDULED_JOBS.tableName(),\n        Clock.fixed(CURRENT_TIME, ZoneId.systemDefault())) {\n\n      @Override\n      protected CompletableFuture<String> processJob(@Nullable final byte[] jobData) {\n        jobsEncountered.incrementAndGet();\n\n        return CompletableFuture.failedFuture(new RuntimeException(\"OH NO\"));\n      }\n    };\n\n    scheduler.scheduleJob(scheduler.buildRunAtAttribute(CURRENT_TIME, 0L), CURRENT_TIME, null).join();\n\n    scheduler.processAvailableJobs().block();\n    assertEquals(1, jobsEncountered.get());\n\n    scheduler.processAvailableJobs().block();\n    assertEquals(2, jobsEncountered.get(),\n        \"Jobs should not be cleared after failed processing; encountered job counter should increment on second run\");\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/scheduler/SchedulingUtilTest.java",
    "content": "package org.whispersystems.textsecuregcm.scheduler;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.LocalDateTime;\nimport java.time.LocalTime;\nimport java.time.ZoneId;\nimport java.time.ZoneOffset;\nimport java.time.ZonedDateTime;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass SchedulingUtilTest {\n\n  @Test\n  void getNextRecommendedNotificationTime() {\n    {\n      final Account account = mock(Account.class);\n\n      // The account has a phone number that can be resolved to a region with known timezones\n      when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(\n          PhoneNumberUtil.getInstance().getExampleNumber(\"DE\"), PhoneNumberUtil.PhoneNumberFormat.E164));\n\n      final ZoneId berlinZoneId = ZoneId.of(\"Europe/Berlin\");\n      final ZonedDateTime beforeNotificationTime = ZonedDateTime.now(berlinZoneId).with(LocalTime.of(13, 0));\n\n      assertEquals(\n          beforeNotificationTime.with(LocalTime.of(14, 0)).toInstant(),\n          SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),\n              Clock.fixed(beforeNotificationTime.toInstant(), berlinZoneId)));\n\n      final ZonedDateTime afterNotificationTime = ZonedDateTime.now(berlinZoneId).with(LocalTime.of(15, 0));\n\n      assertEquals(\n          afterNotificationTime.with(LocalTime.of(14, 0)).plusDays(1).toInstant(),\n          SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),\n              Clock.fixed(afterNotificationTime.toInstant(), berlinZoneId)));\n    }\n\n    {\n      final Account account = mock(Account.class);\n\n      // The account does not have a phone number that can be connected to a region/time zone\n      when(account.getNumber()).thenReturn(\"Not a parseable number\");\n\n      final ZonedDateTime beforeNotificationTime = ZonedDateTime.now(ZoneId.systemDefault()).with(LocalTime.of(13, 59));\n      final LocalTime preferredNotificationTime = LocalTime.of(14, 0);\n\n      assertEquals(\n          beforeNotificationTime.with(preferredNotificationTime).toInstant(),\n          SchedulingUtil.getNextRecommendedNotificationTime(account, preferredNotificationTime,\n              Clock.fixed(beforeNotificationTime.toInstant(), ZoneId.systemDefault())));\n\n      final ZonedDateTime afterNotificationTime = ZonedDateTime.now(ZoneId.systemDefault()).with(LocalTime.of(14, 1));\n\n      assertEquals(\n          afterNotificationTime.with(preferredNotificationTime).plusDays(1).toInstant(),\n          SchedulingUtil.getNextRecommendedNotificationTime(account, preferredNotificationTime,\n              Clock.fixed(afterNotificationTime.toInstant(), ZoneId.systemDefault())));\n    }\n  }\n\n  @Test\n  void getNextRecommendedNotificationTimeDaylightSavings() {\n    final Account account = mock(Account.class);\n\n    // The account has a phone number that can be resolved to a region with known timezones\n    when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"DE\"), PhoneNumberUtil.PhoneNumberFormat.E164));\n\n    final LocalDateTime afterNotificationTime = LocalDateTime.of(2025, 3, 29, 15, 0);\n    final ZoneId berlinZoneId = ZoneId.of(\"Europe/Berlin\");\n    final ZoneOffset berlineZoneOffset = berlinZoneId.getRules().getOffset(afterNotificationTime);\n\n    // Daylight Savings Time started on 2025-03-30 at 2:00AM in Germany.\n    // Instantiating a ZonedDateTime with a zone ID factors in daylight savings when we adjust the time.\n    final ZonedDateTime afterNotificationTimeWithZoneId = ZonedDateTime.of(afterNotificationTime, berlinZoneId);\n    \n    assertEquals(\n        afterNotificationTimeWithZoneId.with(LocalTime.of(14, 0)).plusDays(1).toInstant(),\n        SchedulingUtil.getNextRecommendedNotificationTime(account, LocalTime.of(14, 0),\n            Clock.fixed(afterNotificationTime.toInstant(berlineZoneOffset), berlinZoneId)));\n  }\n\n  @Test\n  void zoneIdSelectionSingleOffset() {\n    final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().getExampleNumber(\"DE\");\n    final String e164 = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final Instant now = Instant.now();\n\n    assertEquals(\n        ZoneId.of(\"Europe/Berlin\").getRules().getOffset(now),\n        SchedulingUtil.getZoneId(e164, TestClock.pinned(now)).orElseThrow().getRules().getOffset(now));\n  }\n\n  @Test\n  void zoneIdSelectionMultipleOffsets() {\n    // A US VOIP number spans multiple time zones, we should pick a 'middle' one\n    final Phonenumber.PhoneNumber phoneNumber =\n        PhoneNumberUtil.getInstance().getExampleNumberForType(\"US\", PhoneNumberUtil.PhoneNumberType.VOIP);\n    final String e164 = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final Instant now = Instant.now();\n\n    assertEquals(\n        ZoneId.of(\"America/Chicago\").getRules().getOffset(now),\n        SchedulingUtil.getZoneId(e164, TestClock.pinned(now)).orElseThrow().getRules().getOffset(now));\n  }\n\n  @Test\n  void zoneIdSelectionUnknownNumber() {\n    // An invalid number will fall back to a geographical lookup. Even if that is not technically correct for the number,\n    // it will give a time zone for the region, rather than an Optional.empty() result\n    final Phonenumber.PhoneNumber phoneNumber =\n        PhoneNumberUtil.getInstance().getInvalidExampleNumber(\"US\");\n    final String e164 = PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final Instant now = Instant.now();\n\n    assertEquals(\n        ZoneId.of(\"America/New_York\").getRules().getOffset(now),\n        SchedulingUtil.getZoneId(e164, TestClock.pinned(now)).orElseThrow().getRules().getOffset(now));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/securestorage/SecureStorageClientTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.securestorage;\n\nimport static com.github.tomakehurst.wiremock.client.WireMock.aResponse;\nimport static com.github.tomakehurst.wiremock.client.WireMock.delete;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;\nimport static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;\n\nimport com.github.tomakehurst.wiremock.junit5.WireMockExtension;\nimport java.security.cert.CertificateException;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.CircuitBreakerConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.SecureStorageServiceConfiguration;\n\nclass SecureStorageClientTest {\n\n  private UUID accountUuid;\n  private ExternalServiceCredentialsGenerator credentialsGenerator;\n  private ExecutorService httpExecutor;\n  private ScheduledExecutorService retryExecutor;\n\n  private SecureStorageClient secureStorageClient;\n\n  @RegisterExtension\n  private final WireMockExtension wireMock = WireMockExtension.newInstance()\n      .options(wireMockConfig().dynamicPort().dynamicHttpsPort())\n      .build();\n\n  @BeforeEach\n  void setUp() throws CertificateException {\n    accountUuid = UUID.randomUUID();\n    credentialsGenerator = mock(ExternalServiceCredentialsGenerator.class);\n    httpExecutor = Executors.newSingleThreadExecutor();\n    retryExecutor = Executors.newSingleThreadScheduledExecutor();\n\n    final SecureStorageServiceConfiguration config = new SecureStorageServiceConfiguration(\n        randomSecretBytes(32),\n        \"http://localhost:\" + wireMock.getPort(),\n        List.of(\"\"\"\n            -----BEGIN CERTIFICATE-----\n            MIICZDCCAc2gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBPMQswCQYDVQQGEwJ1czEL\n            MAkGA1UECAwCVVMxHjAcBgNVBAoMFVNpZ25hbCBNZXNzZW5nZXIsIExMQzETMBEG\n            A1UEAwwKc2lnbmFsLm9yZzAeFw0yMDEyMjMyMjQ3NTlaFw0zMDEyMjEyMjQ3NTla\n            ME8xCzAJBgNVBAYTAnVzMQswCQYDVQQIDAJVUzEeMBwGA1UECgwVU2lnbmFsIE1l\n            c3NlbmdlciwgTExDMRMwEQYDVQQDDApzaWduYWwub3JnMIGfMA0GCSqGSIb3DQEB\n            AQUAA4GNADCBiQKBgQCfSLcZNHYqbxSsgWp4JvbPRHjQTrlsrKrgD2q7f/OY6O3Y\n            /X0QNcNSOJpliN8rmzwslfsrXHO3q1diGRw4xHogUJZ/7NQrHiP/zhN0VTDh49pD\n            ZpjXVyUbayLS/6qM5arKxBspzEFBb5v8cF6bPr76SO/rpGXiI0j6yJKX6fRiKwID\n            AQABo1AwTjAdBgNVHQ4EFgQU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwHwYDVR0jBBgw\n            FoAU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B\n            AQ0FAAOBgQB+5d5+NtzLILfrc9QmJdIO1YeDP64JmFwTER0kEUouRsb9UwknVWZa\n            y7MTM4NoBV1k0zb5LAk89SIDPr/maW5AsLtEomzjnEiomjoMBUdNe3YCgQReoLnr\n            R/QaUNbrCjTGYfBsjGbIzmkWPUyTec2ZdRyJ8JiVl386+6CZkxnndQ==\n            -----END CERTIFICATE-----\n            \"\"\", \"\"\"\n            -----BEGIN CERTIFICATE-----\n            MIIEpDCCAowCCQC43PUTWSADVjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls\n            b2NhbGhvc3QwHhcNMjIxMDE3MjA0NTM0WhcNMjMxMDE3MjA0NTM0WjAUMRIwEAYD\n            VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDV\n            x1cdEd2ffQTlTXWRiCHGcrlYf4RJnctt9sw/BuHWTLXBu5LhyJSGn5LRszO/NCXK\n            Z/cmGR7pLj366RtiwL+Qo3nhvDCK7T9xZeNIusM6XMcMK9D/DGCYPqtjQz8NXd9V\n            ajBBe6nwTDTa+oqX8Mt89foWNkg5Il/lY62u9Dr18LRZ2W9zzYi3Q9/K0CbIX6pM\n            yVlPIO5rITOR2IsbeyqsO9jufgX5lP4ZKLLBAP1b7usjC4YdvWacjQg/rK5aay1x\n            jC2HCDgo/4N30QVXzSA9nFfSe6AE/xkStK4819JqOkY5JsJCbef1P3hOOdSLEjbp\n            xq3MjOs6G6dOgteaAGs10vx7dHxDWETTIiD7BIZ9zRYgOF5bkCaIUO+JfySE1MHD\n            KBAFLoRuvmRev5Ln5R0MCHpUMSmMNgJqz+RWZV3g/gpYbuWiHgJOwL1393eK50Bg\n            W7SXQ8EjJj2yXZSH+1gPzN0DRoJZiaBoTPnCL2qUgvwFpW1PJsM5FDyUJFUoK5kK\n            HLBBSKAPt6ZlSrUe2nBgJv7EF1GK+fTU08LXgW33OpLceGPa0zTShkukQUMtUtZ8\n            GqhO12ohMzEupIu5Xurthq4VVUrzHUdj1ZZRMhAbfLU36sd03MMyL/xBqTN6dzCa\n            GDGIPGpYjAllZ5xMRt2kZdv+Kr6oo3u2nLUIsqI7KQIDAQABMA0GCSqGSIb3DQEB\n            CwUAA4ICAQCB5s43YF35ssf5YONW5iAaifGpi1o0866xfeOybtohFGvQ7V2W34i9\n            TYBCt8+0hgatMcvZ08f0vqig1i7nrvYcE1hnhL7JNkU8qm0s9ytHZt6j62nB0kd/\n            uqE2hOEQalTf/2TGPV0CCgiqLyd8lEUQvQeA38wktwUeZpVnErlzHeMR2CvV3K8R\n            u4vV6SnBcf+TAt56RKYZkPyvZj5llQPo14Glyoo8qZES7Ky1SHmM0GL+baPRBjRW\n            3KgSt98Wyu4yr9qu21JpnbAnLhBfzfSKjSeCRgFElUE1GIaFGRZ7ypA74dUKeLnb\n            /VUWrszmUhGaEjV9dpI6x6B/kSpQMtIQqBaKRY2ALUeEujS/rURi4iMDwSU+GkSH\n            cyEvZKS97OA/dWeXfLXdo4beDBRG93bI4rQnDg5+VdlBOkQSLueb8x6/VThMoC5d\n            vZiotFQHseljQAdTkNa6tBu6c4XDYPCKB3CfkMYOlCfTS7Acn5G6dxTPKBtLGBnL\n            nQfYyzuwYkN09+2PVzt6auBHr3To7uoclkxX+hxyvPIwIZ0N6b4tQR1FCAkvg29Q\n            WIOjZOKGW690ESKCKOnFjUHVO0HpuWnT81URTuY62FXsYdVc2wE4v0E04mEbqQ0P\n            lY6ZKNA81Lm3YADYtObmK1IUrOPo9BeIaPy0UM08SmN880Vunqa91Q==\n            -----END CERTIFICATE-----\n            \"\"\"),\n        null,\n        null);\n\n    secureStorageClient = new SecureStorageClient(credentialsGenerator, httpExecutor, retryExecutor, config);\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n\n    httpExecutor.shutdown();\n    httpExecutor.awaitTermination(1, TimeUnit.SECONDS);\n    retryExecutor.shutdown();\n    retryExecutor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void deleteStoredData() {\n\n    final String username = RandomStringUtils.secure().nextAlphabetic(16);\n    final String password = RandomStringUtils.secure().nextAlphanumeric(32);\n\n    when(credentialsGenerator.generateForUuid(accountUuid)).thenReturn(\n        new ExternalServiceCredentials(username, password));\n\n    wireMock.stubFor(delete(urlEqualTo(SecureStorageClient.DELETE_PATH))\n        .withBasicAuth(username, password)\n        .willReturn(aResponse().withStatus(202)));\n\n    // We're happy as long as this doesn't throw an exception\n    secureStorageClient.deleteStoredData(accountUuid).join();\n  }\n\n  @Test\n  void deleteStoredDataFailure() {\n\n    final String username = RandomStringUtils.secure().nextAlphabetic(16);\n    final String password = RandomStringUtils.secure().nextAlphanumeric(32);\n\n    when(credentialsGenerator.generateForUuid(accountUuid)).thenReturn(\n        new ExternalServiceCredentials(username, password));\n\n    wireMock.stubFor(delete(urlEqualTo(SecureStorageClient.DELETE_PATH))\n        .withBasicAuth(username, password)\n        .willReturn(aResponse().withStatus(400)));\n\n    final CompletionException completionException = assertThrows(CompletionException.class,\n        () -> secureStorageClient.deleteStoredData(accountUuid).join());\n    assertTrue(completionException.getCause() instanceof SecureStorageException);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/securevaluerecovery/SecureValueRecoveryClientTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.securevaluerecovery;\n\nimport static com.github.tomakehurst.wiremock.client.WireMock.aResponse;\nimport static com.github.tomakehurst.wiremock.client.WireMock.delete;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;\nimport static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.randomSecretBytes;\n\nimport com.github.tomakehurst.wiremock.junit5.WireMockExtension;\nimport java.security.cert.CertificateException;\nimport java.util.Arrays;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentials;\nimport org.whispersystems.textsecuregcm.auth.ExternalServiceCredentialsGenerator;\nimport org.whispersystems.textsecuregcm.configuration.SecureValueRecoveryConfiguration;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\n\nclass SecureValueRecoveryClientTest {\n  private static final List<Integer> ALLOWED_ERRORS = Arrays.asList(567, 568);\n\n  private UUID accountUuid;\n  private ExternalServiceCredentialsGenerator credentialsGenerator;\n  private ExecutorService httpExecutor;\n  private ScheduledExecutorService retryExecutor;\n\n  private SecureValueRecoveryClient secureValueRecoveryClient;\n\n  @RegisterExtension\n  private static final WireMockExtension wireMock = WireMockExtension.newInstance()\n      .options(wireMockConfig().dynamicPort().dynamicHttpsPort())\n      .build();\n\n  @BeforeEach\n  void setUp() throws CertificateException {\n    accountUuid = UUID.randomUUID();\n    credentialsGenerator = mock(ExternalServiceCredentialsGenerator.class);\n    httpExecutor = Executors.newSingleThreadExecutor();\n    retryExecutor = Executors.newSingleThreadScheduledExecutor();\n\n    final SecureValueRecoveryConfiguration config = new SecureValueRecoveryConfiguration(\n        \"http://localhost:\" + wireMock.getPort(),\n        randomSecretBytes(32),\n        randomSecretBytes(32),\n        // This is a randomly-generated, throwaway certificate that's not actually connected to anything\n        List.of(\"\"\"\n            -----BEGIN CERTIFICATE-----\n            MIICZDCCAc2gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBPMQswCQYDVQQGEwJ1czEL\n            MAkGA1UECAwCVVMxHjAcBgNVBAoMFVNpZ25hbCBNZXNzZW5nZXIsIExMQzETMBEG\n            A1UEAwwKc2lnbmFsLm9yZzAeFw0yMDEyMjMyMjQ3NTlaFw0zMDEyMjEyMjQ3NTla\n            ME8xCzAJBgNVBAYTAnVzMQswCQYDVQQIDAJVUzEeMBwGA1UECgwVU2lnbmFsIE1l\n            c3NlbmdlciwgTExDMRMwEQYDVQQDDApzaWduYWwub3JnMIGfMA0GCSqGSIb3DQEB\n            AQUAA4GNADCBiQKBgQCfSLcZNHYqbxSsgWp4JvbPRHjQTrlsrKrgD2q7f/OY6O3Y\n            /X0QNcNSOJpliN8rmzwslfsrXHO3q1diGRw4xHogUJZ/7NQrHiP/zhN0VTDh49pD\n            ZpjXVyUbayLS/6qM5arKxBspzEFBb5v8cF6bPr76SO/rpGXiI0j6yJKX6fRiKwID\n            AQABo1AwTjAdBgNVHQ4EFgQU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwHwYDVR0jBBgw\n            FoAU6Jrs/Fmj0z4dA3wvdq/WqA4P49IwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0B\n            AQ0FAAOBgQB+5d5+NtzLILfrc9QmJdIO1YeDP64JmFwTER0kEUouRsb9UwknVWZa\n            y7MTM4NoBV1k0zb5LAk89SIDPr/maW5AsLtEomzjnEiomjoMBUdNe3YCgQReoLnr\n            R/QaUNbrCjTGYfBsjGbIzmkWPUyTec2ZdRyJ8JiVl386+6CZkxnndQ==\n            -----END CERTIFICATE-----\n            \"\"\", \"\"\"\n            -----BEGIN CERTIFICATE-----\n            MIIEpDCCAowCCQC43PUTWSADVjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls\n            b2NhbGhvc3QwHhcNMjIxMDE3MjA0NTM0WhcNMjMxMDE3MjA0NTM0WjAUMRIwEAYD\n            VQQDDAlsb2NhbGhvc3QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDV\n            x1cdEd2ffQTlTXWRiCHGcrlYf4RJnctt9sw/BuHWTLXBu5LhyJSGn5LRszO/NCXK\n            Z/cmGR7pLj366RtiwL+Qo3nhvDCK7T9xZeNIusM6XMcMK9D/DGCYPqtjQz8NXd9V\n            ajBBe6nwTDTa+oqX8Mt89foWNkg5Il/lY62u9Dr18LRZ2W9zzYi3Q9/K0CbIX6pM\n            yVlPIO5rITOR2IsbeyqsO9jufgX5lP4ZKLLBAP1b7usjC4YdvWacjQg/rK5aay1x\n            jC2HCDgo/4N30QVXzSA9nFfSe6AE/xkStK4819JqOkY5JsJCbef1P3hOOdSLEjbp\n            xq3MjOs6G6dOgteaAGs10vx7dHxDWETTIiD7BIZ9zRYgOF5bkCaIUO+JfySE1MHD\n            KBAFLoRuvmRev5Ln5R0MCHpUMSmMNgJqz+RWZV3g/gpYbuWiHgJOwL1393eK50Bg\n            W7SXQ8EjJj2yXZSH+1gPzN0DRoJZiaBoTPnCL2qUgvwFpW1PJsM5FDyUJFUoK5kK\n            HLBBSKAPt6ZlSrUe2nBgJv7EF1GK+fTU08LXgW33OpLceGPa0zTShkukQUMtUtZ8\n            GqhO12ohMzEupIu5Xurthq4VVUrzHUdj1ZZRMhAbfLU36sd03MMyL/xBqTN6dzCa\n            GDGIPGpYjAllZ5xMRt2kZdv+Kr6oo3u2nLUIsqI7KQIDAQABMA0GCSqGSIb3DQEB\n            CwUAA4ICAQCB5s43YF35ssf5YONW5iAaifGpi1o0866xfeOybtohFGvQ7V2W34i9\n            TYBCt8+0hgatMcvZ08f0vqig1i7nrvYcE1hnhL7JNkU8qm0s9ytHZt6j62nB0kd/\n            uqE2hOEQalTf/2TGPV0CCgiqLyd8lEUQvQeA38wktwUeZpVnErlzHeMR2CvV3K8R\n            u4vV6SnBcf+TAt56RKYZkPyvZj5llQPo14Glyoo8qZES7Ky1SHmM0GL+baPRBjRW\n            3KgSt98Wyu4yr9qu21JpnbAnLhBfzfSKjSeCRgFElUE1GIaFGRZ7ypA74dUKeLnb\n            /VUWrszmUhGaEjV9dpI6x6B/kSpQMtIQqBaKRY2ALUeEujS/rURi4iMDwSU+GkSH\n            cyEvZKS97OA/dWeXfLXdo4beDBRG93bI4rQnDg5+VdlBOkQSLueb8x6/VThMoC5d\n            vZiotFQHseljQAdTkNa6tBu6c4XDYPCKB3CfkMYOlCfTS7Acn5G6dxTPKBtLGBnL\n            nQfYyzuwYkN09+2PVzt6auBHr3To7uoclkxX+hxyvPIwIZ0N6b4tQR1FCAkvg29Q\n            WIOjZOKGW690ESKCKOnFjUHVO0HpuWnT81URTuY62FXsYdVc2wE4v0E04mEbqQ0P\n            lY6ZKNA81Lm3YADYtObmK1IUrOPo9BeIaPy0UM08SmN880Vunqa91Q==\n            -----END CERTIFICATE-----\n            \"\"\"),\n        null, null);\n\n    secureValueRecoveryClient = new SecureValueRecoveryClient(credentialsGenerator, httpExecutor, retryExecutor, config, () -> ALLOWED_ERRORS);\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    httpExecutor.shutdown();\n    httpExecutor.awaitTermination(1, TimeUnit.SECONDS);\n    retryExecutor.shutdown();\n    retryExecutor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"400, false\",\n      \"429, false\",\n      \"200, true\",\n      \"201, true\",\n      \"567, true\",\n      \"568, true\",\n  })\n  void deleteStatus(int status, boolean shouldSucceed) {\n    final String username = RandomStringUtils.secure().nextAlphabetic(16);\n    final String password = RandomStringUtils.secure().nextAlphanumeric(32);\n\n    when(credentialsGenerator.generateFor(accountUuid.toString())).thenReturn(\n        new ExternalServiceCredentials(username, password));\n\n    wireMock.stubFor(delete(urlEqualTo(SecureValueRecoveryClient.DELETE_PATH))\n        .withBasicAuth(username, password)\n        .willReturn(aResponse().withStatus(status)));\n\n    final CompletableFuture<Void> deleteFuture = secureValueRecoveryClient.removeData(accountUuid);\n    if (shouldSucceed) {\n      assertDoesNotThrow(() -> deleteFuture.join());\n    } else {\n      CompletableFutureTestUtil.assertFailsWithCause(SecureValueRecoveryException.class, deleteFuture);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountChangeValidatorTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Base64;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.function.Executable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nclass AccountChangeValidatorTest {\n\n  private static final String ORIGINAL_NUMBER = \"+18005551234\";\n  private static final String CHANGED_NUMBER = \"+18005559876\";\n\n  private static final UUID ORIGINAL_PNI = UUID.randomUUID();\n  private static final UUID CHANGED_PNI = UUID.randomUUID();\n\n  private static final String BASE_64_URL_ORIGINAL_USERNAME = \"9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE\";\n  private static final String BASE_64_URL_CHANGED_USERNAME = \"NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc\";\n  private static final byte[] ORIGINAL_USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_ORIGINAL_USERNAME);\n  private static final byte[] CHANGED_USERNAME_HASH = Base64.getUrlDecoder().decode(BASE_64_URL_CHANGED_USERNAME);\n\n  @ParameterizedTest\n  @MethodSource\n  void validateChange(final Account originalAccount,\n      final Account updatedAccount,\n      final AccountChangeValidator changeValidator,\n      final boolean expectChangeAllowed) {\n\n    final Executable applyChange = () -> changeValidator.validateChange(originalAccount, updatedAccount);\n\n    if (expectChangeAllowed) {\n      assertDoesNotThrow(applyChange);\n    } else {\n      assertThrows(AssertionError.class, applyChange);\n    }\n  }\n\n  private static Stream<Arguments> validateChange() {\n    final Account originalAccount = mock(Account.class);\n    when(originalAccount.getNumber()).thenReturn(ORIGINAL_NUMBER);\n    when(originalAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI);\n    when(originalAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH));\n\n    final Account unchangedAccount = mock(Account.class);\n    when(unchangedAccount.getNumber()).thenReturn(ORIGINAL_NUMBER);\n    when(unchangedAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI);\n    when(unchangedAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH));\n\n    final Account changedNumberAccount = mock(Account.class);\n    when(changedNumberAccount.getNumber()).thenReturn(CHANGED_NUMBER);\n    when(changedNumberAccount.getPhoneNumberIdentifier()).thenReturn(CHANGED_PNI);\n    when(changedNumberAccount.getUsernameHash()).thenReturn(Optional.of(ORIGINAL_USERNAME_HASH));\n\n    final Account changedUsernameAccount = mock(Account.class);\n    when(changedUsernameAccount.getNumber()).thenReturn(ORIGINAL_NUMBER);\n    when(changedUsernameAccount.getPhoneNumberIdentifier()).thenReturn(ORIGINAL_PNI);\n    when(changedUsernameAccount.getUsernameHash()).thenReturn(Optional.of(CHANGED_USERNAME_HASH));\n\n    return Stream.of(\n        Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, true),\n        Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.NUMBER_CHANGE_VALIDATOR, true),\n        Arguments.of(originalAccount, unchangedAccount, AccountChangeValidator.USERNAME_CHANGE_VALIDATOR, true),\n\n        Arguments.of(originalAccount, changedNumberAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, false),\n        Arguments.of(originalAccount, changedNumberAccount, AccountChangeValidator.NUMBER_CHANGE_VALIDATOR, true),\n        Arguments.of(originalAccount, changedNumberAccount, AccountChangeValidator.USERNAME_CHANGE_VALIDATOR, false),\n\n        Arguments.of(originalAccount, changedUsernameAccount, AccountChangeValidator.GENERAL_CHANGE_VALIDATOR, false),\n        Arguments.of(originalAccount, changedUsernameAccount, AccountChangeValidator.NUMBER_CHANGE_VALIDATOR, false),\n        Arguments.of(originalAccount, changedUsernameAccount, AccountChangeValidator.USERNAME_CHANGE_VALIDATOR, true)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountCreationDeletionIntegrationTest.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junitpioneer.jupiter.cartesian.ArgumentSets;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.ApnRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.GcmRegistrationId;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\npublic class AccountCreationDeletionIntegrationTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.ACCOUNTS,\n      DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS,\n      DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS_LOCK,\n      DynamoDbExtensionSchema.Tables.NUMBERS,\n      DynamoDbExtensionSchema.Tables.PNI,\n      DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS,\n      DynamoDbExtensionSchema.Tables.USERNAMES,\n      DynamoDbExtensionSchema.Tables.EC_KEYS,\n      DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS,\n      DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,\n      DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  @RegisterExtension\n  static final RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @RegisterExtension\n  static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension(\"testbucket\");\n\n  private static final Clock CLOCK = Clock.fixed(Instant.now(), ZoneId.systemDefault());\n\n  private ScheduledExecutorService executor;\n\n  private AccountsManager accountsManager;\n  private KeysManager keysManager;\n  private DisconnectionRequestManager disconnectionRequestManager;\n\n  record DeliveryChannels(boolean fetchesMessages, String apnsToken, String fcmToken) {}\n\n  @BeforeEach\n  void setUp() {\n    final DynamoDbAsyncClient dynamoDbAsyncClient = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient();\n    keysManager = new KeysManager(\n        new SingleUseECPreKeyStore(dynamoDbAsyncClient, DynamoDbExtensionSchema.Tables.EC_KEYS.tableName()),\n        new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,\n            S3_EXTENSION.getS3Client(),\n            DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),\n            S3_EXTENSION.getBucketName()),\n        new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,\n            DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),\n        new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,\n            DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));\n\n    final Accounts accounts = new Accounts(\n        CLOCK,\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.ACCOUNTS.tableName(),\n        DynamoDbExtensionSchema.Tables.NUMBERS.tableName(),\n        DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(),\n        DynamoDbExtensionSchema.Tables.USERNAMES.tableName(),\n        DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS.tableName(),\n        DynamoDbExtensionSchema.Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n    executor = Executors.newSingleThreadScheduledExecutor();\n\n    final AccountLockManager accountLockManager = new AccountLockManager(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS_LOCK.tableName());\n\n    final SecureStorageClient secureStorageClient = mock(SecureStorageClient.class);\n    when(secureStorageClient.deleteStoredData(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final SecureValueRecoveryClient svr2Client = mock(SecureValueRecoveryClient.class);\n    when(svr2Client.removeData(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n    final PhoneNumberIdentifiers phoneNumberIdentifiers =\n        new PhoneNumberIdentifiers(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n            DynamoDbExtensionSchema.Tables.PNI.tableName());\n\n    final MessagesManager messagesManager = mock(MessagesManager.class);\n    when(messagesManager.clear(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final ProfilesManager profilesManager = mock(ProfilesManager.class);\n    when(profilesManager.deleteAll(any(), anyBoolean())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n        mock(RegistrationRecoveryPasswordsManager.class);\n\n    when(registrationRecoveryPasswordsManager.remove(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    disconnectionRequestManager = mock(DisconnectionRequestManager.class);\n    when(disconnectionRequestManager.requestDisconnection(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    accountsManager = new AccountsManager(\n        accounts,\n        phoneNumberIdentifiers,\n        CACHE_CLUSTER_EXTENSION.getRedisCluster(),\n        mock(FaultTolerantRedisClient.class),\n        accountLockManager,\n        keysManager,\n        messagesManager,\n        profilesManager,\n        secureStorageClient,\n        svr2Client,\n        disconnectionRequestManager,\n        registrationRecoveryPasswordsManager,\n        executor,\n        executor,\n        executor,\n        CLOCK,\n        \"link-device-secret\".getBytes(StandardCharsets.UTF_8));\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    executor.shutdown();\n\n    //noinspection ResultOfMethodCallIgnored\n    executor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @CartesianTest\n  @CartesianTest.MethodFactory(\"createAccount\")\n  void createAccount(final DeliveryChannels deliveryChannels,\n      final boolean discoverableByPhoneNumber) throws InterruptedException {\n\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final String password = RandomStringUtils.secure().nextAlphanumeric(16);\n    final String signalAgent = RandomStringUtils.secure().nextAlphabetic(3);\n    final int registrationId = ThreadLocalRandom.current().nextInt(Device.MAX_REGISTRATION_ID);\n    final int pniRegistrationId = ThreadLocalRandom.current().nextInt(Device.MAX_REGISTRATION_ID);\n    final byte[] deviceName = RandomStringUtils.secure().nextAlphabetic(16).getBytes(StandardCharsets.UTF_8);\n    final String registrationLockSecret = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    final Set<DeviceCapability> deviceCapabilities = Set.of();\n\n    final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(),\n        registrationId,\n        pniRegistrationId,\n        deviceName,\n        registrationLockSecret,\n        discoverableByPhoneNumber,\n        deviceCapabilities);\n\n    final List<AccountBadge> badges = new ArrayList<>(List.of(new AccountBadge(\n        RandomStringUtils.secure().nextAlphabetic(8),\n        CLOCK.instant().plus(Duration.ofDays(7)),\n        true)));\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair);\n\n    final Optional<ApnRegistrationId> maybeApnRegistrationId =\n        deliveryChannels.apnsToken() != null\n            ? Optional.of(new ApnRegistrationId(deliveryChannels.apnsToken()))\n            : Optional.empty();\n\n    final Optional<GcmRegistrationId> maybeGcmRegistrationId = deliveryChannels.fcmToken() != null\n        ? Optional.of(new GcmRegistrationId(deliveryChannels.fcmToken()))\n        : Optional.empty();\n\n    final Account account = accountsManager.create(number,\n        accountAttributes,\n        badges,\n        new IdentityKey(aciKeyPair.getPublicKey()),\n        new IdentityKey(pniKeyPair.getPublicKey()),\n        new DeviceSpec(\n            deviceName,\n            password,\n            signalAgent,\n            deviceCapabilities,\n            registrationId,\n            pniRegistrationId,\n            deliveryChannels.fetchesMessages(),\n            maybeApnRegistrationId,\n            maybeGcmRegistrationId,\n            aciSignedPreKey,\n            pniSignedPreKey,\n            aciPqLastResortPreKey,\n            pniPqLastResortPreKey),\n        null);\n\n    assertExpectedStoredAccount(account,\n        number,\n        password,\n        signalAgent,\n        deliveryChannels,\n        registrationId,\n        pniRegistrationId,\n        deviceName,\n        discoverableByPhoneNumber,\n        deviceCapabilities,\n        badges,\n        maybeApnRegistrationId,\n        maybeGcmRegistrationId,\n        registrationLockSecret,\n        aciSignedPreKey,\n        pniSignedPreKey,\n        aciPqLastResortPreKey,\n        pniPqLastResortPreKey);\n\n    assertEquals(Optional.of(aciSignedPreKey), keysManager.getEcSignedPreKey(account.getUuid(), Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(pniSignedPreKey), keysManager.getEcSignedPreKey(account.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(aciPqLastResortPreKey), keysManager.getLastResort(account.getUuid(), Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(pniPqLastResortPreKey), keysManager.getLastResort(account.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join());\n  }\n\n  @SuppressWarnings(\"unused\")\n  static ArgumentSets createAccount() {\n    return ArgumentSets\n        // deliveryChannels\n        .argumentsForFirstParameter(\n            new DeliveryChannels(true, null, null),\n            new DeliveryChannels(false, \"apns-token\", null),\n            new DeliveryChannels(false, \"apns-token\", null),\n            new DeliveryChannels(false, null, \"fcm-token\"))\n\n        // discoverableByPhoneNumber\n        .argumentsForNextParameter(true, false);\n  }\n\n  @CartesianTest\n  @CartesianTest.MethodFactory(\"createAccount\")\n  void reregisterAccount(final DeliveryChannels deliveryChannels,\n      final boolean discoverableByPhoneNumber) throws InterruptedException {\n\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final UUID existingAccountUuid;\n    {\n      final ECKeyPair aciKeyPair = ECKeyPair.generate();\n      final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n      final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair);\n      final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniKeyPair);\n      final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciKeyPair);\n      final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair);\n\n      final Account existingAccount = accountsManager.create(number,\n          new AccountAttributes(true, 1, 1, \"name\".getBytes(StandardCharsets.UTF_8), \"registration-lock\", false, Set.of()),\n          Collections.emptyList(),\n          new IdentityKey(aciKeyPair.getPublicKey()),\n          new IdentityKey(pniKeyPair.getPublicKey()),\n          new DeviceSpec(null,\n              \"password?\",\n              \"OWI\",\n              Set.of(),\n              1,\n              2,\n              true,\n              Optional.empty(),\n              Optional.empty(),\n              aciSignedPreKey,\n              pniSignedPreKey,\n              aciPqLastResortPreKey,\n              pniPqLastResortPreKey),\n          null);\n\n      existingAccountUuid = existingAccount.getUuid();\n    }\n\n    final String password = RandomStringUtils.secure().nextAlphanumeric(16);\n    final String signalAgent = RandomStringUtils.secure().nextAlphabetic(3);\n    final int registrationId = ThreadLocalRandom.current().nextInt(Device.MAX_REGISTRATION_ID);\n    final int pniRegistrationId = ThreadLocalRandom.current().nextInt(Device.MAX_REGISTRATION_ID);\n    final byte[] deviceName = RandomStringUtils.secure().nextAlphabetic(16).getBytes(StandardCharsets.UTF_8);\n    final String registrationLockSecret = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    final Set<DeviceCapability> deviceCapabilities = Set.of();\n\n    final AccountAttributes accountAttributes = new AccountAttributes(deliveryChannels.fetchesMessages(),\n        registrationId,\n        pniRegistrationId,\n        deviceName,\n        registrationLockSecret,\n        discoverableByPhoneNumber,\n        deviceCapabilities);\n\n    final List<AccountBadge> badges = new ArrayList<>(List.of(new AccountBadge(\n        RandomStringUtils.secure().nextAlphabetic(8),\n        CLOCK.instant().plus(Duration.ofDays(7)),\n        true)));\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair);\n\n    final Optional<ApnRegistrationId> maybeApnRegistrationId =\n        deliveryChannels.apnsToken() != null\n            ? Optional.of(new ApnRegistrationId(deliveryChannels.apnsToken()))\n            : Optional.empty();\n\n    final Optional<GcmRegistrationId> maybeGcmRegistrationId = deliveryChannels.fcmToken() != null\n        ? Optional.of(new GcmRegistrationId(deliveryChannels.fcmToken()))\n        : Optional.empty();\n\n    final Account reregisteredAccount = accountsManager.create(number,\n        accountAttributes,\n        badges,\n        new IdentityKey(aciKeyPair.getPublicKey()),\n        new IdentityKey(pniKeyPair.getPublicKey()),\n        new DeviceSpec(deviceName,\n            password,\n            signalAgent,\n            deviceCapabilities,\n            registrationId,\n            pniRegistrationId,\n            accountAttributes.getFetchesMessages(),\n            maybeApnRegistrationId,\n            maybeGcmRegistrationId,\n            aciSignedPreKey,\n            pniSignedPreKey,\n            aciPqLastResortPreKey,\n            pniPqLastResortPreKey),\n        null);\n\n    assertExpectedStoredAccount(reregisteredAccount,\n        number,\n        password,\n        signalAgent,\n        deliveryChannels,\n        registrationId,\n        pniRegistrationId,\n        deviceName,\n        discoverableByPhoneNumber,\n        deviceCapabilities,\n        badges,\n        maybeApnRegistrationId,\n        maybeGcmRegistrationId,\n        registrationLockSecret,\n        aciSignedPreKey,\n        pniSignedPreKey,\n        aciPqLastResortPreKey,\n        pniPqLastResortPreKey);\n\n    assertEquals(existingAccountUuid, reregisteredAccount.getUuid());\n\n    verify(disconnectionRequestManager).requestDisconnection(argThat(account ->\n        account.getIdentifier(IdentityType.ACI).equals(existingAccountUuid) && account != reregisteredAccount));\n  }\n\n  @Test\n  void deleteAccount() throws InterruptedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final String password = RandomStringUtils.secure().nextAlphanumeric(16);\n    final String signalAgent = RandomStringUtils.secure().nextAlphabetic(3);\n    final int registrationId = ThreadLocalRandom.current().nextInt(Device.MAX_REGISTRATION_ID);\n    final int pniRegistrationId = ThreadLocalRandom.current().nextInt(Device.MAX_REGISTRATION_ID);\n    final byte[] deviceName = RandomStringUtils.secure().nextAlphabetic(16).getBytes(StandardCharsets.UTF_8);\n    final String registrationLockSecret = RandomStringUtils.secure().nextAlphanumeric(16);\n\n    final Set<DeviceCapability> deviceCapabilities = Set.of();\n\n    final AccountAttributes accountAttributes = new AccountAttributes(true,\n        registrationId,\n        pniRegistrationId,\n        deviceName,\n        registrationLockSecret,\n        true,\n        deviceCapabilities);\n\n    final List<AccountBadge> badges = new ArrayList<>(List.of(new AccountBadge(\n        RandomStringUtils.secure().nextAlphabetic(8),\n        CLOCK.instant().plus(Duration.ofDays(7)),\n        true)));\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair);\n\n    final Account account = accountsManager.create(number,\n        accountAttributes,\n        badges,\n        new IdentityKey(aciKeyPair.getPublicKey()),\n        new IdentityKey(pniKeyPair.getPublicKey()),\n        new DeviceSpec(\n            deviceName,\n            password,\n            signalAgent,\n            deviceCapabilities,\n            registrationId,\n            pniRegistrationId,\n            true,\n            Optional.empty(),\n            Optional.empty(),\n            aciSignedPreKey,\n            pniSignedPreKey,\n            aciPqLastResortPreKey,\n            pniPqLastResortPreKey),\n        null);\n\n    final UUID aci = account.getIdentifier(IdentityType.ACI);\n\n    assertTrue(accountsManager.getByAccountIdentifier(aci).isPresent());\n\n    accountsManager.delete(account, AccountsManager.DeletionReason.ADMIN_DELETED);\n\n    assertFalse(accountsManager.getByAccountIdentifier(aci).isPresent());\n    assertFalse(keysManager.getEcSignedPreKey(account.getUuid(), Device.PRIMARY_ID).join().isPresent());\n    assertFalse(keysManager.getEcSignedPreKey(account.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join().isPresent());\n    assertFalse(keysManager.getLastResort(account.getUuid(), Device.PRIMARY_ID).join().isPresent());\n    assertFalse(keysManager.getLastResort(account.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join().isPresent());\n\n    verify(disconnectionRequestManager).requestDisconnection(account);\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  private void assertExpectedStoredAccount(final Account account,\n      final String number,\n      final String password,\n      final String signalAgent,\n      final DeliveryChannels deliveryChannels,\n      final int registrationId,\n      final int pniRegistrationId,\n      final byte[] deviceName,\n      final boolean discoverableByPhoneNumber,\n      final Set<DeviceCapability> deviceCapabilities,\n      final List<AccountBadge> badges,\n      final Optional<ApnRegistrationId> maybeApnRegistrationId,\n      final Optional<GcmRegistrationId> maybeGcmRegistrationId,\n      final String registrationLockSecret,\n      final ECSignedPreKey aciSignedPreKey,\n      final ECSignedPreKey pniSignedPreKey,\n      final KEMSignedPreKey aciPqLastResortPreKey,\n      final KEMSignedPreKey pniPqLastResortPreKey) {\n\n    final Device primaryDevice = account.getPrimaryDevice();\n\n    assertEquals(number, account.getNumber());\n    assertEquals(signalAgent, primaryDevice.getUserAgent());\n    assertEquals(deliveryChannels.fetchesMessages(), primaryDevice.getFetchesMessages());\n    assertEquals(registrationId, primaryDevice.getRegistrationId(IdentityType.ACI));\n    assertEquals(pniRegistrationId, primaryDevice.getRegistrationId(IdentityType.PNI));\n    assertArrayEquals(deviceName, primaryDevice.getName());\n    assertEquals(discoverableByPhoneNumber, account.isDiscoverableByPhoneNumber());\n    assertEquals(deviceCapabilities, primaryDevice.getCapabilities());\n    assertEquals(badges, account.getBadges());\n\n    maybeApnRegistrationId.ifPresentOrElse(\n        apnRegistrationId -> assertEquals(apnRegistrationId.apnRegistrationId(), primaryDevice.getApnId()),\n        () -> assertNull(primaryDevice.getApnId()));\n\n    maybeGcmRegistrationId.ifPresentOrElse(\n        gcmRegistrationId -> assertEquals(deliveryChannels.fcmToken(), primaryDevice.getGcmId()),\n        () -> assertNull(primaryDevice.getGcmId()));\n\n    assertTrue(account.getRegistrationLock().verify(registrationLockSecret));\n    assertTrue(primaryDevice.getAuthTokenHash().verify(password));\n    assertNotNull(primaryDevice.getCreatedAtCiphertext());\n    assertEquals(Optional.of(aciSignedPreKey), keysManager.getEcSignedPreKey(account.getIdentifier(IdentityType.ACI), Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(pniSignedPreKey), keysManager.getEcSignedPreKey(account.getIdentifier(IdentityType.PNI), Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(aciPqLastResortPreKey), keysManager.getLastResort(account.getIdentifier(IdentityType.ACI), Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(pniPqLastResortPreKey), keysManager.getLastResort(account.getIdentifier(IdentityType.PNI), Device.PRIMARY_ID).join());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountLockManagerTest.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\n\nimport com.amazonaws.services.dynamodbv2.AmazonDynamoDBLockClient;\nimport com.amazonaws.services.dynamodbv2.ReleaseLockOptions;\nimport java.util.Collections;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\n\nclass AccountLockManagerTest {\n\n  private AmazonDynamoDBLockClient lockClient;\n  private ExecutorService executor;\n\n  private AccountLockManager accountLockManager;\n\n  private static final UUID FIRST_PNI = UUID.randomUUID();\n  private static final UUID SECOND_PNI = UUID.randomUUID();\n\n  @BeforeEach\n  void setUp() {\n    lockClient = mock(AmazonDynamoDBLockClient.class);\n    executor = Executors.newSingleThreadExecutor();\n\n    accountLockManager = new AccountLockManager(lockClient);\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    executor.shutdown();\n\n    //noinspection ResultOfMethodCallIgnored\n    executor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void withLock() throws Exception {\n    accountLockManager.withLock(Set.of(FIRST_PNI, SECOND_PNI), () -> null, executor);\n\n    verify(lockClient, times(2)).acquireLock(any());\n    verify(lockClient, times(2)).releaseLock(any(ReleaseLockOptions.class));\n  }\n\n  @Test\n  void withLockTaskThrowsException() throws InterruptedException {\n    assertThrows(RuntimeException.class, () -> accountLockManager.withLock(Set.of(FIRST_PNI, SECOND_PNI), () -> {\n          throw new RuntimeException();\n    }, executor));\n\n    verify(lockClient, times(2)).acquireLock(any());\n    verify(lockClient, times(2)).releaseLock(any(ReleaseLockOptions.class));\n  }\n\n  @Test\n  void withLockEmptyList() {\n    final Runnable task = mock(Runnable.class);\n\n    assertThrows(IllegalArgumentException.class, () -> accountLockManager.withLock(Collections.emptySet(), () -> null,\n        executor));\n    verify(task, never()).run();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.tests.util.DevicesHelper.createDevice;\n\nimport com.fasterxml.jackson.annotation.JsonFilter;\nimport java.lang.annotation.Annotation;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Instant;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass AccountTest {\n\n  private final Device oldPrimaryDevice = mock(Device.class);\n  private final Device recentPrimaryDevice = mock(Device.class);\n  private final Device agingSecondaryDevice = mock(Device.class);\n  private final Device recentSecondaryDevice = mock(Device.class);\n  private final Device oldSecondaryDevice = mock(Device.class);\n\n  @BeforeEach\n  void setup() {\n    when(oldPrimaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(366));\n    when(oldPrimaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n\n    when(recentPrimaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1));\n    when(recentPrimaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n\n    when(agingSecondaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(31));\n    final byte deviceId2 = 2;\n    when(agingSecondaryDevice.getId()).thenReturn(deviceId2);\n\n    when(recentSecondaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1));\n    when(recentSecondaryDevice.getId()).thenReturn(deviceId2);\n\n    when(oldSecondaryDevice.getLastSeen()).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(366));\n    when(oldSecondaryDevice.getId()).thenReturn(deviceId2);\n  }\n\n  @Test\n  void testIsTransferSupported() {\n    final Device transferCapablePrimaryDevice = mock(Device.class);\n    final Device nonTransferCapablePrimaryDevice = mock(Device.class);\n    final Device transferCapableLinkedDevice = mock(Device.class);\n\n    when(transferCapablePrimaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(transferCapablePrimaryDevice.isPrimary()).thenReturn(true);\n    when(transferCapablePrimaryDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(true);\n\n    when(nonTransferCapablePrimaryDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(nonTransferCapablePrimaryDevice.isPrimary()).thenReturn(true);\n    when(nonTransferCapablePrimaryDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(false);\n\n    when(transferCapableLinkedDevice.getId()).thenReturn((byte) 2);\n    when(transferCapableLinkedDevice.isPrimary()).thenReturn(false);\n    when(transferCapableLinkedDevice.hasCapability(DeviceCapability.TRANSFER)).thenReturn(true);\n\n    {\n      final Account transferablePrimaryAccount =\n              AccountsHelper.generateTestAccount(\"+14152222222\", UUID.randomUUID(), UUID.randomUUID(), List.of(transferCapablePrimaryDevice), \"1234\".getBytes());\n\n      assertTrue(transferablePrimaryAccount.hasCapability(DeviceCapability.TRANSFER));\n    }\n\n    {\n      final Account nonTransferablePrimaryAccount =\n              AccountsHelper.generateTestAccount(\"+14152222222\", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapablePrimaryDevice), \"1234\".getBytes());\n\n      assertFalse(nonTransferablePrimaryAccount.hasCapability(DeviceCapability.TRANSFER));\n    }\n\n    {\n      final Account transferableLinkedAccount = AccountsHelper.generateTestAccount(\"+14152222222\", UUID.randomUUID(), UUID.randomUUID(), List.of(nonTransferCapablePrimaryDevice, transferCapableLinkedDevice), \"1234\".getBytes());\n\n      assertFalse(transferableLinkedAccount.hasCapability(DeviceCapability.TRANSFER));\n    }\n  }\n\n  @Test\n  void testDiscoverableByPhoneNumber() {\n    final Account account = AccountsHelper.generateTestAccount(\"+14152222222\", UUID.randomUUID(), UUID.randomUUID(), List.of(recentPrimaryDevice),\n        \"1234\".getBytes());\n\n    assertTrue(account.isDiscoverableByPhoneNumber(),\n        \"Freshly-loaded legacy accounts should be discoverable by phone number.\");\n\n    account.setDiscoverableByPhoneNumber(false);\n    assertFalse(account.isDiscoverableByPhoneNumber());\n\n    account.setDiscoverableByPhoneNumber(true);\n    assertTrue(account.isDiscoverableByPhoneNumber());\n  }\n\n  @Test\n  void hardcodedCapabilities() {\n    final Device mockDevice = mock(Device.class);\n    when(mockDevice.getId()).thenReturn(Device.PRIMARY_ID);\n\n    assertFalse(AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(),\n        List.of(mockDevice),\n        \"1234\".getBytes(StandardCharsets.UTF_8)).hasCapability(DeviceCapability.TRANSFER));\n  }\n\n  void stale() {\n    final Account account = AccountsHelper.generateTestAccount(\"+14151234567\", UUID.randomUUID(), UUID.randomUUID(), Collections.emptyList(),\n        new byte[0]);\n\n    assertDoesNotThrow(account::getNumber);\n\n    account.markStale();\n\n    assertThrows(AssertionError.class, account::getNumber);\n    assertDoesNotThrow(account::getUuid);\n  }\n\n  @Test\n  void getNextDeviceId() {\n\n    final List<Device> devices = List.of(createDevice(Device.PRIMARY_ID));\n\n    final Account account = AccountsHelper.generateTestAccount(\"+14151234567\", UUID.randomUUID(), UUID.randomUUID(), devices, new byte[0]);\n\n    final byte deviceId2 = 2;\n    assertThat(account.getNextDeviceId()).isEqualTo(deviceId2);\n\n    account.addDevice(createDevice(deviceId2));\n\n    final byte deviceId3 = 3;\n    assertThat(account.getNextDeviceId()).isEqualTo(deviceId3);\n\n    account.removeDevice(deviceId2);\n\n    assertThat(account.getNextDeviceId()).isEqualTo(deviceId2);\n\n    while (account.getNextDeviceId() < Device.MAXIMUM_DEVICE_ID) {\n      account.addDevice(createDevice(account.getNextDeviceId()));\n    }\n\n    account.addDevice(createDevice(Device.MAXIMUM_DEVICE_ID));\n\n    assertThatThrownBy(account::getNextDeviceId).isInstanceOf(RuntimeException.class);\n  }\n\n  @Test\n  void replaceDevice() {\n    final Device firstDevice = createDevice(Device.PRIMARY_ID);\n    final Device secondDevice = createDevice(Device.PRIMARY_ID);\n    final Account account = AccountsHelper.generateTestAccount(\"+14151234567\", UUID.randomUUID(), UUID.randomUUID(), List.of(firstDevice), new byte[0]);\n\n    assertEquals(List.of(firstDevice), account.getDevices());\n\n    account.addDevice(secondDevice);\n\n    assertEquals(List.of(secondDevice), account.getDevices());\n  }\n\n  @Test\n  void addAndRemoveBadges() {\n    final Account account = AccountsHelper.generateTestAccount(\"+14151234567\", UUID.randomUUID(), UUID.randomUUID(), List.of(createDevice(Device.PRIMARY_ID)), new byte[0]);\n    final TestClock clock = TestClock.pinned(Instant.ofEpochSecond(40));\n\n    account.addBadge(clock, new AccountBadge(\"foo\", Instant.ofEpochSecond(42), false));\n    account.addBadge(clock, new AccountBadge(\"bar\", Instant.ofEpochSecond(44), true));\n    account.addBadge(clock, new AccountBadge(\"baz\", Instant.ofEpochSecond(46), true));\n\n    assertThat(account.getBadges()).hasSize(3);\n\n    account.removeBadge(clock, \"baz\");\n\n    assertThat(account.getBadges()).hasSize(2);\n\n    account.addBadge(clock, new AccountBadge(\"foo\", Instant.ofEpochSecond(50), false));\n\n    assertThat(account.getBadges()).hasSize(2).element(0).satisfies(badge -> {\n      assertThat(badge.id()).isEqualTo(\"foo\");\n      assertThat(badge.expiration().getEpochSecond()).isEqualTo(50);\n      assertThat(badge.visible()).isFalse();\n    });\n\n    account.addBadge(clock, new AccountBadge(\"foo\", Instant.ofEpochSecond(51), true));\n\n    assertThat(account.getBadges()).hasSize(2).element(0).satisfies(badge -> {\n      assertThat(badge.id()).isEqualTo(\"foo\");\n      assertThat(badge.expiration().getEpochSecond()).isEqualTo(51);\n      assertThat(badge.visible()).isTrue();\n    });\n\n    clock.pin(Instant.ofEpochSecond(52));\n\n    // for a merged badge, visible = true is preferred\n    account.addBadge(clock, new AccountBadge(\"foo\", Instant.ofEpochSecond(53), false));\n\n    assertThat(account.getBadges()).hasSize(1).element(0).satisfies(badge -> {\n      assertThat(badge.id()).isEqualTo(\"foo\");\n      assertThat(badge.expiration().getEpochSecond()).isEqualTo(53);\n      assertThat(badge.visible()).isTrue();\n    });\n  }\n\n  @Test\n  public void testAccountClassJsonFilterIdMatchesClassName() {\n    // Some logic relies on the @JsonFilter name being equal to the class name.\n    // This test is just making sure that annotation is there and that the ID matches class name.\n    final Optional<Annotation> maybeJsonFilterAnnotation = Arrays.stream(Account.class.getAnnotations())\n        .filter(a -> a.annotationType().equals(JsonFilter.class))\n        .findFirst();\n    assertTrue(maybeJsonFilterAnnotation.isPresent());\n    final JsonFilter jsonFilterAnnotation = (JsonFilter) maybeJsonFilterAnnotation.get();\n    assertEquals(Account.class.getSimpleName(), jsonFilterAnnotation.value());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerChangeNumberIntegrationTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\nclass AccountsManagerChangeNumberIntegrationTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      Tables.ACCOUNTS,\n      Tables.DELETED_ACCOUNTS,\n      Tables.DELETED_ACCOUNTS_LOCK,\n      Tables.NUMBERS,\n      Tables.PNI,\n      Tables.PNI_ASSIGNMENTS,\n      Tables.USERNAMES,\n      Tables.EC_KEYS,\n      Tables.PAGED_PQ_KEYS,\n      Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,\n      Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  @RegisterExtension\n  static final RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @RegisterExtension\n  static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension(\"testbucket\");\n\n  private KeysManager keysManager;\n  private DisconnectionRequestManager disconnectionRequestManager;\n  private ScheduledExecutorService executor;\n\n  private AccountsManager accountsManager;\n\n  @BeforeEach\n  void setup() throws InterruptedException {\n\n    {\n      final DynamoDbAsyncClient dynamoDbAsyncClient = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient();\n      keysManager = new KeysManager(\n          new SingleUseECPreKeyStore(dynamoDbAsyncClient, DynamoDbExtensionSchema.Tables.EC_KEYS.tableName()),\n          new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,\n              S3_EXTENSION.getS3Client(),\n              DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),\n              S3_EXTENSION.getBucketName()),\n          new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,\n              DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),\n          new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,\n              DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));\n\n      final Accounts accounts = new Accounts(\n          Clock.systemUTC(),\n          DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n          DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n          Tables.ACCOUNTS.tableName(),\n          Tables.NUMBERS.tableName(),\n          Tables.PNI_ASSIGNMENTS.tableName(),\n          Tables.USERNAMES.tableName(),\n          Tables.DELETED_ACCOUNTS.tableName(),\n          Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n      executor = Executors.newSingleThreadScheduledExecutor();\n\n      final AccountLockManager accountLockManager = new AccountLockManager(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n          Tables.DELETED_ACCOUNTS_LOCK.tableName());\n\n      final SecureStorageClient secureStorageClient = mock(SecureStorageClient.class);\n      when(secureStorageClient.deleteStoredData(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n      final SecureValueRecoveryClient svr2Client = mock(SecureValueRecoveryClient.class);\n      when(svr2Client.removeData(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n      disconnectionRequestManager = mock(DisconnectionRequestManager.class);\n\n      final PhoneNumberIdentifiers phoneNumberIdentifiers =\n          new PhoneNumberIdentifiers(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), Tables.PNI.tableName());\n\n      final MessagesManager messagesManager = mock(MessagesManager.class);\n      when(messagesManager.clear(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n      final ProfilesManager profilesManager = mock(ProfilesManager.class);\n      when(profilesManager.deleteAll(any(), anyBoolean())).thenReturn(CompletableFuture.completedFuture(null));\n\n      final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n          mock(RegistrationRecoveryPasswordsManager.class);\n\n      when(registrationRecoveryPasswordsManager.remove(any()))\n          .thenReturn(CompletableFuture.completedFuture(null));\n\n      accountsManager = new AccountsManager(\n          accounts,\n          phoneNumberIdentifiers,\n          CACHE_CLUSTER_EXTENSION.getRedisCluster(),\n          mock(FaultTolerantRedisClient.class),\n          accountLockManager,\n          keysManager,\n          messagesManager,\n          profilesManager,\n          secureStorageClient,\n          svr2Client,\n          disconnectionRequestManager,\n          registrationRecoveryPasswordsManager,\n          executor,\n          executor,\n          executor,\n          mock(Clock.class),\n          \"link-device-secret\".getBytes(StandardCharsets.UTF_8));\n    }\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    executor.shutdown();\n\n    //noinspection ResultOfMethodCallIgnored\n    executor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void testChangeNumber() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+18005551111\";\n    final String secondNumber = \"+18005552222\";\n    final Account account = AccountsHelper.createAccount(accountsManager, originalNumber);\n\n    final UUID originalUuid = account.getUuid();\n    final UUID originalPni = account.getPhoneNumberIdentifier();\n\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    accountsManager.changeNumber(account,\n        secondNumber,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    assertTrue(accountsManager.getByE164(originalNumber).isEmpty());\n\n    final Account updatedAccount = accountsManager.getByE164(secondNumber).orElseThrow();\n    assertEquals(originalUuid, updatedAccount.getUuid());\n    assertEquals(secondNumber, updatedAccount.getNumber());\n    assertNotEquals(originalPni, updatedAccount.getPhoneNumberIdentifier());\n\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(updatedAccount.getPhoneNumberIdentifier()));\n  }\n\n  @Test\n  void testChangeNumberSameNumber() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+18005551111\";\n    final Account account = AccountsHelper.createAccount(accountsManager, originalNumber);\n\n    final UUID originalUuid = account.getUuid();\n    final UUID originalPni = account.getPhoneNumberIdentifier();\n\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    accountsManager.changeNumber(account,\n        originalNumber,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    final Account updatedAccount = accountsManager.getByE164(originalNumber).orElseThrow();\n    assertEquals(originalUuid, updatedAccount.getUuid());\n    assertEquals(originalNumber, updatedAccount.getNumber());\n    assertEquals(originalPni, updatedAccount.getPhoneNumberIdentifier());\n\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(updatedAccount.getPhoneNumberIdentifier()));\n  }\n\n  @Test\n  void testChangeNumberWithPniExtensions() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+18005551111\";\n    final String secondNumber = \"+18005552222\";\n    final int rotatedPniRegistrationId = 17;\n    final ECKeyPair rotatedPniIdentityKeyPair = ECKeyPair.generate();\n    final ECSignedPreKey rotatedSignedPreKey = KeysHelper.signedECPreKey(1L, rotatedPniIdentityKeyPair);\n    final KEMSignedPreKey rotatedKemSignedPreKey = KeysHelper.signedKEMPreKey(2L, rotatedPniIdentityKeyPair);\n    final AccountAttributes accountAttributes = new AccountAttributes(true, rotatedPniRegistrationId + 1, rotatedPniRegistrationId, \"test\".getBytes(StandardCharsets.UTF_8), null, true, Set.of());\n    final Account account = AccountsHelper.createAccount(accountsManager, originalNumber, accountAttributes);\n\n    keysManager.storeEcSignedPreKeys(account.getIdentifier(IdentityType.ACI),\n        Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, rotatedPniIdentityKeyPair)).join();\n\n    final UUID originalUuid = account.getUuid();\n    final UUID originalPni = account.getPhoneNumberIdentifier();\n\n    final IdentityKey pniIdentityKey = new IdentityKey(rotatedPniIdentityKeyPair.getPublicKey());\n    final Map<Byte, ECSignedPreKey> preKeys = Map.of(Device.PRIMARY_ID, rotatedSignedPreKey);\n    final Map<Byte, KEMSignedPreKey> kemSignedPreKeys = Map.of(Device.PRIMARY_ID, rotatedKemSignedPreKey);\n    final Map<Byte, Integer> registrationIds = Map.of(Device.PRIMARY_ID, rotatedPniRegistrationId);\n\n    final Account updatedAccount = accountsManager.changeNumber(account, secondNumber, pniIdentityKey, preKeys, kemSignedPreKeys, registrationIds);\n    final UUID secondPni = updatedAccount.getPhoneNumberIdentifier();\n\n    assertTrue(accountsManager.getByE164(originalNumber).isEmpty());\n\n    assertTrue(accountsManager.getByE164(secondNumber).isPresent());\n    assertEquals(originalUuid, accountsManager.getByE164(secondNumber).map(Account::getUuid).orElseThrow());\n    assertNotEquals(originalPni, secondPni);\n    assertEquals(secondPni, accountsManager.getByE164(secondNumber).map(Account::getPhoneNumberIdentifier).orElseThrow());\n\n    assertEquals(secondNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow());\n\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(secondPni));\n\n    assertEquals(pniIdentityKey, updatedAccount.getIdentityKey(IdentityType.PNI));\n    assertEquals(rotatedPniRegistrationId, updatedAccount.getPrimaryDevice().getRegistrationId(IdentityType.PNI));\n\n    assertEquals(Optional.of(rotatedSignedPreKey),\n        keysManager.getEcSignedPreKey(updatedAccount.getIdentifier(IdentityType.PNI), Device.PRIMARY_ID).join());\n  }\n\n  @Test\n  void testChangeNumberReturnToOriginal() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+18005551111\";\n    final String secondNumber = \"+18005552222\";\n\n    Account account = AccountsHelper.createAccount(accountsManager, originalNumber);\n\n    final UUID originalUuid = account.getUuid();\n    final UUID originalPni = account.getPhoneNumberIdentifier();\n\n    final ECKeyPair originalIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair secondIdentityKeyPair = ECKeyPair.generate();\n\n    account = accountsManager.changeNumber(account,\n        secondNumber,\n        new IdentityKey(secondIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, secondIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, secondIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    final UUID secondPni = account.getPhoneNumberIdentifier();\n\n    accountsManager.changeNumber(account,\n        originalNumber,\n        new IdentityKey(originalIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(3, originalIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(4, originalIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 2));\n\n    assertTrue(accountsManager.getByE164(originalNumber).isPresent());\n    assertEquals(originalUuid, accountsManager.getByE164(originalNumber).map(Account::getUuid).orElseThrow());\n    assertEquals(originalPni, accountsManager.getByE164(originalNumber).map(Account::getPhoneNumberIdentifier).orElseThrow());\n\n    assertTrue(accountsManager.getByE164(secondNumber).isEmpty());\n\n    assertEquals(originalNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow());\n\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(secondPni));\n  }\n\n  @Test\n  void testChangeNumberContested() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+18005551111\";\n    final String secondNumber = \"+18005552222\";\n\n    final Account account = AccountsHelper.createAccount(accountsManager, originalNumber);\n\n    final UUID originalUuid = account.getUuid();\n    final UUID originalPni = account.getPhoneNumberIdentifier();\n\n    final ECKeyPair originalIdentityKeyPair = ECKeyPair.generate();\n    final ECKeyPair secondIdentityKeyPair = ECKeyPair.generate();\n\n    final Account existingAccount = AccountsHelper.createAccount(accountsManager, secondNumber);\n\n    final UUID existingAccountUuid = existingAccount.getUuid();\n\n    accountsManager.changeNumber(account,\n        secondNumber,\n        new IdentityKey(secondIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, secondIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, secondIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    final UUID secondPni = accountsManager.getByE164(secondNumber).get().getPhoneNumberIdentifier();\n\n    assertTrue(accountsManager.getByE164(originalNumber).isEmpty());\n\n    assertTrue(accountsManager.getByE164(secondNumber).isPresent());\n    assertEquals(Optional.of(originalUuid), accountsManager.getByE164(secondNumber).map(Account::getUuid));\n\n    assertEquals(secondNumber, accountsManager.getByAccountIdentifier(originalUuid).map(Account::getNumber).orElseThrow());\n\n    verify(disconnectionRequestManager).requestDisconnection(argThat(disconnectedAccount ->\n        disconnectedAccount.getIdentifier(IdentityType.ACI).equals(existingAccountUuid) && disconnectedAccount != account));\n\n    assertEquals(Optional.of(existingAccountUuid), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(secondPni));\n\n    accountsManager.changeNumber(accountsManager.getByAccountIdentifier(originalUuid).orElseThrow(),\n        originalNumber,\n        new IdentityKey(originalIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, originalIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, originalIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    final Account existingAccount2 = AccountsHelper.createAccount(accountsManager, secondNumber);\n\n    assertEquals(existingAccountUuid, existingAccount2.getUuid());\n  }\n\n  @Test\n  void testChangeNumberChaining() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+18005551111\";\n    final String secondNumber = \"+18005552222\";\n\n    final Account account = AccountsHelper.createAccount(accountsManager, originalNumber);\n\n    final UUID originalUuid = account.getUuid();\n    final UUID originalPni = account.getPhoneNumberIdentifier();\n\n    final Account existingAccount = AccountsHelper.createAccount(accountsManager, secondNumber);\n\n    final UUID existingAccountUuid = existingAccount.getUuid();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final Account changedNumberAccount = accountsManager.changeNumber(account,\n        secondNumber,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    final UUID secondPni = changedNumberAccount.getPhoneNumberIdentifier();\n\n    final Account reRegisteredAccount = AccountsHelper.createAccount(accountsManager, originalNumber);\n\n    assertEquals(existingAccountUuid, reRegisteredAccount.getUuid());\n    assertEquals(originalPni, reRegisteredAccount.getPhoneNumberIdentifier());\n\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(secondPni));\n\n    final ECKeyPair reRegisteredPniIdentityKeyPair = ECKeyPair.generate();\n\n    final Account changedNumberReRegisteredAccount = accountsManager.changeNumber(reRegisteredAccount,\n        secondNumber,\n        new IdentityKey(reRegisteredPniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, reRegisteredPniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, reRegisteredPniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    assertEquals(Optional.of(originalUuid), accountsManager.findRecentlyDeletedAccountIdentifier(originalPni));\n    assertEquals(Optional.empty(), accountsManager.findRecentlyDeletedAccountIdentifier(secondPni));\n    assertEquals(secondPni, changedNumberReRegisteredAccount.getPhoneNumberIdentifier());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerConcurrentModificationIntegrationTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertAll;\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anySet;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.atLeast;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.LinkedBlockingDeque;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ThreadPoolExecutor;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.stubbing.Answer;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.tests.util.JsonHelpers;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.ThrowingSupplier;\n\n\nclass AccountsManagerConcurrentModificationIntegrationTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      Tables.ACCOUNTS,\n      Tables.NUMBERS,\n      Tables.PNI_ASSIGNMENTS,\n      Tables.DELETED_ACCOUNTS,\n      Tables.EC_KEYS,\n      Tables.PAGED_PQ_KEYS,\n      Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,\n      Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  private Accounts accounts;\n\n  private AccountsManager accountsManager;\n\n  private RedisAdvancedClusterCommands<String, String> commands;\n\n  private Executor mutationExecutor = new ThreadPoolExecutor(20, 20, 5, TimeUnit.SECONDS, new LinkedBlockingDeque<>(20));\n\n  @BeforeEach\n  void setup() throws Exception {\n\n    accounts = new Accounts(\n        Clock.systemUTC(),\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n    {\n      //noinspection unchecked\n      commands = mock(RedisAdvancedClusterCommands.class);\n\n      final AccountLockManager accountLockManager = mock(AccountLockManager.class);\n\n      doAnswer(invocation -> {\n        final ThrowingSupplier<?, ?> task = invocation.getArgument(1);\n        return task.get();\n      }).when(accountLockManager).withLock(anySet(), any(), any());\n\n      final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class);\n      when(phoneNumberIdentifiers.getPhoneNumberIdentifier(anyString()))\n          .thenAnswer((Answer<CompletableFuture<UUID>>) _ -> CompletableFuture.completedFuture(UUID.randomUUID()));\n\n      accountsManager = new AccountsManager(\n          accounts,\n          phoneNumberIdentifiers,\n          RedisClusterHelper.builder().stringCommands(commands).build(),\n          mock(FaultTolerantRedisClient.class),\n          accountLockManager,\n          mock(KeysManager.class),\n          mock(MessagesManager.class),\n          mock(ProfilesManager.class),\n          mock(SecureStorageClient.class),\n          mock(SecureValueRecoveryClient.class),\n          mock(DisconnectionRequestManager.class),\n          mock(RegistrationRecoveryPasswordsManager.class),\n          mock(Executor.class),\n          mock(ScheduledExecutorService.class),\n          mock(ScheduledExecutorService.class),\n          mock(Clock.class),\n          \"link-device-secret\".getBytes(StandardCharsets.UTF_8)\n      );\n    }\n  }\n\n  @Test\n  void testConcurrentUpdate() throws IOException, InterruptedException {\n    final UUID uuid;\n    {\n      final ECKeyPair aciKeyPair = ECKeyPair.generate();\n      final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n      final Account account = accountsManager.update(\n          accountsManager.create(\"+14155551212\",\n              new AccountAttributes(),\n              new ArrayList<>(),\n              new IdentityKey(aciKeyPair.getPublicKey()),\n              new IdentityKey(pniKeyPair.getPublicKey()),\n              new DeviceSpec(\n                  null,\n                  \"password\",\n                  null,\n                  Set.of(),\n                  1,\n                  2,\n                  true,\n                  Optional.empty(),\n                  Optional.empty(),\n                  KeysHelper.signedECPreKey(1, aciKeyPair),\n                  KeysHelper.signedECPreKey(2, pniKeyPair),\n                  KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                  KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n              null),\n          a -> {\n            a.setUnidentifiedAccessKey(new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n            a.removeDevice(Device.PRIMARY_ID);\n            a.addDevice(DevicesHelper.createDevice(Device.PRIMARY_ID));\n          });\n\n      uuid = account.getUuid();\n    }\n\n    final boolean discoverableByPhoneNumber = false;\n    final String currentProfileVersion = \"cpv\";\n    final IdentityKey identityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n    final byte[] unidentifiedAccessKey = new byte[]{1};\n    final String pin = \"1234\";\n    final String registrationLock = \"reglock\";\n    final SaltedTokenHash credentials = SaltedTokenHash.generateFor(registrationLock);\n    final boolean unrestrictedUnidentifiedAccess = true;\n    final long lastSeen = Instant.now().getEpochSecond();\n\n    CompletableFuture.allOf(\n        modifyAccount(uuid, account -> account.setDiscoverableByPhoneNumber(discoverableByPhoneNumber)),\n        modifyAccount(uuid, account -> account.setCurrentProfileVersion(currentProfileVersion)),\n        modifyAccount(uuid, account -> account.setIdentityKey(identityKey)),\n        modifyAccount(uuid, account -> account.setUnidentifiedAccessKey(unidentifiedAccessKey)),\n        modifyAccount(uuid, account -> account.setRegistrationLock(credentials.hash(), credentials.salt())),\n        modifyAccount(uuid, account -> account.setUnrestrictedUnidentifiedAccess(unrestrictedUnidentifiedAccess)),\n        modifyDevice(uuid, Device.PRIMARY_ID, device -> device.setLastSeen(lastSeen)),\n        modifyDevice(uuid, Device.PRIMARY_ID, device -> device.setName(\"deviceName\".getBytes(StandardCharsets.UTF_8)))\n    ).join();\n\n    final Account managerAccount = accountsManager.getByAccountIdentifier(uuid).orElseThrow();\n    final Account dynamoAccount = accounts.getByAccountIdentifier(uuid).orElseThrow();\n\n    final Account redisAccount = getLastAccountFromRedisMock(commands);\n\n    Stream.of(\n        new Pair<>(\"manager\", managerAccount),\n        new Pair<>(\"dynamo\", dynamoAccount),\n        new Pair<>(\"redis\", redisAccount)\n    ).forEach(pair ->\n        verifyAccount(pair.first(), pair.second(), discoverableByPhoneNumber,\n            currentProfileVersion, identityKey, unidentifiedAccessKey, pin, registrationLock,\n            unrestrictedUnidentifiedAccess, lastSeen));\n  }\n\n  private Account getLastAccountFromRedisMock(RedisAdvancedClusterCommands<String, String> commands) throws IOException {\n    ArgumentCaptor<String> redisSetArgumentCapture = ArgumentCaptor.forClass(String.class);\n\n    verify(commands, atLeast(20)).setex(anyString(), anyLong(), redisSetArgumentCapture.capture());\n\n    return JsonHelpers.fromJson(redisSetArgumentCapture.getValue(), Account.class);\n  }\n\n  private void verifyAccount(final String name, final Account account, final boolean discoverableByPhoneNumber, final String currentProfileVersion, final IdentityKey identityKey, final byte[] unidentifiedAccessKey, final String pin, final String clientRegistrationLock, final boolean unrestrictedUnidentifiedAccess, final long lastSeen) {\n\n    assertAll(name,\n        () -> assertEquals(discoverableByPhoneNumber, account.isDiscoverableByPhoneNumber()),\n        () -> assertEquals(currentProfileVersion, account.getCurrentProfileVersion().orElseThrow()),\n        () -> assertEquals(identityKey, account.getIdentityKey(IdentityType.ACI)),\n        () -> assertArrayEquals(unidentifiedAccessKey, account.getUnidentifiedAccessKey().orElseThrow()),\n        () -> assertTrue(account.getRegistrationLock().verify(clientRegistrationLock)),\n        () -> assertEquals(unrestrictedUnidentifiedAccess, account.isUnrestrictedUnidentifiedAccess())\n    );\n  }\n\n  private CompletableFuture<?> modifyAccount(final UUID uuid, final Consumer<Account> accountMutation) {\n\n    return CompletableFuture.runAsync(() -> {\n      final Account account = accountsManager.getByAccountIdentifier(uuid).orElseThrow();\n      accountsManager.update(account, accountMutation);\n    }, mutationExecutor);\n  }\n\n  private CompletableFuture<?> modifyDevice(final UUID uuid, final byte deviceId, final Consumer<Device> deviceMutation) {\n\n    return CompletableFuture.runAsync(() -> {\n      final Account account = accountsManager.getByAccountIdentifier(uuid).orElseThrow();\n      accountsManager.updateDevice(account, deviceId, deviceMutation);\n    }, mutationExecutor);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerDeviceTransferIntegrationTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Base64;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachment;\nimport org.whispersystems.textsecuregcm.entities.RemoteAttachmentError;\nimport org.whispersystems.textsecuregcm.entities.RestoreAccountRequest;\nimport org.whispersystems.textsecuregcm.entities.TransferArchiveResult;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisServerExtension;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n// ThreadMode.SEPARATE_THREAD protects against hangs in the remote Redis calls, as this mode allows the test code to be\n// preempted by the timeout check\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\npublic class AccountsManagerDeviceTransferIntegrationTest {\n\n  @RegisterExtension\n  static final RedisServerExtension PUBSUB_SERVER_EXTENSION = RedisServerExtension.builder().build();\n\n  private AccountsManager accountsManager;\n\n  @BeforeEach\n  void setUp() {\n    PUBSUB_SERVER_EXTENSION.getRedisClient().useConnection(connection -> {\n      connection.sync().flushall();\n      connection.sync().configSet(\"notify-keyspace-events\", \"K$\");\n    });\n\n    accountsManager = new AccountsManager(\n        mock(Accounts.class),\n        mock(PhoneNumberIdentifiers.class),\n        mock(FaultTolerantRedisClusterClient.class),\n        PUBSUB_SERVER_EXTENSION.getRedisClient(),\n        mock(AccountLockManager.class),\n        mock(KeysManager.class),\n        mock(MessagesManager.class),\n        mock(ProfilesManager.class),\n        mock(SecureStorageClient.class),\n        mock(SecureValueRecoveryClient.class),\n        mock(DisconnectionRequestManager.class),\n        mock(RegistrationRecoveryPasswordsManager.class),\n        mock(ExecutorService.class),\n        mock(ScheduledExecutorService.class),\n        mock(ScheduledExecutorService.class),\n        Clock.systemUTC(),\n        \"link-device-secret\".getBytes(StandardCharsets.UTF_8));\n\n    accountsManager.start();\n  }\n\n  @AfterEach\n  void tearDown() {\n    accountsManager.stop();\n  }\n\n  @Test\n  void waitForTransferArchive() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final int registrationId = 123;\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final RemoteAttachment transferArchive =\n        new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"transfer-archive\".getBytes(StandardCharsets.UTF_8)));\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n\n    final CompletableFuture<Optional<TransferArchiveResult>> displacedFuture =\n        accountsManager.waitForTransferArchive(account, device, Duration.ofSeconds(5));\n\n    final CompletableFuture<Optional<TransferArchiveResult>> activeFuture =\n        accountsManager.waitForTransferArchive(account, device, Duration.ofSeconds(5));\n\n    assertEquals(Optional.empty(), displacedFuture.join());\n\n    accountsManager.recordTransferArchiveUpload(account, deviceId, registrationId, transferArchive).join();\n\n    assertEquals(Optional.of(transferArchive), activeFuture.join());\n  }\n\n  @Test\n  void waitForTransferArchiveAlreadyAdded() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n    final int registrationId = 123;\n\n    final RemoteAttachment transferArchive =\n        new RemoteAttachment(3, Base64.getUrlEncoder().encodeToString(\"transfer-archive\".getBytes(StandardCharsets.UTF_8)));\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n\n    accountsManager.recordTransferArchiveUpload(account, deviceId, registrationId, transferArchive).join();\n\n    assertEquals(Optional.of(transferArchive),\n        accountsManager.waitForTransferArchive(account, device, Duration.ofSeconds(5)).join());\n  }\n\n  @Test\n  void waitForErrorTransferArchive() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n    final int registrationId = 123;\n\n    final RemoteAttachmentError transferArchiveError =\n        new RemoteAttachmentError(RemoteAttachmentError.ErrorType.CONTINUE_WITHOUT_UPLOAD);\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(registrationId);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n\n    accountsManager.recordTransferArchiveUpload(account, deviceId,\n        registrationId, transferArchiveError).join();\n\n    assertEquals(Optional.of(transferArchiveError),\n        accountsManager.waitForTransferArchive(account, device, Duration.ofSeconds(5)).join());\n  }\n\n  @Test\n  void waitForTransferArchiveTimeout() {\n    final UUID accountIdentifier = UUID.randomUUID();\n\n    final Device device = mock(Device.class);\n    when(device.getRegistrationId(IdentityType.ACI)).thenReturn(123);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n\n    assertEquals(Optional.empty(),\n        accountsManager.waitForTransferArchive(account, device, Duration.ofMillis(1)).join());\n  }\n\n  @Test\n  void waitForRestoreAccountRequest() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n    final byte[] deviceTransferBootstrap = TestRandomUtil.nextBytes(100);\n    final RestoreAccountRequest restoreAccountRequest =\n        new RestoreAccountRequest(RestoreAccountRequest.Method.DEVICE_TRANSFER, deviceTransferBootstrap);\n\n    final CompletableFuture<Optional<RestoreAccountRequest>> displacedFuture =\n        accountsManager.waitForRestoreAccountRequest(token, Duration.ofSeconds(5));\n\n    final CompletableFuture<Optional<RestoreAccountRequest>> activeFuture =\n        accountsManager.waitForRestoreAccountRequest(token, Duration.ofSeconds(5));\n\n    assertEquals(Optional.empty(), displacedFuture.join());\n\n    accountsManager.recordRestoreAccountRequest(token, restoreAccountRequest).join();\n\n    final Optional<RestoreAccountRequest> result = activeFuture.join();\n    assertTrue(result.isPresent());\n    assertEquals(restoreAccountRequest.method(), result.get().method());\n    assertArrayEquals(restoreAccountRequest.deviceTransferBootstrap(), result.get().deviceTransferBootstrap());\n  }\n\n  @Test\n  void waitForRestoreAccountRequestAlreadyRequested() {\n    final String token = RandomStringUtils.secure().nextAlphanumeric(16);\n    final RestoreAccountRequest restoreAccountRequest =\n        new RestoreAccountRequest(RestoreAccountRequest.Method.DEVICE_TRANSFER, null);\n\n    accountsManager.recordRestoreAccountRequest(token, restoreAccountRequest).join();\n\n    assertEquals(Optional.of(restoreAccountRequest),\n        accountsManager.waitForRestoreAccountRequest(token, Duration.ofSeconds(5)).join());\n  }\n\n  @Test\n  void waitForRestoreAccountRequestTimeout() {\n    assertEquals(Optional.empty(),\n        accountsManager.waitForRestoreAccountRequest(RandomStringUtils.secure().nextAlphanumeric(16),\n            Duration.ofMillis(1)).join());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertSame;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anySet;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.ArgumentMatchers.notNull;\nimport static org.mockito.Mockito.anyString;\nimport static org.mockito.Mockito.atLeastOnce;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.api.async.RedisAsyncCommands;\nimport io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport java.io.InputStream;\nimport java.nio.charset.StandardCharsets;\nimport java.security.InvalidKeyException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executor;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport javax.crypto.spec.SecretKeySpec;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.function.Executable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.stubbing.Answer;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevices;\nimport org.whispersystems.textsecuregcm.controllers.MismatchedDevicesException;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager.UsernameReservation;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.tests.util.MockRedisFuture;\nimport org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;\nimport org.whispersystems.textsecuregcm.tests.util.RedisServerHelper;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.ThrowingSupplier;\n\n@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass AccountsManagerTest {\n  private static final String BASE_64_URL_USERNAME_HASH_1 = \"9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE\";\n  private static final String BASE_64_URL_USERNAME_HASH_2 = \"NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_1 = \"md1votbj9r794DsqTNrBqA\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_2 = \"9hrqVLy59bzgPse-S9NUsA\";\n\n  private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);\n  private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);\n  private static final byte[] ENCRYPTED_USERNAME_1 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_1);\n  private static final byte[] ENCRYPTED_USERNAME_2 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_2);\n\n  private static final byte[] LINK_DEVICE_SECRET = \"link-device-secret\".getBytes(StandardCharsets.UTF_8);\n\n  private static TestClock CLOCK;\n\n  private Accounts accounts;\n  private KeysManager keysManager;\n  private MessagesManager messagesManager;\n  private ProfilesManager profilesManager;\n  private DisconnectionRequestManager disconnectionRequestManager;\n\n  private Map<String, UUID> phoneNumberIdentifiersByE164;\n\n  private RedisAsyncCommands<String, String> asyncCommands;\n  private RedisAdvancedClusterCommands<String, String> clusterCommands;\n  private RedisAdvancedClusterAsyncCommands<String, String> asyncClusterCommands;\n  private AccountsManager accountsManager;\n  private SecureValueRecoveryClient svr2Client;\n\n  private static final Answer<?> ACCOUNT_UPDATE_ANSWER = (answer) -> {\n    // it is implicit in the update() contract is that a successful call will\n    // result in an incremented version\n    final Account updatedAccount = answer.getArgument(0, Account.class);\n    updatedAccount.setVersion(updatedAccount.getVersion() + 1);\n    return null;\n  };\n\n  @BeforeEach\n  void setup() throws Exception {\n    accounts = mock(Accounts.class);\n    keysManager = mock(KeysManager.class);\n    messagesManager = mock(MessagesManager.class);\n    profilesManager = mock(ProfilesManager.class);\n    disconnectionRequestManager = mock(DisconnectionRequestManager.class);\n\n    //noinspection unchecked\n    asyncCommands = mock(RedisAsyncCommands.class);\n    when(asyncCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n\n    //noinspection unchecked\n    clusterCommands = mock(RedisAdvancedClusterCommands.class);\n\n    //noinspection unchecked\n    asyncClusterCommands = mock(RedisAdvancedClusterAsyncCommands.class);\n    when(asyncClusterCommands.del(any(String[].class))).thenReturn(MockRedisFuture.completedFuture(0L));\n    when(asyncClusterCommands.get(any())).thenReturn(MockRedisFuture.completedFuture(null));\n    when(asyncClusterCommands.set(any(), any(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n\n    doAnswer((Answer<Void>) invocation -> {\n      final Account account = invocation.getArgument(0, Account.class);\n      final String number = invocation.getArgument(1, String.class);\n      final UUID phoneNumberIdentifier = invocation.getArgument(2, UUID.class);\n\n      account.setNumber(number, phoneNumberIdentifier);\n\n      return null;\n    }).when(accounts).changeNumber(any(), anyString(), any(), any(), any());\n\n    final SecureStorageClient storageClient = mock(SecureStorageClient.class);\n    when(storageClient.deleteStoredData(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n    svr2Client = mock(SecureValueRecoveryClient.class);\n    when(svr2Client.removeData(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n    final PhoneNumberIdentifiers phoneNumberIdentifiers = mock(PhoneNumberIdentifiers.class);\n    phoneNumberIdentifiersByE164 = new HashMap<>();\n\n    when(phoneNumberIdentifiers.getPhoneNumberIdentifier(anyString())).thenAnswer((Answer<CompletableFuture<UUID>>) invocation -> {\n      final String number = invocation.getArgument(0, String.class);\n      return CompletableFuture.completedFuture(phoneNumberIdentifiersByE164.computeIfAbsent(number, n -> UUID.randomUUID()));\n    });\n\n    final AccountLockManager accountLockManager = mock(AccountLockManager.class);\n\n    doAnswer(invocation -> {\n      final ThrowingSupplier<?, ?> task = invocation.getArgument(1);\n      return task.get();\n    }).when(accountLockManager).withLock(anySet(), any(), any());\n\n    final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n        mock(RegistrationRecoveryPasswordsManager.class);\n\n    when(registrationRecoveryPasswordsManager.remove(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(keysManager.deleteSingleUsePreKeys(any())).thenReturn(CompletableFuture.completedFuture(null));\n    when(messagesManager.clear(any())).thenReturn(CompletableFuture.completedFuture(null));\n    when(profilesManager.deleteAll(any(), anyBoolean())).thenReturn(CompletableFuture.completedFuture(null));\n\n    CLOCK = TestClock.now();\n\n    final FaultTolerantRedisClient pubSubClient = RedisServerHelper.builder()\n        .stringAsyncCommands(asyncCommands)\n        .build();\n\n    final FaultTolerantRedisClusterClient redisCluster = RedisClusterHelper.builder()\n        .stringCommands(clusterCommands)\n        .stringAsyncCommands(asyncClusterCommands)\n        .build();\n\n    when(disconnectionRequestManager.requestDisconnection(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    accountsManager = new AccountsManager(\n        accounts,\n        phoneNumberIdentifiers,\n        redisCluster,\n        pubSubClient,\n        accountLockManager,\n        keysManager,\n        messagesManager,\n        profilesManager,\n        storageClient,\n        svr2Client,\n        disconnectionRequestManager,\n        registrationRecoveryPasswordsManager,\n        mock(Executor.class),\n        mock(ScheduledExecutorService.class),\n        mock(ScheduledExecutorService.class),\n        CLOCK,\n        LINK_DEVICE_SECRET);\n  }\n\n  @Test\n  void testGetByServiceIdentifier() {\n    final UUID aci = UUID.randomUUID();\n    final UUID pni = UUID.randomUUID();\n\n    when(clusterCommands.get(eq(\"AccountMap::\" + pni))).thenReturn(aci.toString());\n    when(clusterCommands.get(eq(\"Account3::\" + aci))).thenReturn(\n        \"{\\\"number\\\": \\\"+14152222222\\\", \\\"pni\\\": \\\"\" + pni + \"\\\"}\");\n\n    assertTrue(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(aci)).isPresent());\n    assertTrue(accountsManager.getByServiceIdentifier(new PniServiceIdentifier(pni)).isPresent());\n    assertFalse(accountsManager.getByServiceIdentifier(new AciServiceIdentifier(pni)).isPresent());\n    assertFalse(accountsManager.getByServiceIdentifier(new PniServiceIdentifier(aci)).isPresent());\n  }\n\n  @Test\n  void testGetByServiceIdentifierAsync() {\n    final UUID aci = UUID.randomUUID();\n    final UUID pni = UUID.randomUUID();\n\n    when(asyncClusterCommands.get(eq(\"AccountMap::\" + pni))).thenReturn(MockRedisFuture.completedFuture(aci.toString()));\n    when(asyncClusterCommands.get(eq(\"Account3::\" + aci))).thenReturn(MockRedisFuture.completedFuture(\n        \"{\\\"number\\\": \\\"+14152222222\\\", \\\"pni\\\": \\\"\" + pni + \"\\\"}\"));\n\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n\n    when(accounts.getByAccountIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(accounts.getByPhoneNumberIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    assertTrue(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(aci)).join().isPresent());\n    assertTrue(accountsManager.getByServiceIdentifierAsync(new PniServiceIdentifier(pni)).join().isPresent());\n    assertFalse(accountsManager.getByServiceIdentifierAsync(new AciServiceIdentifier(pni)).join().isPresent());\n    assertFalse(accountsManager.getByServiceIdentifierAsync(new PniServiceIdentifier(aci)).join().isPresent());\n  }\n\n\n  @Test\n  void testGetAccountByUuidInCache() {\n    UUID uuid = UUID.randomUUID();\n\n    when(clusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(\n        \"{\\\"number\\\": \\\"+14152222222\\\", \\\"pni\\\": \\\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\\\"}\");\n\n    Optional<Account> account = accountsManager.getByAccountIdentifier(uuid);\n\n    assertTrue(account.isPresent());\n    assertEquals(account.get().getNumber(), \"+14152222222\");\n    assertEquals(account.get().getUuid(), uuid);\n    assertEquals(UUID.fromString(\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"), account.get().getPhoneNumberIdentifier());\n\n    verify(clusterCommands, times(1)).get(eq(\"Account3::\" + uuid));\n    verifyNoMoreInteractions(clusterCommands);\n\n    verifyNoInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByUuidInCacheAsync() {\n    UUID uuid = UUID.randomUUID();\n\n    when(asyncClusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(MockRedisFuture.completedFuture(\n        \"{\\\"number\\\": \\\"+14152222222\\\", \\\"pni\\\": \\\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\\\"}\"));\n\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n\n    Optional<Account> account = accountsManager.getByAccountIdentifierAsync(uuid).join();\n\n    assertTrue(account.isPresent());\n    assertEquals(account.get().getNumber(), \"+14152222222\");\n    assertEquals(account.get().getUuid(), uuid);\n    assertEquals(UUID.fromString(\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"), account.get().getPhoneNumberIdentifier());\n\n    verify(asyncClusterCommands, times(1)).get(eq(\"Account3::\" + uuid));\n    verifyNoMoreInteractions(asyncClusterCommands);\n\n    verifyNoInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByPniInCache() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n\n    when(clusterCommands.get(eq(\"AccountMap::\" + pni))).thenReturn(uuid.toString());\n    when(clusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(\n        \"{\\\"number\\\": \\\"+14152222222\\\", \\\"pni\\\": \\\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\\\"}\");\n\n    Optional<Account> account = accountsManager.getByPhoneNumberIdentifier(pni);\n\n    assertTrue(account.isPresent());\n    assertEquals(account.get().getNumber(), \"+14152222222\");\n    assertEquals(UUID.fromString(\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"), account.get().getPhoneNumberIdentifier());\n\n    verify(clusterCommands).get(eq(\"AccountMap::\" + pni));\n    verify(clusterCommands).get(eq(\"Account3::\" + uuid));\n    verifyNoMoreInteractions(clusterCommands);\n\n    verifyNoInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByPniInCacheAsync() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n\n    when(asyncClusterCommands.get(eq(\"AccountMap::\" + pni)))\n        .thenReturn(MockRedisFuture.completedFuture(uuid.toString()));\n\n    when(asyncClusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(MockRedisFuture.completedFuture(\n        \"{\\\"number\\\": \\\"+14152222222\\\", \\\"pni\\\": \\\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\\\"}\"));\n\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n\n    Optional<Account> account = accountsManager.getByPhoneNumberIdentifierAsync(pni).join();\n\n    assertTrue(account.isPresent());\n    assertEquals(account.get().getNumber(), \"+14152222222\");\n    assertEquals(UUID.fromString(\"de24dc73-fbd8-41be-a7d5-764c70d9da7e\"), account.get().getPhoneNumberIdentifier());\n\n    verify(asyncClusterCommands).get(eq(\"AccountMap::\" + pni));\n    verify(asyncClusterCommands).get(eq(\"Account3::\" + uuid));\n    verifyNoMoreInteractions(asyncClusterCommands);\n\n    verifyNoInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByUuidNotInCache() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(clusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(null);\n    when(accounts.getByAccountIdentifier(eq(uuid))).thenReturn(Optional.of(account));\n\n    Optional<Account> retrieved = accountsManager.getByAccountIdentifier(uuid);\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(clusterCommands, times(1)).get(eq(\"Account3::\" + uuid));\n    verify(clusterCommands, times(1)).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(clusterCommands, times(1)).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(clusterCommands);\n\n    verify(accounts, times(1)).getByAccountIdentifier(eq(uuid));\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByUuidNotInCacheAsync() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(asyncClusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(MockRedisFuture.completedFuture(null));\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n    when(accounts.getByAccountIdentifierAsync(eq(uuid)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    Optional<Account> retrieved = accountsManager.getByAccountIdentifierAsync(uuid).join();\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(asyncClusterCommands).get(eq(\"Account3::\" + uuid));\n    verify(asyncClusterCommands).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(asyncClusterCommands).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(asyncClusterCommands);\n\n    verify(accounts).getByAccountIdentifierAsync(eq(uuid));\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByPniNotInCache() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(clusterCommands.get(eq(\"AccountMap::\" + pni))).thenReturn(null);\n    when(accounts.getByPhoneNumberIdentifier(pni)).thenReturn(Optional.of(account));\n\n    Optional<Account> retrieved = accountsManager.getByPhoneNumberIdentifier(pni);\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(clusterCommands).get(eq(\"AccountMap::\" + pni));\n    verify(clusterCommands).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(clusterCommands).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(clusterCommands);\n\n    verify(accounts).getByPhoneNumberIdentifier(pni);\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByPniNotInCacheAsync() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(asyncClusterCommands.get(eq(\"AccountMap::\" + pni))).thenReturn(MockRedisFuture.completedFuture(null));\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n    when(accounts.getByPhoneNumberIdentifierAsync(pni))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    Optional<Account> retrieved = accountsManager.getByPhoneNumberIdentifierAsync(pni).join();\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(asyncClusterCommands).get(eq(\"AccountMap::\" + pni));\n    verify(asyncClusterCommands).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(asyncClusterCommands).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(asyncClusterCommands);\n\n    verify(accounts).getByPhoneNumberIdentifierAsync(pni);\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByUsernameHash() {\n    UUID uuid = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, UUID.randomUUID(), new ArrayList<>(),\n        new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    account.setUsernameHash(USERNAME_HASH_1);\n    when(accounts.getByUsernameHash(USERNAME_HASH_1))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n    Optional<Account> retrieved = accountsManager.getByUsernameHash(USERNAME_HASH_1).join();\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n    verify(accounts).getByUsernameHash(USERNAME_HASH_1);\n    verifyNoMoreInteractions(accounts);\n  }\n\n  enum FailureStep {\n    GET,\n    SET_ACI,\n    SET_PNI\n  }\n\n  @ParameterizedTest\n  @EnumSource(FailureStep.class)\n  void testGetAccountByUuidBrokenCache(final FailureStep step) {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    (switch (step) {\n      case GET -> when(clusterCommands.get(eq(\"Account3::\" + uuid)));\n      case SET_ACI -> when(clusterCommands.setex(eq(\"Account3::\" + uuid), anyLong(), anyString()));\n      case SET_PNI -> when(clusterCommands.setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString())));\n    }).thenThrow(new RedisException(\"Connection lost!\"));\n\n    when(accounts.getByAccountIdentifier(eq(uuid))).thenReturn(Optional.of(account));\n\n    Optional<Account> retrieved = accountsManager.getByAccountIdentifier(uuid);\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(clusterCommands, times(1)).get(eq(\"Account3::\" + uuid));\n    verify(clusterCommands, times(1)).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    // we only try setting the ACI if we successfully set the PNI\n    verify(clusterCommands, times(step == FailureStep.SET_PNI ? 0 : 1))\n        .setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(clusterCommands);\n\n    verify(accounts, times(1)).getByAccountIdentifier(eq(uuid));\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @ParameterizedTest\n  @EnumSource(FailureStep.class)\n  void testGetAccountByUuidBrokenCacheAsync(final FailureStep step) {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n\n    when(asyncClusterCommands.get(eq(\"Account3::\" + uuid)))\n        .thenReturn(MockRedisFuture.completedFuture(null));\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n    when(accounts.getByAccountIdentifierAsync(eq(uuid)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    (switch (step) {\n      case GET -> when(asyncClusterCommands.get(eq(\"Account3::\" + uuid)));\n      case SET_ACI -> when(asyncClusterCommands.setex(eq(\"Account3::\" + uuid), anyLong(), anyString()));\n      case SET_PNI -> when(asyncClusterCommands.setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString())));\n    }).thenReturn(MockRedisFuture.failedFuture(new RedisException(\"Connection lost!\")));\n\n    Optional<Account> retrieved = accountsManager.getByAccountIdentifierAsync(uuid).join();\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(asyncClusterCommands).get(eq(\"Account3::\" + uuid));\n    verify(asyncClusterCommands).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(asyncClusterCommands).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(asyncClusterCommands);\n\n    verify(accounts).getByAccountIdentifierAsync(eq(uuid));\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByPniBrokenCache() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(clusterCommands.get(eq(\"AccountMap::\" + pni))).thenThrow(new RedisException(\"OH NO\"));\n    when(accounts.getByPhoneNumberIdentifier(pni)).thenReturn(Optional.of(account));\n\n    Optional<Account> retrieved = accountsManager.getByPhoneNumberIdentifier(pni);\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(clusterCommands).get(eq(\"AccountMap::\" + pni));\n    verify(clusterCommands).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(clusterCommands).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(clusterCommands);\n\n    verify(accounts).getByPhoneNumberIdentifier(pni);\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testGetAccountByPniBrokenCacheAsync() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(asyncClusterCommands.get(eq(\"AccountMap::\" + pni)))\n        .thenReturn(MockRedisFuture.failedFuture(new RedisException(\"OH NO\")));\n\n    when(asyncClusterCommands.setex(any(), anyLong(), any())).thenReturn(MockRedisFuture.completedFuture(\"OK\"));\n\n    when(accounts.getByPhoneNumberIdentifierAsync(pni))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    Optional<Account> retrieved = accountsManager.getByPhoneNumberIdentifierAsync(pni).join();\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), account);\n\n    verify(asyncClusterCommands).get(eq(\"AccountMap::\" + pni));\n    verify(asyncClusterCommands).setex(eq(\"AccountMap::\" + pni), anyLong(), eq(uuid.toString()));\n    verify(asyncClusterCommands).setex(eq(\"Account3::\" + uuid), anyLong(), anyString());\n    verifyNoMoreInteractions(asyncClusterCommands);\n\n    verify(accounts).getByPhoneNumberIdentifierAsync(pni);\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testUpdate_optimisticLockingFailure() {\n    UUID uuid = UUID.randomUUID();\n    UUID pni = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(clusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(null);\n\n    when(accounts.getByAccountIdentifier(uuid)).thenReturn(\n        Optional.of(AccountsHelper.generateTestAccount(\"+14152222222\", uuid, pni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH])));\n    doThrow(ContestedOptimisticLockException.class)\n        .doAnswer(ACCOUNT_UPDATE_ANSWER)\n        .when(accounts).update(any());\n\n    final IdentityKey identityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n    account = accountsManager.update(account, a -> a.setIdentityKey(identityKey));\n\n    assertEquals(1, account.getVersion());\n    assertEquals(identityKey, account.getIdentityKey(IdentityType.ACI));\n\n    verify(accounts, times(1)).getByAccountIdentifier(uuid);\n    verify(accounts, times(2)).update(any());\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testUpdate_dynamoOptimisticLockingFailureDuringCreate() throws AccountAlreadyExistsException {\n    UUID uuid = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(clusterCommands.get(eq(\"Account3::\" + uuid))).thenReturn(null);\n    when(accounts.getByAccountIdentifier(uuid)).thenReturn(Optional.empty())\n        .thenReturn(Optional.of(account));\n    when(accounts.create(any(), any())).thenThrow(ContestedOptimisticLockException.class);\n\n    accountsManager.update(account, a -> {\n    });\n\n    verify(accounts, times(1)).update(account);\n    verifyNoMoreInteractions(accounts);\n  }\n\n  @Test\n  void testUpdateDevice() {\n    final UUID uuid = UUID.randomUUID();\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    when(accounts.getByAccountIdentifier(uuid)).thenReturn(\n        Optional.of(AccountsHelper.generateTestAccount(\"+14152222222\", uuid, UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH])));\n\n    assertTrue(account.getDevices().isEmpty());\n\n    Device enabledDevice = new Device();\n    enabledDevice.setFetchesMessages(true);\n    enabledDevice.setLastSeen(System.currentTimeMillis());\n    final byte deviceId = account.getNextDeviceId();\n    enabledDevice.setId(deviceId);\n    account.addDevice(enabledDevice);\n\n    @SuppressWarnings(\"unchecked\") Consumer<Device> deviceUpdater = mock(Consumer.class);\n    @SuppressWarnings(\"unchecked\") Consumer<Device> unknownDeviceUpdater = mock(Consumer.class);\n\n    account = accountsManager.updateDevice(account, deviceId, deviceUpdater);\n    account = accountsManager.updateDevice(account, deviceId, d -> d.setName(\"deviceName\".getBytes(StandardCharsets.UTF_8)));\n\n    assertArrayEquals(\"deviceName\".getBytes(StandardCharsets.UTF_8), account.getDevice(deviceId).orElseThrow().getName());\n\n    verify(deviceUpdater, times(1)).accept(any(Device.class));\n\n    accountsManager.updateDevice(account, account.getNextDeviceId(), unknownDeviceUpdater);\n\n    verify(unknownDeviceUpdater, never()).accept(any(Device.class));\n  }\n\n  @Test\n  void testRemoveDevice() {\n    final Device primaryDevice = new Device();\n    primaryDevice.setId(Device.PRIMARY_ID);\n\n    final Device linkedDevice = new Device();\n    linkedDevice.setId((byte) (Device.PRIMARY_ID + 1));\n\n    Account account = AccountsHelper.generateTestAccount(\"+14152222222\", List.of(primaryDevice, linkedDevice));\n\n    when(accounts.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n    when(keysManager.deleteSingleUsePreKeys(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n    when(messagesManager.clear(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n\n    assertTrue(account.getDevice(linkedDevice.getId()).isPresent());\n\n    account = accountsManager.removeDevice(account, linkedDevice.getId());\n\n    assertFalse(account.getDevice(linkedDevice.getId()).isPresent());\n    verify(messagesManager, times(2)).clear(account.getUuid(), linkedDevice.getId());\n    verify(keysManager, times(2)).deleteSingleUsePreKeys(account.getUuid(), linkedDevice.getId());\n    verify(keysManager).buildWriteItemsForRemovedDevice(account.getUuid(), account.getPhoneNumberIdentifier(), linkedDevice.getId());\n    verify(disconnectionRequestManager).requestDisconnection(account.getUuid(), List.of(linkedDevice.getId()));\n  }\n\n  @Test\n  void testRemovePrimaryDevice() {\n    final Device primaryDevice = new Device();\n    primaryDevice.setId(Device.PRIMARY_ID);\n\n    final Account account = AccountsHelper.generateTestAccount(\"+14152222222\", List.of(primaryDevice));\n\n    when(keysManager.deleteSingleUsePreKeys(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n    when(messagesManager.clear(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n\n    assertThrows(IllegalArgumentException.class, () -> accountsManager.removeDevice(account, Device.PRIMARY_ID));\n\n    assertDoesNotThrow(account::getPrimaryDevice);\n    verify(messagesManager, never()).clear(any(), anyByte());\n    verify(keysManager, never()).deleteSingleUsePreKeys(any(), anyByte());\n    verify(disconnectionRequestManager, never()).requestDisconnection(any(), any());\n  }\n\n  @Test\n  void testCreateFreshAccount() throws InterruptedException, AccountAlreadyExistsException {\n    when(accounts.create(any(), any())).thenReturn(true);\n\n    final String e164 = \"+18005550123\";\n    final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null, true, null);\n\n    final Account createdAccount = createAccount(e164, attributes);\n\n    verify(accounts).create(argThat(account -> e164.equals(account.getNumber())), any());\n    verify(keysManager).buildWriteItemsForNewDevice(\n        eq(createdAccount.getUuid()),\n        eq(createdAccount.getPhoneNumberIdentifier()),\n        eq(Device.PRIMARY_ID),\n        notNull(),\n        notNull(),\n        notNull(),\n        notNull());\n\n    verifyNoInteractions(messagesManager);\n    verifyNoInteractions(profilesManager);\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"+18005550123, +18005550123\",\n      // the canonical form of numbers may change over time, so an existing account might have not-identical e164 that\n      // maps to the same PNI, and the number used by the caller must be present on the re-registered account\n      \"+2290123456789, +22923456789\"\n  })\n  void testReregisterAccount(final String e164, final String existingAccountE164)\n      throws InterruptedException, AccountAlreadyExistsException {\n    final UUID existingUuid = UUID.randomUUID();\n\n    final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null, true, null);\n\n    when(accounts.create(any(), any()))\n        .thenAnswer(invocation -> {\n          final Account requestedAccount = invocation.getArgument(0);\n\n          final Account existingAccount = mock(Account.class);\n          when(existingAccount.getUuid()).thenReturn(existingUuid);\n          when(existingAccount.getIdentifier(IdentityType.ACI)).thenReturn(existingUuid);\n          when(existingAccount.getNumber()).thenReturn(existingAccountE164);\n          when(existingAccount.getPhoneNumberIdentifier()).thenReturn(requestedAccount.getIdentifier(IdentityType.PNI));\n          when(existingAccount.getIdentifier(IdentityType.PNI)).thenReturn(requestedAccount.getIdentifier(IdentityType.PNI));\n          when(existingAccount.getPrimaryDevice()).thenReturn(mock(Device.class));\n\n          throw new AccountAlreadyExistsException(existingAccount);\n        });\n\n    when(accounts.reclaimAccount(any(), any(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final Account reregisteredAccount = createAccount(e164, attributes);\n\n    assertTrue(phoneNumberIdentifiersByE164.containsKey(e164));\n    assertEquals(e164, reregisteredAccount.getNumber());\n\n    verify(accounts)\n        .create(argThat(account -> e164.equals(account.getNumber()) && existingUuid.equals(account.getUuid())), any());\n\n    verify(keysManager).buildWriteItemsForNewDevice(\n        eq(reregisteredAccount.getUuid()),\n        eq(reregisteredAccount.getPhoneNumberIdentifier()),\n        eq(Device.PRIMARY_ID),\n        notNull(),\n        notNull(),\n        notNull(),\n        notNull());\n\n    verify(keysManager, times(2)).deleteSingleUsePreKeys(existingUuid);\n    verify(keysManager, times(2)).deleteSingleUsePreKeys(phoneNumberIdentifiersByE164.get(e164));\n    verify(messagesManager, times(2)).clear(existingUuid);\n    verify(profilesManager, times(2)).deleteAll(existingUuid, false);\n    verify(disconnectionRequestManager).requestDisconnection(argThat(account ->\n        account.getIdentifier(IdentityType.ACI).equals(existingUuid) && account != reregisteredAccount));\n  }\n\n  @Test\n  void testCreateAccountRecentlyDeleted() throws InterruptedException, AccountAlreadyExistsException {\n    final UUID recentlyDeletedUuid = UUID.randomUUID();\n\n    when(accounts.findRecentlyDeletedAccountIdentifier(any())).thenReturn(Optional.of(recentlyDeletedUuid));\n    when(accounts.create(any(), any())).thenReturn(true);\n\n    final String e164 = \"+18005550123\";\n    final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null, true, null);\n\n    final Account account = createAccount(e164, attributes);\n\n    verify(accounts).create(\n        argThat(a -> e164.equals(a.getNumber()) && recentlyDeletedUuid.equals(a.getUuid())),\n        any());\n\n    verify(keysManager).buildWriteItemsForNewDevice(eq(account.getIdentifier(IdentityType.ACI)),\n        eq(account.getIdentifier(IdentityType.PNI)),\n        eq(Device.PRIMARY_ID),\n        any(),\n        any(),\n        any(),\n        any());\n\n    verifyNoMoreInteractions(keysManager);\n    verifyNoInteractions(messagesManager);\n    verifyNoInteractions(profilesManager);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testCreateWithDiscoverability(final boolean discoverable) throws InterruptedException {\n    final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null, discoverable, null);\n    final Account account = createAccount(\"+18005550123\", attributes);\n\n    assertEquals(discoverable, account.isDiscoverableByPhoneNumber());\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testCreateWithStorageCapability(final boolean hasStorage) throws InterruptedException {\n    final AccountAttributes attributes = new AccountAttributes(false, 1, 2, null, null,\n            true, hasStorage ? Set.of(DeviceCapability.STORAGE) : Set.of());\n\n    final Account account = createAccount(\"+18005550123\", attributes);\n\n    assertEquals(hasStorage, account.hasCapability(DeviceCapability.STORAGE));\n  }\n\n  @Test\n  void testAddDevice() throws LinkDeviceTokenAlreadyUsedException {\n    final String phoneNumber =\n        PhoneNumberUtil.getInstance().format(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n            PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final Account account = AccountsHelper.generateTestAccount(phoneNumber, List.of(generateTestDevice(CLOCK.millis())));\n    final UUID aci = account.getIdentifier(IdentityType.ACI);\n    final UUID pni = account.getIdentifier(IdentityType.PNI);\n    account.setIdentityKey(new IdentityKey(ECKeyPair.generate().getPublicKey()));\n\n    final byte nextDeviceId = account.getNextDeviceId();\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final byte[] deviceNameCiphertext = \"device-name\".getBytes(StandardCharsets.UTF_8);\n    final String password = \"password\";\n    final String signalAgent = \"OWT\";\n    final Set<DeviceCapability> deviceCapabilities = Set.of();\n    final int aciRegistrationId = 17;\n    final int pniRegistrationId = 19;\n    final ECSignedPreKey aciSignedPreKey = KeysHelper.signedECPreKey(1, aciKeyPair);\n    final ECSignedPreKey pniSignedPreKey = KeysHelper.signedECPreKey(2, pniKeyPair);\n    final KEMSignedPreKey aciPqLastResortPreKey = KeysHelper.signedKEMPreKey(3, aciKeyPair);\n    final KEMSignedPreKey pniPqLastResortPreKey = KeysHelper.signedKEMPreKey(4, pniKeyPair);\n\n    when(keysManager.deleteSingleUsePreKeys(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n    when(messagesManager.clear(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n    when(accounts.getByAccountIdentifier(aci)).thenReturn(Optional.of(account));\n\n    CLOCK.pin(CLOCK.instant().plusSeconds(60));\n\n    final Pair<Account, Device> updatedAccountAndDevice = accountsManager.addDevice(account, new DeviceSpec(\n            deviceNameCiphertext,\n            password,\n            signalAgent,\n            deviceCapabilities,\n            aciRegistrationId,\n            pniRegistrationId,\n            true,\n            Optional.empty(),\n            Optional.empty(),\n            aciSignedPreKey,\n            pniSignedPreKey,\n            aciPqLastResortPreKey,\n            pniPqLastResortPreKey),\n            accountsManager.generateLinkDeviceToken(aci));\n\n    verify(keysManager).deleteSingleUsePreKeys(aci, nextDeviceId);\n    verify(keysManager).deleteSingleUsePreKeys(pni, nextDeviceId);\n    verify(messagesManager).clear(aci, nextDeviceId);\n\n    verify(keysManager).buildWriteItemsForNewDevice(\n        aci,\n        pni,\n        nextDeviceId,\n        aciSignedPreKey,\n        pniSignedPreKey,\n        aciPqLastResortPreKey,\n        pniPqLastResortPreKey);\n\n    final Device device = updatedAccountAndDevice.second();\n\n    assertEquals(deviceNameCiphertext, device.getName());\n    assertTrue(device.getAuthTokenHash().verify(password));\n    assertEquals(signalAgent, device.getUserAgent());\n    assertEquals(Collections.emptySet(), device.getCapabilities());\n    assertEquals(aciRegistrationId, device.getRegistrationId(IdentityType.ACI));\n    assertEquals(pniRegistrationId, device.getRegistrationId(IdentityType.PNI));\n    assertTrue(device.getFetchesMessages());\n    assertNull(device.getApnId());\n    assertNull(device.getGcmId());\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testUpdateDeviceLastSeen(final boolean expectUpdate, final long initialLastSeen, final long updatedLastSeen) {\n    final Account account = AccountsHelper.generateTestAccount(\"+14152222222\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    final Device device = generateTestDevice(initialLastSeen);\n    account.addDevice(device);\n\n    accountsManager.updateDeviceLastSeen(account, device, updatedLastSeen);\n\n    assertEquals(expectUpdate ? updatedLastSeen : initialLastSeen, device.getLastSeen());\n    verify(accounts, expectUpdate ? times(1) : never()).update(account);\n  }\n\n  @SuppressWarnings(\"unused\")\n  private static Stream<Arguments> testUpdateDeviceLastSeen() {\n    return Stream.of(\n        Arguments.of(true, 1, 2),\n        Arguments.of(false, 1, 1),\n        Arguments.of(false, 2, 1)\n    );\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"+14152222222,+14153333333\",\n\n      // Historically, \"change number\" behavior was different for \"change to existing number,\" though that's no longer\n      // the case\n      \"+14152222222,+14152222222\"\n  })\n  void testChangePhoneNumber(final String originalNumber, final String targetNumber) throws InterruptedException, MismatchedDevicesException {\n    final UUID uuid = UUID.randomUUID();\n    final UUID originalPni = UUID.randomUUID();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(1, pniIdentityKeyPair);\n    final KEMSignedPreKey kemLastResortPreKey = KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair);\n\n    Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, originalPni, List.of(DevicesHelper.createDevice(Device.PRIMARY_ID)), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    account = accountsManager.changeNumber(account,\n        targetNumber,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, ecSignedPreKey),\n        Map.of(Device.PRIMARY_ID, kemLastResortPreKey),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    assertEquals(targetNumber, account.getNumber());\n\n    assertTrue(phoneNumberIdentifiersByE164.containsKey(targetNumber));\n\n    verify(keysManager).deleteSingleUsePreKeys(originalPni);\n    verify(keysManager).deleteSingleUsePreKeys(phoneNumberIdentifiersByE164.get(targetNumber));\n    verify(keysManager).buildWriteItemForEcSignedPreKey(phoneNumberIdentifiersByE164.get(targetNumber), Device.PRIMARY_ID, ecSignedPreKey);\n    verify(keysManager).buildWriteItemForLastResortKey(phoneNumberIdentifiersByE164.get(targetNumber), Device.PRIMARY_ID, kemLastResortPreKey);\n  }\n\n  @Test\n  void testChangePhoneNumberDifferentNumberSamePni() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+22923456789\";\n    // the canonical form of numbers may change over time, so we use PNIs as stable identifiers\n    final String newNumber = \"+2290123456789\";\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n    final UUID phoneNumberIdentifier = UUID.randomUUID();\n\n    Account account = AccountsHelper.generateTestAccount(originalNumber, UUID.randomUUID(), phoneNumberIdentifier,\n        List.of(DevicesHelper.createDevice(Device.PRIMARY_ID)), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    phoneNumberIdentifiersByE164.put(originalNumber, account.getPhoneNumberIdentifier());\n    phoneNumberIdentifiersByE164.put(newNumber, account.getPhoneNumberIdentifier());\n    account = accountsManager.changeNumber(account,\n        newNumber,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair)),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    assertEquals(newNumber, account.getNumber());\n    assertEquals(phoneNumberIdentifier, account.getIdentifier(IdentityType.PNI));\n    verify(accounts, never()).delete(any(), any());\n  }\n\n  @Test\n  void testChangePhoneNumberExistingAccount() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+14152222222\";\n    final String targetNumber = \"+14153333333\";\n    final UUID existingAccountUuid = UUID.randomUUID();\n    final UUID uuid = UUID.randomUUID();\n    final UUID originalPni = UUID.randomUUID();\n    final UUID targetPni = UUID.randomUUID();\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n\n    final Account existingAccount = AccountsHelper.generateTestAccount(targetNumber, existingAccountUuid, targetPni, List.of(DevicesHelper.createDevice(Device.PRIMARY_ID)), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    when(accounts.getByE164(targetNumber)).thenReturn(Optional.of(existingAccount));\n\n    final ECSignedPreKey ecSignedPreKey = KeysHelper.signedECPreKey(1, pniIdentityKeyPair);\n    final KEMSignedPreKey kemLastResoryPreKey = KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair);\n\n    Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, originalPni, List.of(DevicesHelper.createDevice(Device.PRIMARY_ID)), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    account = accountsManager.changeNumber(account,\n        targetNumber,\n        new IdentityKey(pniIdentityKeyPair.getPublicKey()),\n        Map.of(Device.PRIMARY_ID, ecSignedPreKey),\n        Map.of(Device.PRIMARY_ID, kemLastResoryPreKey),\n        Map.of(Device.PRIMARY_ID, 1));\n\n    assertEquals(targetNumber, account.getNumber());\n\n    assertTrue(phoneNumberIdentifiersByE164.containsKey(targetNumber));\n    final UUID newPni = phoneNumberIdentifiersByE164.get(targetNumber);\n\n    verify(keysManager).deleteSingleUsePreKeys(existingAccountUuid);\n    verify(keysManager).deleteSingleUsePreKeys(originalPni);\n    verify(keysManager, atLeastOnce()).deleteSingleUsePreKeys(targetPni);\n    verify(keysManager).deleteSingleUsePreKeys(newPni);\n    verify(keysManager).buildWriteItemsForRemovedDevice(existingAccountUuid, targetPni, Device.PRIMARY_ID);\n    verify(keysManager).buildWriteItemForEcSignedPreKey(newPni, Device.PRIMARY_ID, ecSignedPreKey);\n    verify(keysManager).buildWriteItemForLastResortKey(newPni, Device.PRIMARY_ID, kemLastResoryPreKey);\n    verifyNoMoreInteractions(keysManager);\n  }\n\n  @Test\n  void testChangePhoneNumberWithPqKeysExistingAccount() throws InterruptedException, MismatchedDevicesException {\n    final String originalNumber = \"+14152222222\";\n    final String targetNumber = \"+14153333333\";\n    final UUID existingAccountUuid = UUID.randomUUID();\n    final UUID uuid = UUID.randomUUID();\n    final UUID originalPni = UUID.randomUUID();\n    final UUID targetPni = UUID.randomUUID();\n    final byte deviceId2 = 2;\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final Map<Byte, ECSignedPreKey> newSignedKeys = Map.of(\n        Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, identityKeyPair),\n        deviceId2, KeysHelper.signedECPreKey(2, identityKeyPair));\n    final Map<Byte, KEMSignedPreKey> newSignedPqKeys = Map.of(\n        Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(4, identityKeyPair),\n        deviceId2, KeysHelper.signedKEMPreKey(5, identityKeyPair));\n    final Map<Byte, Integer> newRegistrationIds = Map.of(Device.PRIMARY_ID, 201, deviceId2, 202);\n\n    final Account existingAccount = AccountsHelper.generateTestAccount(targetNumber, existingAccountUuid, targetPni, new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    when(accounts.getByE164(targetNumber)).thenReturn(Optional.of(existingAccount));\n    when(keysManager.storePqLastResort(any(), anyByte(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final List<Device> devices = List.of(\n        DevicesHelper.createDevice(Device.PRIMARY_ID, 0L, 101),\n        DevicesHelper.createDevice(deviceId2, 0L, 102));\n    final Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, originalPni, devices, new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    final Account updatedAccount = accountsManager.changeNumber(\n        account, targetNumber, new IdentityKey(ECKeyPair.generate().getPublicKey()), newSignedKeys, newSignedPqKeys, newRegistrationIds);\n\n    assertEquals(targetNumber, updatedAccount.getNumber());\n\n    assertTrue(phoneNumberIdentifiersByE164.containsKey(targetNumber));\n\n    final UUID newPni = phoneNumberIdentifiersByE164.get(targetNumber);\n    verify(keysManager).deleteSingleUsePreKeys(existingAccountUuid);\n    verify(keysManager, atLeastOnce()).deleteSingleUsePreKeys(targetPni);\n    verify(keysManager).deleteSingleUsePreKeys(newPni);\n    verify(keysManager).deleteSingleUsePreKeys(originalPni);\n    verify(keysManager).buildWriteItemForEcSignedPreKey(eq(newPni), eq(Device.PRIMARY_ID), any());\n    verify(keysManager).buildWriteItemForEcSignedPreKey(eq(newPni), eq(deviceId2), any());\n    verify(keysManager).buildWriteItemForLastResortKey(eq(newPni), eq(Device.PRIMARY_ID), any());\n    verify(keysManager).buildWriteItemForLastResortKey(eq(newPni), eq(deviceId2), any());\n    verifyNoMoreInteractions(keysManager);\n  }\n\n\n  @Test\n  void testChangePhoneNumberWithMismatchedPqKeys() {\n    final String originalNumber = \"+14152222222\";\n    final String targetNumber = \"+14153333333\";\n    final UUID uuid = UUID.randomUUID();\n    final UUID originalPni = UUID.randomUUID();\n    final byte deviceId2 = 2;\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n    final Map<Byte, ECSignedPreKey> newSignedKeys = Map.of(\n        Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, identityKeyPair),\n        deviceId2, KeysHelper.signedECPreKey(2, identityKeyPair));\n    final Map<Byte, KEMSignedPreKey> newSignedPqKeys = Map.of(\n        Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(3, identityKeyPair));\n    final Map<Byte, Integer> newRegistrationIds = Map.of(Device.PRIMARY_ID, 201, deviceId2, 202);\n\n    final List<Device> devices = List.of(DevicesHelper.createDevice(Device.PRIMARY_ID, 0L, 101),\n        DevicesHelper.createDevice(deviceId2, 0L, 102));\n    final Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, originalPni, devices, new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    assertThrows(MismatchedDevicesException.class,\n        () -> accountsManager.changeNumber(\n            account, targetNumber, new IdentityKey(ECKeyPair.generate().getPublicKey()), newSignedKeys, newSignedPqKeys, newRegistrationIds));\n\n    verifyNoInteractions(accounts);\n    verifyNoInteractions(keysManager);\n  }\n\n  @Test\n  void testChangePhoneNumberViaUpdate() {\n    final String originalNumber = \"+14152222222\";\n    final String targetNumber = \"+14153333333\";\n    final UUID uuid = UUID.randomUUID();\n\n    final Account account = AccountsHelper.generateTestAccount(originalNumber, uuid, UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setNumber(targetNumber, UUID.randomUUID())));\n  }\n\n  @Test\n  void testReserveUsernameHash() throws UsernameHashNotAvailableException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    when(accounts.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n\n    final List<byte[]> usernameHashes = List.of(TestRandomUtil.nextBytes(32), TestRandomUtil.nextBytes(32));\n\n    final UsernameReservation result = accountsManager.reserveUsernameHash(account, usernameHashes);\n    assertArrayEquals(usernameHashes.getFirst(), result.reservedUsernameHash());\n    verify(accounts, times(1)).reserveUsernameHash(eq(account), any(), eq(Duration.ofMinutes(5)));\n  }\n\n  @Test\n  void testReserveOwnUsernameHash() throws UsernameHashNotAvailableException {\n    final byte[] oldUsernameHash = TestRandomUtil.nextBytes(32);\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    account.setUsernameHash(oldUsernameHash);\n    when(accounts.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n\n    final List<byte[]> usernameHashes = List.of(TestRandomUtil.nextBytes(32), oldUsernameHash, TestRandomUtil.nextBytes(32));\n\n    final UsernameReservation result = accountsManager.reserveUsernameHash(account, usernameHashes);\n    assertArrayEquals(oldUsernameHash, result.reservedUsernameHash());\n    verify(accounts, never()).reserveUsernameHash(any(), any(), any());\n  }\n\n  @Test\n  void testReserveUsernameOptimisticLockingFailure() throws UsernameHashNotAvailableException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    when(accounts.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n\n    final List<byte[]> usernameHashes = List.of(TestRandomUtil.nextBytes(32), TestRandomUtil.nextBytes(32));\n\n    doThrow(new ContestedOptimisticLockException())\n        .doNothing()\n        .when(accounts).reserveUsernameHash(any(), any(), any());\n\n    final UsernameReservation result = accountsManager.reserveUsernameHash(account, usernameHashes);\n    assertArrayEquals(usernameHashes.getFirst(), result.reservedUsernameHash());\n    verify(accounts, times(2)).reserveUsernameHash(eq(account), any(), eq(Duration.ofMinutes(5)));\n  }\n\n  @Test\n  void testReserveUsernameHashAsyncNotAvailable() throws UsernameHashNotAvailableException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    when(accounts.getByAccountIdentifierAsync(account.getUuid())).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    doThrow(new UsernameHashNotAvailableException())\n        .when(accounts).reserveUsernameHash(any(), any(), any());\n\n    assertThrows(UsernameHashNotAvailableException.class, () ->\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_1, USERNAME_HASH_2)));\n  }\n\n  @Test\n  void testConfirmReservedUsernameHash() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    setReservationHash(account, USERNAME_HASH_1);\n\n    accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    verify(accounts).confirmUsernameHash(eq(account), eq(USERNAME_HASH_1), eq(ENCRYPTED_USERNAME_1));\n  }\n\n  @Test\n  void testConfirmReservedUsernameHashOptimisticLockingFailure() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    setReservationHash(account, USERNAME_HASH_1);\n    when(accounts.getByAccountIdentifier(account.getUuid())).thenReturn(Optional.of(account));\n\n    doThrow(new ContestedOptimisticLockException())\n        .doNothing()\n        .when(accounts).confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    verify(accounts, times(2)).confirmUsernameHash(eq(account), eq(USERNAME_HASH_1), eq(ENCRYPTED_USERNAME_1));\n  }\n\n  @Test\n  void testConfirmReservedHashNameMismatch() {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    setReservationHash(account, USERNAME_HASH_1);\n    assertThrows(UsernameReservationNotFoundException.class,\n        () -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_2, ENCRYPTED_USERNAME_2));\n  }\n\n  @Test\n  void testConfirmReservedLapsed() throws UsernameHashNotAvailableException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    // hash was reserved, but the reservation lapsed and another account took it\n    setReservationHash(account, USERNAME_HASH_1);\n    doThrow(new UsernameHashNotAvailableException())\n        .when(accounts).confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n    assertTrue(account.getUsernameHash().isEmpty());\n  }\n\n  @Test\n  void testConfirmReservedRetry() throws UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    account.setUsernameHash(USERNAME_HASH_1);\n\n    // reserved username already set, should be treated as a replay\n    accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    verifyNoInteractions(accounts);\n  }\n\n  @Test\n  void testConfirmReservedUsernameHashWithNoReservation() throws UsernameHashNotAvailableException {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(),\n        new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    assertThrows(UsernameReservationNotFoundException.class,\n        () -> accountsManager.confirmReservedUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n    verify(accounts, never()).confirmUsernameHash(any(), any(), any());\n  }\n\n  @Test\n  void testClearUsernameHash() {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n    account.setUsernameHash(USERNAME_HASH_1);\n    accountsManager.clearUsernameHash(account);\n    verify(accounts).clearUsernameHash(eq(account));\n  }\n\n  @Test\n  void testSetUsernameViaUpdate() {\n    final Account account = AccountsHelper.generateTestAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID(), new ArrayList<>(), new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH]);\n\n    assertThrows(AssertionError.class, () -> accountsManager.update(account, a -> a.setUsernameHash(USERNAME_HASH_1)));\n  }\n\n  @Test\n  void testOnlyPrimaryCanWaitForDeviceLinked() {\n    final Device primaryDevice = new Device();\n    primaryDevice.setId(Device.PRIMARY_ID);\n\n    final Device linkedDevice = new Device();\n    linkedDevice.setId((byte) (Device.PRIMARY_ID + 1));\n\n    final Account account = AccountsHelper.generateTestAccount(\"+14152222222\", List.of(primaryDevice, linkedDevice));\n\n    assertThrows(IllegalArgumentException.class,\n        () -> accountsManager.waitForNewLinkedDevice(account.getUuid(), linkedDevice, \"\", Duration.ofSeconds(1)));\n\n  }\n\n  @Test\n  void testJsonRoundTripSerialization() throws Exception {\n    String originalJson;\n    try (InputStream inputStream = getClass().getResourceAsStream(\n        \"AccountsManagerTest-testJsonRoundTripSerialization.json\")) {\n      Objects.requireNonNull(inputStream);\n      originalJson = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);\n    }\n\n    final Account originalAccount = AccountsManager.parseAccountJson(originalJson,\n        UUID.fromString(\"111111-1111-1111-1111-111111111111\")).orElseThrow();\n\n    final String serialized = AccountsManager.writeRedisAccountJson(originalAccount);\n    final Account parsedAccount = AccountsManager.parseAccountJson(serialized, originalAccount.getUuid()).orElseThrow();\n\n    assertEquals(originalAccount.getUuid(), parsedAccount.getUuid());\n    assertEquals(originalAccount.getPhoneNumberIdentifier(), parsedAccount.getPhoneNumberIdentifier());\n    assertEquals(originalAccount.getIdentityKey(IdentityType.ACI), parsedAccount.getIdentityKey(IdentityType.ACI));\n    assertEquals(originalAccount.getIdentityKey(IdentityType.PNI), parsedAccount.getIdentityKey(IdentityType.PNI));\n    assertEquals(originalAccount.getNumber(), parsedAccount.getNumber());\n    assertArrayEquals(originalAccount.getUnidentifiedAccessKey().orElseThrow(),\n        parsedAccount.getUnidentifiedAccessKey().orElseThrow());\n    assertEquals(originalAccount.isDiscoverableByPhoneNumber(), parsedAccount.isDiscoverableByPhoneNumber());\n    assertEquals(originalAccount.isUnrestrictedUnidentifiedAccess(), parsedAccount.isUnrestrictedUnidentifiedAccess());\n\n    assertEquals(originalAccount.getDevices().size(), parsedAccount.getDevices().size());\n\n    final Device originalDevice = originalAccount.getPrimaryDevice();\n    final Device parsedDevice = parsedAccount.getPrimaryDevice();\n\n    assertEquals(originalDevice.getId(), parsedDevice.getId());\n    assertEquals(originalDevice.getRegistrationId(IdentityType.ACI), parsedDevice.getRegistrationId(IdentityType.ACI));\n    assertEquals(originalDevice.getRegistrationId(IdentityType.PNI), parsedDevice.getRegistrationId(IdentityType.PNI));\n    assertEquals(originalDevice.getCapabilities(), parsedDevice.getCapabilities());\n    assertEquals(originalDevice.getFetchesMessages(), parsedDevice.getFetchesMessages());\n  }\n\n  private void setReservationHash(final Account account, final byte[] reservedUsernameHash) {\n    account.setReservedUsernameHash(reservedUsernameHash);\n  }\n\n  private static Device generateTestDevice(final long lastSeen) {\n    final Device device = new Device();\n    device.setId(Device.PRIMARY_ID);\n    device.setFetchesMessages(true);\n    device.setLastSeen(lastSeen);\n\n    return device;\n  }\n\n  private Account createAccount(final String e164, final AccountAttributes accountAttributes) throws InterruptedException {\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    return accountsManager.create(e164,\n        accountAttributes,\n        new ArrayList<>(),\n        new IdentityKey(aciKeyPair.getPublicKey()),\n        new IdentityKey(pniKeyPair.getPublicKey()),\n        new DeviceSpec(\n            accountAttributes.getName(),\n            \"password\",\n            null,\n            accountAttributes.getCapabilities(),\n            accountAttributes.getRegistrationId(),\n            accountAttributes.getPhoneNumberIdentityRegistrationId(),\n            accountAttributes.getFetchesMessages(),\n            Optional.empty(),\n            Optional.empty(),\n            KeysHelper.signedECPreKey(1, aciKeyPair),\n            KeysHelper.signedECPreKey(2, pniKeyPair),\n            KeysHelper.signedKEMPreKey(3, aciKeyPair),\n            KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n        null);\n  }\n\n  @Test\n  void checkDeviceLinkingToken() {\n    final UUID aci = UUID.randomUUID();\n\n    assertEquals(Optional.of(aci),\n        accountsManager.checkDeviceLinkingToken(accountsManager.generateLinkDeviceToken(aci)));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void checkVerificationTokenBadToken(final String token, final Instant currentTime) {\n    CLOCK.pin(currentTime);\n\n    assertEquals(Optional.empty(), accountsManager.checkDeviceLinkingToken(token));\n  }\n\n  private static Stream<Arguments> checkVerificationTokenBadToken() throws InvalidKeyException {\n    final Instant tokenTimestamp = Instant.now();\n\n    return Stream.of(\n        // Expired token\n        Arguments.of(AccountsManager.generateLinkDeviceToken(UUID.randomUUID(),\n                new SecretKeySpec(LINK_DEVICE_SECRET, AccountsManager.LINK_DEVICE_VERIFICATION_TOKEN_ALGORITHM),\n                CLOCK),\n            tokenTimestamp.plus(AccountsManager.LINK_DEVICE_TOKEN_EXPIRATION_DURATION).plusSeconds(1)),\n\n        // Bad UUID\n        Arguments.of(\"not-a-valid-uuid.1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=\", tokenTimestamp),\n\n        // No UUID\n        Arguments.of(\".1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=\", tokenTimestamp),\n\n        // Bad timestamp\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4.not-a-valid-timestamp:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=\", tokenTimestamp),\n\n        // No timestamp\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=\", tokenTimestamp),\n\n        // Blank timestamp\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4.:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=\", tokenTimestamp),\n\n        // No signature\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4.1691096565171\", tokenTimestamp),\n\n        // Blank signature\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:\", tokenTimestamp),\n\n        // Incorrect signature\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:0CKWF7q3E9fi4sB2or4q1A0Up2z_73EQlMAy7Dpel9c=\", tokenTimestamp),\n\n        // Invalid signature\n        Arguments.of(\"e552603a-1492-4de6-872d-bac19a2825b4.1691096565171:This is not valid base64\", tokenTimestamp)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void validateCompleteDeviceList(final Account account, final Set<Byte> deviceIds, @Nullable final MismatchedDevicesException expectedException) {\n    final Executable validateCompleteDeviceListExecutable =\n        () -> AccountsManager.validateCompleteDeviceList(account, deviceIds);\n\n    if (expectedException != null) {\n      final MismatchedDevicesException caughtException =\n          assertThrows(MismatchedDevicesException.class, validateCompleteDeviceListExecutable);\n\n      assertEquals(expectedException.getMismatchedDevices(), caughtException.getMismatchedDevices());\n    } else {\n      assertDoesNotThrow(validateCompleteDeviceListExecutable);\n    }\n  }\n\n  private static List<Arguments> validateCompleteDeviceList() {\n    final byte deviceId = Device.PRIMARY_ID;\n    final byte extraDeviceId = deviceId + 1;\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n\n    final Account account = mock(Account.class);\n    when(account.getDevices()).thenReturn(List.of(device));\n\n    return List.of(\n        Arguments.of(account, Set.of(deviceId), null),\n\n        Arguments.of(account, Set.of(deviceId, extraDeviceId),\n            new MismatchedDevicesException(\n                new MismatchedDevices(Collections.emptySet(), Set.of(extraDeviceId), Collections.emptySet()))),\n\n        Arguments.of(account, Collections.emptySet(),\n            new MismatchedDevicesException(\n                new MismatchedDevices(Set.of(deviceId), Collections.emptySet(), Collections.emptySet()))),\n\n        Arguments.of(account, Set.of(extraDeviceId),\n            new MismatchedDevicesException(\n                new MismatchedDevices(Set.of(deviceId), Set.of((byte) (extraDeviceId)), Collections.emptySet())))\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsManagerUsernameIntegrationTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anySet;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executors;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.mockito.Mockito;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.ThrowingSupplier;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\nclass AccountsManagerUsernameIntegrationTest {\n\n  private static final String BASE_64_URL_USERNAME_HASH_1 = \"9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE\";\n  private static final String BASE_64_URL_USERNAME_HASH_2 = \"NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_1 = \"md1votbj9r794DsqTNrBqA\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_2 = \"9hrqVLy59bzgPse-S9NUsA\";\n  private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);\n  private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);\n  private static final byte[] ENCRYPTED_USERNAME_1 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_1);\n  private static final byte[] ENCRYPTED_USERNAME_2 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_2);\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      Tables.ACCOUNTS,\n      Tables.NUMBERS,\n      Tables.USERNAMES,\n      Tables.DELETED_ACCOUNTS,\n      Tables.PNI,\n      Tables.PNI_ASSIGNMENTS,\n      Tables.EC_KEYS,\n      Tables.PAGED_PQ_KEYS,\n      Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,\n      Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  @RegisterExtension\n  static RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @RegisterExtension\n  static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension(\"testbucket\");\n\n  private AccountsManager accountsManager;\n  private Accounts accounts;\n\n  @BeforeEach\n  void setup() throws Exception {\n    buildAccountsManager(1, 2, 10);\n  }\n\n  private void buildAccountsManager(final int initialWidth, int discriminatorMaxWidth, int attemptsPerWidth)\n      throws Exception {\n\n    final DynamoDbAsyncClient dynamoDbAsyncClient = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient();\n    final KeysManager keysManager = new KeysManager(\n        new SingleUseECPreKeyStore(dynamoDbAsyncClient, DynamoDbExtensionSchema.Tables.EC_KEYS.tableName()),\n        new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,\n            S3_EXTENSION.getS3Client(),\n            DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),\n            S3_EXTENSION.getBucketName()),\n        new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,\n            DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),\n        new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,\n            DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));\n\n    accounts = Mockito.spy(new Accounts(\n        Clock.systemUTC(),\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName()));\n\n    final AccountLockManager accountLockManager = mock(AccountLockManager.class);\n\n    doAnswer(invocation -> {\n      final ThrowingSupplier<?, ?> task = invocation.getArgument(1);\n      return task.get();\n    }).when(accountLockManager).withLock(anySet(), any(), any());\n\n    final PhoneNumberIdentifiers phoneNumberIdentifiers =\n        new PhoneNumberIdentifiers(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), Tables.PNI.tableName());\n\n    final MessagesManager messageManager = mock(MessagesManager.class);\n    final ProfilesManager profileManager = mock(ProfilesManager.class);\n    when(messageManager.clear(any())).thenReturn(CompletableFuture.completedFuture(null));\n    when(profileManager.deleteAll(any(), anyBoolean())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final DisconnectionRequestManager disconnectionRequestManager = mock(DisconnectionRequestManager.class);\n    when(disconnectionRequestManager.requestDisconnection(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    accountsManager = new AccountsManager(\n        accounts,\n        phoneNumberIdentifiers,\n        CACHE_CLUSTER_EXTENSION.getRedisCluster(),\n        mock(FaultTolerantRedisClient.class),\n        accountLockManager,\n        keysManager,\n        messageManager,\n        profileManager,\n        mock(SecureStorageClient.class),\n        mock(SecureValueRecoveryClient.class),\n        disconnectionRequestManager,\n        mock(RegistrationRecoveryPasswordsManager.class),\n        Executors.newSingleThreadExecutor(),\n        Executors.newSingleThreadScheduledExecutor(),\n        Executors.newSingleThreadScheduledExecutor(),\n        mock(Clock.class),\n        \"link-device-secret\".getBytes(StandardCharsets.UTF_8));\n  }\n\n  @Test\n  void testNoUsernames() throws InterruptedException {\n    final Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    List<byte[]> usernameHashes = List.of(USERNAME_HASH_1, USERNAME_HASH_2);\n    int i = 0;\n    for (byte[] hash : usernameHashes) {\n      final Map<String, AttributeValue> item = new HashMap<>(Map.of(\n          Accounts.UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),\n          Accounts.UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(hash)));\n      // half of these are taken usernames, half are only reservations (have a TTL)\n      if (i % 2 == 0) {\n        item.put(Accounts.UsernameTable.ATTR_TTL,\n            AttributeValues.fromLong(Instant.now().plus(Duration.ofMinutes(1)).getEpochSecond()));\n      }\n      i++;\n      DYNAMO_DB_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()\n          .tableName(Tables.USERNAMES.tableName())\n          .item(item)\n          .build());\n    }\n\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accountsManager.reserveUsernameHash(account, usernameHashes));\n\n    assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty();\n  }\n\n  @Test\n  void testReserveUsernameGetFirstAvailableChoice() throws InterruptedException, UsernameHashNotAvailableException {\n    final Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    ArrayList<byte[]> usernameHashes = new ArrayList<>(Arrays.asList(USERNAME_HASH_1, USERNAME_HASH_2));\n    for (byte[] hash : usernameHashes) {\n      DYNAMO_DB_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()\n          .tableName(Tables.USERNAMES.tableName())\n          .item(Map.of(\n              Accounts.UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.fromUUID(UUID.randomUUID()),\n              Accounts.UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(hash)))\n          .build());\n    }\n\n\n    byte[] availableHash = TestRandomUtil.nextBytes(32);\n    usernameHashes.add(availableHash);\n    usernameHashes.add(TestRandomUtil.nextBytes(32));\n\n    final byte[] username = accountsManager\n        .reserveUsernameHash(account, usernameHashes)\n        .reservedUsernameHash();\n\n    assertArrayEquals(username, availableHash);\n  }\n\n  @Test\n  public void testReserveConfirmClear()\n      throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    // reserve\n    AccountsManager.UsernameReservation reservation =\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_1));\n\n    assertArrayEquals(reservation.account().getReservedUsernameHash().orElseThrow(), USERNAME_HASH_1);\n    assertThat(accountsManager.getByUsernameHash(reservation.reservedUsernameHash()).join()).isEmpty();\n\n    // confirm\n    account = accountsManager.confirmReservedUsernameHash(\n        reservation.account(),\n        reservation.reservedUsernameHash(),\n        ENCRYPTED_USERNAME_1);\n    assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);\n    assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).join().orElseThrow().getUuid()).isEqualTo(\n        account.getUuid());\n    assertThat(account.getUsernameLinkHandle()).isNotNull();\n    assertThat(accountsManager.getByUsernameLinkHandle(account.getUsernameLinkHandle()).join().orElseThrow().getUuid())\n        .isEqualTo(account.getUuid());\n\n    // clear\n    account = accountsManager.clearUsernameHash(account);\n    assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n    assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty();\n  }\n\n  @Test\n  public void testHold()\n      throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    AccountsManager.UsernameReservation reservation =\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_1));\n\n    // confirm\n    account = accountsManager.confirmReservedUsernameHash(\n        reservation.account(),\n        reservation.reservedUsernameHash(),\n        ENCRYPTED_USERNAME_1);\n\n    // clear\n    account = accountsManager.clearUsernameHash(account);\n    assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n    assertThat(accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getUsernameHash()).isEmpty();\n\n    assertThat(accountsManager.getByUsernameHash(reservation.reservedUsernameHash()).join()).isEmpty();\n\n    Account account2 = AccountsHelper.createAccount(accountsManager, \"+18005552222\");\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accountsManager.reserveUsernameHash(account2, List.of(USERNAME_HASH_1)),\n        \"account2 should not be able to reserve a held hash\");\n  }\n\n  @Test\n  public void testReservationLapsed()\n      throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    final Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    AccountsManager.UsernameReservation reservation1 =\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_1));\n\n    long past = Instant.now().minus(Duration.ofMinutes(1)).getEpochSecond();\n    // force expiration\n    DYNAMO_DB_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()\n        .tableName(Tables.USERNAMES.tableName())\n        .key(Map.of(Accounts.UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(USERNAME_HASH_1)))\n        .updateExpression(\"SET #ttl = :ttl\")\n        .expressionAttributeNames(Map.of(\"#ttl\", Accounts.UsernameTable.ATTR_TTL))\n        .expressionAttributeValues(Map.of(\":ttl\", AttributeValues.fromLong(past)))\n        .build());\n\n    // a different account should be able to reserve it\n    Account account2 = AccountsHelper.createAccount(accountsManager, \"+18005552222\");\n\n    final AccountsManager.UsernameReservation reservation2 =\n        accountsManager.reserveUsernameHash(account2, List.of(USERNAME_HASH_1));\n    assertArrayEquals(reservation2.reservedUsernameHash(), USERNAME_HASH_1);\n\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n    account2 = accountsManager.confirmReservedUsernameHash(reservation2.account(), USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    assertEquals(accountsManager.getByUsernameHash(USERNAME_HASH_1).join().orElseThrow().getUuid(), account2.getUuid());\n    assertArrayEquals(account2.getUsernameHash().orElseThrow(), USERNAME_HASH_1);\n  }\n\n  @Test\n  void testUsernameSetReserveAnotherClearSetReserved()\n      throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    // Set username hash\n    final AccountsManager.UsernameReservation reservation1 =\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_1));\n\n    account = accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    // Reserve another hash on the same account\n    final AccountsManager.UsernameReservation reservation2 =\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_2));\n\n    account = reservation2.account();\n\n    assertArrayEquals(account.getReservedUsernameHash().orElseThrow(), USERNAME_HASH_2);\n    assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_1);\n    assertArrayEquals(account.getEncryptedUsername().orElseThrow(), ENCRYPTED_USERNAME_1);\n\n    // Clear the set username hash but not the reserved one\n    account = accountsManager.clearUsernameHash(account);\n    assertThat(account.getReservedUsernameHash()).isPresent();\n    assertThat(account.getUsernameHash()).isEmpty();\n\n    // Confirm second reservation\n    account = accountsManager.confirmReservedUsernameHash(account, reservation2.reservedUsernameHash(), ENCRYPTED_USERNAME_2);\n    assertArrayEquals(account.getUsernameHash().orElseThrow(), USERNAME_HASH_2);\n    assertArrayEquals(account.getEncryptedUsername().orElseThrow(), ENCRYPTED_USERNAME_2);\n  }\n\n  @Test\n  public void testReclaim()\n      throws InterruptedException, UsernameHashNotAvailableException, UsernameReservationNotFoundException {\n    Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n    final AccountsManager.UsernameReservation reservation1 =\n        accountsManager.reserveUsernameHash(account, List.of(USERNAME_HASH_1));\n    account = accountsManager.confirmReservedUsernameHash(reservation1.account(), USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    // \"reclaim\" the account by re-registering\n    Account reclaimed = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    // the username should still be reserved, but no longer on our account.\n    assertThat(reclaimed.getUsernameHash()).isEmpty();\n\n    // Make sure we can't lookup the account\n    assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n\n    // confirm it again\n    accountsManager.confirmReservedUsernameHash(reclaimed, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    assertThat(accountsManager.getByUsernameHash(USERNAME_HASH_1).join()).isPresent();\n  }\n\n  @Test\n  public void testUsernameLinks() throws InterruptedException, AccountAlreadyExistsException {\n    final Account account = AccountsHelper.createAccount(accountsManager, \"+18005551111\");\n\n    account.setUsernameHash(TestRandomUtil.nextBytes(16));\n    accounts.create(account, Collections.emptyList());\n\n    final UUID linkHandle = UUID.randomUUID();\n    final byte[] encryptedUsername = TestRandomUtil.nextBytes(32);\n    accountsManager.update(account, a -> a.setUsernameLinkDetails(linkHandle, encryptedUsername));\n\n    final Optional<Account> maybeAccount = accountsManager.getByUsernameLinkHandle(linkHandle).join();\n    assertTrue(maybeAccount.isPresent());\n    assertTrue(maybeAccount.get().getEncryptedUsername().isPresent());\n    assertArrayEquals(encryptedUsername, maybeAccount.get().getEncryptedUsername().get());\n\n    // making some unrelated change and updating account to check that username link data is still there\n    final Optional<Account> accountToChange = accountsManager.getByAccountIdentifier(account.getUuid());\n    assertTrue(accountToChange.isPresent());\n    accountsManager.update(accountToChange.get(), a -> a.setDiscoverableByPhoneNumber(!a.isDiscoverableByPhoneNumber()));\n    final Optional<Account> accountAfterChange = accountsManager.getByUsernameLinkHandle(linkHandle).join();\n    assertTrue(accountAfterChange.isPresent());\n    assertTrue(accountAfterChange.get().getEncryptedUsername().isPresent());\n    assertArrayEquals(encryptedUsername, accountAfterChange.get().getEncryptedUsername().get());\n\n    // now deleting the link\n    final Optional<Account> accountToDeleteLink = accountsManager.getByAccountIdentifier(account.getUuid());\n    accountsManager.update(accountToDeleteLink.orElseThrow(), a -> a.setUsernameLinkDetails(null, null));\n    assertTrue(accounts.getByUsernameLinkHandle(linkHandle).join().isEmpty());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AccountsTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.assertj.core.api.Assertions.assertThatThrownBy;\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Random;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.function.BiConsumer;\nimport java.util.function.Consumer;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.signal.libsignal.zkgroup.backups.BackupCredentialType;\nimport org.whispersystems.textsecuregcm.auth.UnidentifiedAccessUtil;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.scheduler.Schedulers;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.CancellationReason;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\nimport software.amazon.awssdk.services.dynamodb.model.Put;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ReturnValuesOnConditionCheckFailure;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItem;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsResponse;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionConflictException;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\n\n@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass AccountsTest {\n\n  private static final byte DEVICE_ID_1 = 1;\n  private static final byte DEVICE_ID_2 = 2;\n\n  private static final String BASE_64_URL_USERNAME_HASH_1 = \"9p6Tip7BFefFOJzv4kv4GyXEYsBVfk_WbjNejdlOvQE\";\n  private static final String BASE_64_URL_USERNAME_HASH_2 = \"NLUom-CHwtemcdvOTTXdmXmzRIV7F05leS8lwkVK_vc\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_1 = \"md1votbj9r794DsqTNrBqA\";\n  private static final String BASE_64_URL_ENCRYPTED_USERNAME_2 = \"9hrqVLy59bzgPse-S9NUsA\";\n  private static final byte[] USERNAME_HASH_1 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_1);\n  private static final byte[] USERNAME_HASH_2 = Base64.getUrlDecoder().decode(BASE_64_URL_USERNAME_HASH_2);\n  private static final byte[] ENCRYPTED_USERNAME_1 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_1);\n  private static final byte[] ENCRYPTED_USERNAME_2 = Base64.getUrlDecoder().decode(BASE_64_URL_ENCRYPTED_USERNAME_2);\n\n  private static final AtomicInteger ACCOUNT_COUNTER = new AtomicInteger(1);\n\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      Tables.ACCOUNTS,\n      Tables.NUMBERS,\n      Tables.PNI_ASSIGNMENTS,\n      Tables.USERNAMES,\n      Tables.DELETED_ACCOUNTS,\n      Tables.USED_LINK_DEVICE_TOKENS,\n\n      // This is an unrelated table used to test \"tag-along\" transactional updates\n      Tables.CLIENT_RELEASES);\n\n  private final TestClock clock = TestClock.pinned(Instant.EPOCH);\n  private Accounts accounts;\n\n  private record UsernameConstraint(UUID accountIdentifier, boolean confirmed, Optional<Instant> expiration) {\n  }\n\n  @BeforeEach\n  void setupAccountsDao() {\n\n    @SuppressWarnings(\"unchecked\") DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());\n\n    clock.pin(Instant.EPOCH);\n    accounts = new Accounts(\n        clock,\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n  }\n\n  @Test\n  public void testStoreAndLookupUsernameLink() {\n    final Account account = nextRandomAccount();\n    account.setUsernameHash(TestRandomUtil.nextBytes(16));\n    createAccount(account);\n\n    final BiConsumer<Optional<Account>, byte[]> validator = (maybeAccount, expectedEncryptedUsername) -> {\n      assertTrue(maybeAccount.isPresent());\n      assertTrue(maybeAccount.get().getEncryptedUsername().isPresent());\n      assertEquals(account.getUuid(), maybeAccount.get().getUuid());\n      assertArrayEquals(expectedEncryptedUsername, maybeAccount.get().getEncryptedUsername().get());\n    };\n\n    // creating a username link, storing it, checking that it can be looked up\n    final UUID linkHandle1 = UUID.randomUUID();\n    final byte[] encruptedUsername1 = TestRandomUtil.nextBytes(32);\n    account.setUsernameLinkDetails(linkHandle1, encruptedUsername1);\n    accounts.update(account);\n    validator.accept(accounts.getByUsernameLinkHandle(linkHandle1).join(), encruptedUsername1);\n\n    // updating username link, storing new one, checking that it can be looked up, checking that old one can't be looked up\n    final UUID linkHandle2 = UUID.randomUUID();\n    final byte[] encruptedUsername2 = TestRandomUtil.nextBytes(32);\n    account.setUsernameLinkDetails(linkHandle2, encruptedUsername2);\n    accounts.update(account);\n    validator.accept(accounts.getByUsernameLinkHandle(linkHandle2).join(), encruptedUsername2);\n    assertTrue(accounts.getByUsernameLinkHandle(linkHandle1).join().isEmpty());\n\n    // deleting username link, checking it can't be looked up by either handle\n    account.setUsernameLinkDetails(null, null);\n    accounts.update(account);\n    assertTrue(accounts.getByUsernameLinkHandle(linkHandle1).join().isEmpty());\n    assertTrue(accounts.getByUsernameLinkHandle(linkHandle2).join().isEmpty());\n  }\n\n  @Test\n  void testStore() {\n    Device device = generateDevice(DEVICE_ID_1);\n    Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n\n    boolean freshUser = createAccount(account);\n\n    assertThat(freshUser).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n\n    freshUser = createAccount(account);\n    assertThat(freshUser).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n  }\n\n  @Test\n  void testStoreRecentlyDeleted() {\n    final UUID originalUuid = UUID.randomUUID();\n\n    Device device = generateDevice(DEVICE_ID_1);\n    Account account = generateAccount(\"+14151112222\", originalUuid, UUID.randomUUID(), List.of(device));\n\n    boolean freshUser = createAccount(account);\n\n    assertThat(freshUser).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n\n    accounts.delete(originalUuid, Collections.emptyList());\n    assertThat(accounts.findRecentlyDeletedAccountIdentifier(account.getPhoneNumberIdentifier())).hasValue(originalUuid);\n\n    freshUser = createAccount(account);\n    assertThat(freshUser).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n\n    assertThat(accounts.findRecentlyDeletedAccountIdentifier(account.getPhoneNumberIdentifier())).isEmpty();\n  }\n\n  @Test\n  void testStoreMulti() {\n    final List<Device> devices = List.of(generateDevice(DEVICE_ID_1), generateDevice(DEVICE_ID_2));\n    final Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), devices);\n\n    createAccount(account);\n\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n  }\n\n  @Test\n  void testStoreAciCollisionFails() {\n    Device device = generateDevice(DEVICE_ID_1);\n    Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n\n    boolean freshUser = createAccount(account);\n\n    assertThat(freshUser).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n\n    account.setNumber(\"+14153334444\", UUID.randomUUID());\n    assertThrows(IllegalArgumentException.class, () -> createAccount(account),\n        \"Reusing ACI with different PNI should fail\");\n  }\n\n  @Test\n  void testStorePniCollisionFails() {\n    Device device1 = generateDevice(DEVICE_ID_1);\n    Account account1 = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(device1));\n\n    boolean freshUser = createAccount(account1);\n\n    assertThat(freshUser).isTrue();\n    verifyStoredState(\"+14151112222\", account1.getUuid(), account1.getPhoneNumberIdentifier(), null, account1, true);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account1.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account1.getPhoneNumberIdentifier(), account1.getUuid());\n\n    Device device2 = generateDevice(DEVICE_ID_1);\n    Account account2 = generateAccount(\"+14151112222\", UUID.randomUUID(), account1.getPhoneNumberIdentifier(),\n        List.of(device2));\n\n    assertThrows(AccountAlreadyExistsException.class, () -> accounts.create(account2, Collections.emptyList()),\n        \"New ACI with same PNI should fail\");\n  }\n\n  @Test\n  void testRetrieve() {\n    final List<Device> devicesFirst = List.of(generateDevice(DEVICE_ID_1), generateDevice(DEVICE_ID_2));\n\n    UUID uuidFirst = UUID.randomUUID();\n    UUID pniFirst = UUID.randomUUID();\n    Account accountFirst = generateAccount(\"+14151112222\", uuidFirst, pniFirst, devicesFirst);\n\n    final List<Device> devicesSecond = List.of(generateDevice(DEVICE_ID_1), generateDevice(DEVICE_ID_2));\n\n    UUID uuidSecond = UUID.randomUUID();\n    UUID pniSecond = UUID.randomUUID();\n    Account accountSecond = generateAccount(\"+14152221111\", uuidSecond, pniSecond, devicesSecond);\n\n    createAccount(accountFirst);\n    createAccount(accountSecond);\n\n    Optional<Account> retrievedFirst = accounts.getByE164(\"+14151112222\");\n    Optional<Account> retrievedSecond = accounts.getByE164(\"+14152221111\");\n\n    assertThat(retrievedFirst.isPresent()).isTrue();\n    assertThat(retrievedSecond.isPresent()).isTrue();\n\n    verifyStoredState(\"+14151112222\", uuidFirst, pniFirst, null, retrievedFirst.get(), accountFirst);\n    verifyStoredState(\"+14152221111\", uuidSecond, pniSecond, null, retrievedSecond.get(), accountSecond);\n\n    retrievedFirst = accounts.getByAccountIdentifier(uuidFirst);\n    retrievedSecond = accounts.getByAccountIdentifier(uuidSecond);\n\n    assertThat(retrievedFirst.isPresent()).isTrue();\n    assertThat(retrievedSecond.isPresent()).isTrue();\n\n    verifyStoredState(\"+14151112222\", uuidFirst, pniFirst, null, retrievedFirst.get(), accountFirst);\n    verifyStoredState(\"+14152221111\", uuidSecond, pniSecond, null, retrievedSecond.get(), accountSecond);\n\n    retrievedFirst = accounts.getByPhoneNumberIdentifier(pniFirst);\n    retrievedSecond = accounts.getByPhoneNumberIdentifier(pniSecond);\n\n    assertThat(retrievedFirst.isPresent()).isTrue();\n    assertThat(retrievedSecond.isPresent()).isTrue();\n\n    verifyStoredState(\"+14151112222\", uuidFirst, pniFirst, null, retrievedFirst.get(), accountFirst);\n    verifyStoredState(\"+14152221111\", uuidSecond, pniSecond, null, retrievedSecond.get(), accountSecond);\n  }\n\n  @Test\n  void testRetrieveNoPni() throws JsonProcessingException {\n    final List<Device> devices = List.of(generateDevice(DEVICE_ID_1), generateDevice(DEVICE_ID_2));\n    final UUID uuid = UUID.randomUUID();\n    final Account account = generateAccount(\"+14151112222\", uuid, null, devices);\n\n    // Accounts#create enforces that newly-created accounts have a PNI, so we need to make a bit of an end-run around it\n    // to simulate an existing account with no PNI.\n    {\n      final TransactWriteItem phoneNumberConstraintPut = TransactWriteItem.builder()\n          .put(\n              Put.builder()\n                  .tableName(Tables.NUMBERS.tableName())\n                  .item(Map.of(\n                      Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()),\n                      Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))\n                  .conditionExpression(\n                      \"attribute_not_exists(#number) OR (attribute_exists(#number) AND #uuid = :uuid)\")\n                  .expressionAttributeNames(\n                      Map.of(\"#uuid\", Accounts.KEY_ACCOUNT_UUID,\n                          \"#number\", Accounts.ATTR_ACCOUNT_E164))\n                  .expressionAttributeValues(\n                      Map.of(\":uuid\", AttributeValues.fromUUID(account.getUuid())))\n                  .returnValuesOnConditionCheckFailure(ReturnValuesOnConditionCheckFailure.ALL_OLD)\n                  .build())\n          .build();\n\n      final TransactWriteItem accountPut = TransactWriteItem.builder()\n          .put(Put.builder()\n              .tableName(Tables.ACCOUNTS.tableName())\n              .conditionExpression(\"attribute_not_exists(#number) OR #number = :number\")\n              .expressionAttributeNames(Map.of(\"#number\", Accounts.ATTR_ACCOUNT_E164))\n              .expressionAttributeValues(Map.of(\":number\", AttributeValues.fromString(account.getNumber())))\n              .item(Map.of(\n                  Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid),\n                  Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()),\n                  Accounts.ATTR_ACCOUNT_DATA, AttributeValues.fromByteArray(SystemMapper.jsonMapper().writeValueAsBytes(account)),\n                  Accounts.ATTR_VERSION, AttributeValues.fromInt(account.getVersion()),\n                  Accounts.ATTR_CANONICALLY_DISCOVERABLE, AttributeValues.fromBool(account.isDiscoverableByPhoneNumber())))\n              .build())\n          .build();\n\n      DYNAMO_DB_EXTENSION.getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()\n          .transactItems(phoneNumberConstraintPut, accountPut)\n          .build());\n    }\n\n    Optional<Account> retrieved = accounts.getByE164(\"+14151112222\");\n\n    assertThat(retrieved.isPresent()).isTrue();\n    verifyStoredState(\"+14151112222\", uuid, null, null, retrieved.get(), account);\n\n    retrieved = accounts.getByAccountIdentifier(uuid);\n\n    assertThat(retrieved.isPresent()).isTrue();\n    verifyStoredState(\"+14151112222\", uuid, null, null, retrieved.get(), account);\n  }\n\n  // State before the account is re-registered\n  enum UsernameStatus {\n    NONE,\n    RESERVED,\n    RESERVED_WITH_SAVED_LINK,\n    CONFIRMED\n  }\n\n  @ParameterizedTest\n  @EnumSource(UsernameStatus.class)\n  void reclaimAccountWithNoUsername(UsernameStatus usernameStatus) throws UsernameHashNotAvailableException {\n    Device device = generateDevice(DEVICE_ID_1);\n    UUID firstUuid = UUID.randomUUID();\n    UUID firstPni = UUID.randomUUID();\n    Account account = generateAccount(\"+14151112222\", firstUuid, firstPni, List.of(device));\n    createAccount(account);\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(32);\n    final byte[] encryptedUsername = TestRandomUtil.nextBytes(32);\n    switch (usernameStatus) {\n      case NONE:\n        break;\n      case RESERVED:\n        accounts.reserveUsernameHash(account, TestRandomUtil.nextBytes(32), Duration.ofMinutes(1));\n        break;\n      case RESERVED_WITH_SAVED_LINK:\n        // give the account a username\n        accounts.reserveUsernameHash(account, usernameHash, Duration.ofMinutes(1));\n        accounts.confirmUsernameHash(account, usernameHash, encryptedUsername);\n\n        // simulate a partially-completed re-reg: we give the account a reclaimable username, but we'll try\n        // re-registering again later in the test case\n        account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n        reclaimAccount(account);\n        break;\n      case CONFIRMED:\n        accounts.reserveUsernameHash(account, usernameHash, Duration.ofMinutes(1));\n        accounts.confirmUsernameHash(account, usernameHash, encryptedUsername);\n        break;\n    }\n\n    Optional<UUID> preservedLink = Optional.ofNullable(account.getUsernameLinkHandle());\n\n    // re-register the account\n    account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n    reclaimAccount(account);\n\n    // If we had a username link, or we had previously saved a username link from another re-registration, make sure\n    // we preserve it\n    accounts.confirmUsernameHash(account, usernameHash, encryptedUsername);\n\n    boolean shouldReuseLink = switch (usernameStatus) {\n      case RESERVED_WITH_SAVED_LINK, CONFIRMED -> true;\n      case NONE, RESERVED -> false;\n    };\n\n    // If we had a reclaimable username, make sure we preserved the link.\n    assertThat(Objects.equals(account.getUsernameLinkHandle(), preservedLink.orElse(null)))\n        .isEqualTo(shouldReuseLink);\n\n    // in all cases, we should now have usernameHash, usernameLink, and encryptedUsername set\n    assertThat(account.getUsernameHash()).isNotEmpty();\n    assertThat(account.getEncryptedUsername()).isNotEmpty();\n    assertThat(account.getUsernameLinkHandle()).isNotNull();\n    assertThat(account.getReservedUsernameHash()).isEmpty();\n  }\n\n  private void reclaimAccount(final Account reregisteredAccount) {\n    final AccountAlreadyExistsException accountAlreadyExistsException =\n        assertThrows(AccountAlreadyExistsException.class,\n            () -> accounts.create(reregisteredAccount, Collections.emptyList()));\n\n    reregisteredAccount.setUuid(accountAlreadyExistsException.getExistingAccount().getUuid());\n    reregisteredAccount.setNumber(accountAlreadyExistsException.getExistingAccount().getNumber(),\n        accountAlreadyExistsException.getExistingAccount().getPhoneNumberIdentifier());\n\n    assertDoesNotThrow(() -> accounts.reclaimAccount(accountAlreadyExistsException.getExistingAccount(),\n        reregisteredAccount,\n        Collections.emptyList()).toCompletableFuture().join());\n  }\n\n  @Test\n  void testReclaimAccountPreservesFields() {\n    final String e164 = \"+14151112222\";\n    final UUID existingUuid = UUID.randomUUID();\n    final Account existingAccount =\n        generateAccount(e164, existingUuid, UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n\n    // the backup credential request and share-set are always preserved across account reclaims\n    existingAccount.setBackupCredentialRequests(TestRandomUtil.nextBytes(32), TestRandomUtil.nextBytes(32));\n    createAccount(existingAccount);\n    final Account secondAccount =\n        generateAccount(e164, UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n\n    reclaimAccount(secondAccount);\n\n    final Account reclaimed = accounts.getByAccountIdentifier(existingUuid).orElseThrow();\n    assertThat(reclaimed.getBackupCredentialRequest(BackupCredentialType.MESSAGES).orElseThrow())\n        .isEqualTo(existingAccount.getBackupCredentialRequest(BackupCredentialType.MESSAGES).orElseThrow());\n    assertThat(reclaimed.getBackupCredentialRequest(BackupCredentialType.MEDIA).orElseThrow())\n        .isEqualTo(existingAccount.getBackupCredentialRequest(BackupCredentialType.MEDIA).orElseThrow());\n  }\n\n  @Test\n  void testReclaimAccount() throws UsernameHashNotAvailableException {\n    final String e164 = \"+14151112222\";\n    final Device device = generateDevice(DEVICE_ID_1);\n    final UUID existingUuid = UUID.randomUUID();\n    final UUID existingPni = UUID.randomUUID();\n    final Account existingAccount = generateAccount(e164, existingUuid, existingPni, List.of(device));\n\n    // Backup vouchers should be carried over accross re-registration\n    final Account.BackupVoucher bv = new Account.BackupVoucher(1, Instant.now().plus(Duration.ofDays(1)));\n    existingAccount.setBackupVoucher(bv);\n\n    createAccount(existingAccount);\n\n    final byte[] usernameHash = TestRandomUtil.nextBytes(32);\n    final byte[] encryptedUsername = TestRandomUtil.nextBytes(16);\n\n    // Set up the existing account to have a username hash\n    accounts.confirmUsernameHash(existingAccount, usernameHash, encryptedUsername);\n    final UUID usernameLinkHandle = existingAccount.getUsernameLinkHandle();\n\n    verifyStoredState(e164, existingAccount.getUuid(), existingAccount.getPhoneNumberIdentifier(), usernameHash, existingAccount, true);\n\n    assertPhoneNumberConstraintExists(e164, existingUuid);\n    assertPhoneNumberIdentifierConstraintExists(existingPni, existingUuid);\n\n    assertDoesNotThrow(() -> accounts.update(existingAccount));\n\n    final UUID secondUuid = UUID.randomUUID();\n\n    final Device secondDevice = generateDevice(DEVICE_ID_1);\n    final Account secondAccount = generateAccount(e164, secondUuid, UUID.randomUUID(), List.of(secondDevice));\n\n    reclaimAccount(secondAccount);\n\n    // usernameHash should be unset\n    verifyStoredState(\"+14151112222\", existingUuid, existingPni, null, secondAccount, true);\n\n    // username should become 'reclaimable'\n    Map<String, AttributeValue> item = readAccount(existingUuid);\n    Account result = Accounts.fromItem(item);\n    assertThat(AttributeValues.getUUID(item, Accounts.ATTR_USERNAME_LINK_UUID, null))\n        .isEqualTo(usernameLinkHandle)\n        .isEqualTo(result.getUsernameLinkHandle());\n    assertThat(result.getUsernameHash()).isEmpty();\n    assertThat(result.getEncryptedUsername()).isEmpty();\n    assertArrayEquals(result.getReservedUsernameHash().orElseThrow(), usernameHash);\n\n    assertThat(result.getBackupVoucher()).isEqualTo(bv);\n\n    // should keep the same usernameLink, now encryptedUsername should be set\n    accounts.confirmUsernameHash(result, usernameHash, encryptedUsername);\n    item = readAccount(existingUuid);\n    result = Accounts.fromItem(item);\n    assertThat(AttributeValues.getUUID(item, Accounts.ATTR_USERNAME_LINK_UUID, null))\n        .isEqualTo(usernameLinkHandle)\n        .isEqualTo(result.getUsernameLinkHandle());\n    assertArrayEquals(encryptedUsername, result.getEncryptedUsername().orElseThrow());\n    assertArrayEquals(usernameHash, result.getUsernameHash().orElseThrow());\n    assertThat(result.getReservedUsernameHash()).isEmpty();\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", existingUuid);\n    assertPhoneNumberIdentifierConstraintExists(existingPni, existingUuid);\n\n    Account invalidAccount = generateAccount(\"+14151113333\", existingUuid, UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n\n    assertThatThrownBy(() -> createAccount(invalidAccount));\n  }\n\n  @Test\n  void testUpdate() {\n    Device device = generateDevice(DEVICE_ID_1);\n    Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n\n    createAccount(account);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n\n    device.setName(\"foobar\".getBytes(StandardCharsets.UTF_8));\n\n    accounts.update(account);\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(account.getPhoneNumberIdentifier(), account.getUuid());\n\n    Optional<Account> retrieved = accounts.getByE164(\"+14151112222\");\n\n    assertThat(retrieved.isPresent()).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, retrieved.get(), account);\n\n    retrieved = accounts.getByAccountIdentifier(account.getUuid());\n\n    assertThat(retrieved.isPresent()).isTrue();\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    device = generateDevice(DEVICE_ID_1);\n    Account unknownAccount = generateAccount(\"+14151113333\", UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n\n    assertThatThrownBy(() -> accounts.update(unknownAccount)).isInstanceOfAny(ConditionalCheckFailedException.class);\n\n    accounts.update(account);\n\n    assertThat(account.getVersion()).isEqualTo(2);\n\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n\n    account.setVersion(1);\n\n    assertThatThrownBy(() -> accounts.update(account)).isInstanceOfAny(ContestedOptimisticLockException.class);\n\n    account.setVersion(2);\n\n    accounts.update(account);\n\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n  }\n\n  @Test\n  void testUpdateWithMockTransactionConflictException() {\n\n    final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);\n    accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        mock(DynamoDbAsyncClient.class),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n    when(dynamoDbClient.updateItem(any(UpdateItemRequest.class)))\n        .thenThrow(TransactionConflictException.builder().build());\n\n    final Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID());\n\n    assertThatThrownBy(() -> accounts.update(account)).isInstanceOfAny(ContestedOptimisticLockException.class);\n  }\n\n  @Test\n  void testUpdateTransactionally() {\n    final Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    final byte[] deviceName = \"device-name\".getBytes(StandardCharsets.UTF_8);\n\n    assertNotEquals(deviceName,\n        accounts.getByAccountIdentifier(account.getUuid()).orElseThrow().getPrimaryDevice().getName());\n\n    assertFalse(DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n            .tableName(Tables.CLIENT_RELEASES.tableName())\n            .key(Map.of(\n                ClientReleases.ATTR_PLATFORM, AttributeValues.fromString(\"test\"),\n                ClientReleases.ATTR_VERSION, AttributeValues.fromString(\"test\")\n            ))\n            .build())\n        .hasItem());\n\n    account.getPrimaryDevice().setName(deviceName);\n\n    accounts.updateTransactionally(account, List.of(TransactWriteItem.builder()\n        .put(Put.builder()\n            .tableName(Tables.CLIENT_RELEASES.tableName())\n            .item(Map.of(\n                ClientReleases.ATTR_PLATFORM, AttributeValues.fromString(\"test\"),\n                ClientReleases.ATTR_VERSION, AttributeValues.fromString(\"test\")\n            ))\n            .build())\n        .build()));\n\n    assertArrayEquals(deviceName,\n        accounts.getByAccountIdentifier(account.getUuid()).orElseThrow().getPrimaryDevice().getName());\n\n    assertTrue(DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n            .tableName(Tables.CLIENT_RELEASES.tableName())\n            .key(Map.of(\n                ClientReleases.ATTR_PLATFORM, AttributeValues.fromString(\"test\"),\n                ClientReleases.ATTR_VERSION, AttributeValues.fromString(\"test\")\n            ))\n            .build())\n        .hasItem());\n  }\n\n  @Test\n  void testUpdateTransactionallyContestedLock() {\n    final Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    account.setVersion(account.getVersion() - 1);\n\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.updateTransactionally(account, List.of(TransactWriteItem.builder()\n            .put(Put.builder()\n                .tableName(Tables.CLIENT_RELEASES.tableName())\n                .item(Map.of(\n                    ClientReleases.ATTR_PLATFORM, AttributeValues.fromString(\"test\"),\n                    ClientReleases.ATTR_VERSION, AttributeValues.fromString(\"test\")\n                ))\n                .build())\n            .build())));\n  }\n\n  @Test\n  void testUpdateTransactionallyWithMockTransactionConflictException() {\n    final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);\n\n    accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        mock(DynamoDbAsyncClient.class),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n    when(dynamoDbClient.transactWriteItems(any(TransactWriteItemsRequest.class)))\n        .thenThrow(TransactionCanceledException.builder()\n            .cancellationReasons(CancellationReason.builder()\n                .code(\"TransactionConflict\")\n                .build())\n            .build());\n\n    final Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID());\n\n    assertThatThrownBy(() -> accounts.updateTransactionally(account, Collections.emptyList()))\n        .isInstanceOfAny(ContestedOptimisticLockException.class);\n  }\n\n  @Test\n  void testGetAll() {\n    final List<Account> expectedAccounts = new ArrayList<>();\n\n    for (int i = 1; i <= 100; i++) {\n      final Account account = generateAccount(\"+1\" + String.format(\"%03d\", i), UUID.randomUUID(), UUID.randomUUID());\n      expectedAccounts.add(account);\n      createAccount(account);\n    }\n\n    final List<Account> retrievedAccounts =\n        accounts.getAll(2, Schedulers.parallel()).collectList().block();\n\n    assertNotNull(retrievedAccounts);\n    assertEquals(expectedAccounts.stream().map(Account::getUuid).collect(Collectors.toSet()),\n        retrievedAccounts.stream().map(Account::getUuid).collect(Collectors.toSet()));\n  }\n\n  @Test\n  void testGetAllAccountIdentifiers() {\n    final Set<UUID> expectedAccountIdentifiers = new HashSet<>();\n\n    for (int i = 1; i <= 100; i++) {\n      final Account account = generateAccount(\"+1\" + String.format(\"%03d\", i), UUID.randomUUID(), UUID.randomUUID());\n      expectedAccountIdentifiers.add(account.getIdentifier(IdentityType.ACI));\n      createAccount(account);\n    }\n\n    @SuppressWarnings(\"DataFlowIssue\") final Set<UUID> retrievedAccountIdentifiers =\n        new HashSet<>(accounts.getAllAccountIdentifiers(2, Schedulers.parallel()).collectList().block());\n\n    assertEquals(expectedAccountIdentifiers, retrievedAccountIdentifiers);\n  }\n\n  @Test\n  void testDelete() {\n    final Device deletedDevice = generateDevice(DEVICE_ID_1);\n    final Account deletedAccount = generateAccount(\"+14151112222\", UUID.randomUUID(),\n        UUID.randomUUID(), List.of(deletedDevice));\n    final Device retainedDevice = generateDevice(DEVICE_ID_1);\n    final Account retainedAccount = generateAccount(\"+14151112345\", UUID.randomUUID(),\n        UUID.randomUUID(), List.of(retainedDevice));\n\n    createAccount(deletedAccount);\n    createAccount(retainedAccount);\n\n    assertThat(accounts.findRecentlyDeletedAccountIdentifier(deletedAccount.getPhoneNumberIdentifier())).isEmpty();\n\n    assertPhoneNumberConstraintExists(\"+14151112222\", deletedAccount.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(deletedAccount.getPhoneNumberIdentifier(), deletedAccount.getUuid());\n    assertPhoneNumberConstraintExists(\"+14151112345\", retainedAccount.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(retainedAccount.getPhoneNumberIdentifier(), retainedAccount.getUuid());\n\n    assertThat(accounts.getByAccountIdentifier(deletedAccount.getUuid())).isPresent();\n    assertThat(accounts.getByAccountIdentifier(retainedAccount.getUuid())).isPresent();\n\n    accounts.delete(deletedAccount.getUuid(), Collections.emptyList());\n\n    assertThat(accounts.getByAccountIdentifier(deletedAccount.getUuid())).isNotPresent();\n    assertThat(accounts.findRecentlyDeletedAccountIdentifier(deletedAccount.getPhoneNumberIdentifier())).hasValue(deletedAccount.getUuid());\n\n    assertPhoneNumberConstraintDoesNotExist(deletedAccount.getNumber());\n    assertPhoneNumberIdentifierConstraintDoesNotExist(deletedAccount.getPhoneNumberIdentifier());\n\n    verifyStoredState(retainedAccount.getNumber(), retainedAccount.getUuid(), retainedAccount.getPhoneNumberIdentifier(),\n        null, accounts.getByAccountIdentifier(retainedAccount.getUuid()).orElseThrow(), retainedAccount);\n\n    {\n      final Account recreatedAccount = generateAccount(deletedAccount.getNumber(), UUID.randomUUID(),\n          deletedAccount.getPhoneNumberIdentifier(), List.of(generateDevice(DEVICE_ID_1)));\n\n      final boolean freshUser = createAccount(recreatedAccount);\n\n      assertThat(freshUser).isTrue();\n      assertThat(accounts.getByAccountIdentifier(recreatedAccount.getUuid())).isPresent();\n      verifyStoredState(recreatedAccount.getNumber(), recreatedAccount.getUuid(), recreatedAccount.getPhoneNumberIdentifier(),\n          null, accounts.getByAccountIdentifier(recreatedAccount.getUuid()).orElseThrow(), recreatedAccount);\n\n      assertPhoneNumberConstraintExists(recreatedAccount.getNumber(), recreatedAccount.getUuid());\n      assertPhoneNumberIdentifierConstraintExists(recreatedAccount.getPhoneNumberIdentifier(), recreatedAccount.getUuid());\n    }\n  }\n\n  @Test\n  void testMissing() {\n    Device device = generateDevice(DEVICE_ID_1);\n    Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n\n    createAccount(account);\n\n    Optional<Account> retrieved = accounts.getByE164(\"+11111111\");\n    assertThat(retrieved.isPresent()).isFalse();\n\n    retrieved = accounts.getByAccountIdentifier(UUID.randomUUID());\n    assertThat(retrieved.isPresent()).isFalse();\n  }\n\n  @Test\n  void getByAccountIdentifierAsync() {\n    assertThat(accounts.getByAccountIdentifierAsync(UUID.randomUUID()).join()).isEmpty();\n\n    final Account account =\n        generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n\n    createAccount(account);\n\n    assertThat(accounts.getByAccountIdentifierAsync(account.getUuid()).join()).isPresent();\n  }\n\n  @Test\n  void getByPhoneNumberIdentifierAsync() {\n    assertThat(accounts.getByPhoneNumberIdentifierAsync(UUID.randomUUID()).join()).isEmpty();\n\n    final Account account =\n        generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n\n    createAccount(account);\n\n    assertThat(accounts.getByPhoneNumberIdentifierAsync(account.getPhoneNumberIdentifier()).join()).isPresent();\n  }\n\n  @Test\n  void getByE164Async() {\n    final String e164 = \"+14151112222\";\n\n    assertThat(accounts.getByE164Async(e164).join()).isEmpty();\n\n    final Account account =\n        generateAccount(e164, UUID.randomUUID(), UUID.randomUUID(), List.of(generateDevice(DEVICE_ID_1)));\n\n    createAccount(account);\n\n    assertThat(accounts.getByE164Async(e164).join()).isPresent();\n  }\n\n  @Test\n  void testCanonicallyDiscoverableSet() {\n    Device device = generateDevice(DEVICE_ID_1);\n    Account account = generateAccount(\"+14151112222\", UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n    account.setDiscoverableByPhoneNumber(false);\n    createAccount(account);\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, false);\n    account.setDiscoverableByPhoneNumber(true);\n    accounts.update(account);\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, true);\n    account.setDiscoverableByPhoneNumber(false);\n    accounts.update(account);\n    verifyStoredState(\"+14151112222\", account.getUuid(), account.getPhoneNumberIdentifier(), null, account, false);\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  public void testChangeNumber(final Optional<UUID> maybeDisplacedAccountIdentifier) {\n    final String originalNumber = \"+14151112222\";\n    final String targetNumber = \"+14151113333\";\n\n    final UUID originalPni = UUID.randomUUID();\n    final UUID targetPni = UUID.randomUUID();\n\n    final Device device = generateDevice(DEVICE_ID_1);\n    final Account account = generateAccount(originalNumber, UUID.randomUUID(), originalPni, List.of(device));\n\n    createAccount(account);\n\n    assertThat(accounts.getByPhoneNumberIdentifier(originalPni)).isPresent();\n\n    assertPhoneNumberConstraintExists(originalNumber, account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(originalPni, account.getUuid());\n\n    {\n      final Optional<Account> retrieved = accounts.getByE164(originalNumber);\n      assertThat(retrieved).isPresent();\n\n      verifyStoredState(originalNumber, account.getUuid(), account.getPhoneNumberIdentifier(), null, retrieved.get(), account);\n    }\n\n    accounts.changeNumber(account, targetNumber, targetPni, maybeDisplacedAccountIdentifier, Collections.emptyList());\n\n    assertThat(accounts.getByE164(originalNumber)).isEmpty();\n    assertThat(accounts.getByAccountIdentifier(originalPni)).isEmpty();\n\n    assertPhoneNumberConstraintDoesNotExist(originalNumber);\n    assertPhoneNumberIdentifierConstraintDoesNotExist(originalPni);\n    assertPhoneNumberConstraintExists(targetNumber, account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(targetPni, account.getUuid());\n\n    {\n      final Optional<Account> retrieved = accounts.getByE164(targetNumber);\n      assertThat(retrieved).isPresent();\n\n      verifyStoredState(targetNumber, account.getUuid(), account.getPhoneNumberIdentifier(), null, retrieved.get(), account);\n\n      assertThat(retrieved.get().getPhoneNumberIdentifier()).isEqualTo(targetPni);\n      assertThat(accounts.getByPhoneNumberIdentifier(targetPni)).isPresent();\n    }\n\n    assertThat(accounts.findRecentlyDeletedAccountIdentifier(originalPni)).isEqualTo(maybeDisplacedAccountIdentifier);\n  }\n\n  private static Stream<Arguments> testChangeNumber() {\n    return Stream.of(\n        Arguments.of(Optional.empty()),\n        Arguments.of(Optional.of(UUID.randomUUID()))\n    );\n  }\n\n  @Test\n  public void testChangeNumberConflict() {\n    final String originalNumber = \"+14151112222\";\n    final String targetNumber = \"+14151113333\";\n\n    final UUID originalPni = UUID.randomUUID();\n    final UUID targetPni = UUID.randomUUID();\n\n    final Device existingDevice = generateDevice(DEVICE_ID_1);\n    final Account existingAccount = generateAccount(targetNumber, UUID.randomUUID(), targetPni, List.of(existingDevice));\n\n    final Device device = generateDevice(DEVICE_ID_1);\n    final Account account = generateAccount(originalNumber, UUID.randomUUID(), originalPni, List.of(device));\n\n    createAccount(account);\n    createAccount(existingAccount);\n\n    assertThrows(TransactionCanceledException.class, () -> accounts.changeNumber(account, targetNumber, targetPni, Optional.of(existingAccount.getUuid()), Collections.emptyList()));\n\n    assertPhoneNumberConstraintExists(originalNumber, account.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(originalPni, account.getUuid());\n    assertPhoneNumberConstraintExists(targetNumber, existingAccount.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(targetPni, existingAccount.getUuid());\n  }\n\n  @Test\n  public void testChangeNumberPhoneNumberIdentifierConflict() {\n    final String originalNumber = \"+14151112222\";\n    final String targetNumber = \"+14151113333\";\n\n    final Device device = generateDevice(DEVICE_ID_1);\n    final Account account = generateAccount(originalNumber, UUID.randomUUID(), UUID.randomUUID(), List.of(device));\n\n    createAccount(account);\n\n    final UUID existingAccountIdentifier = UUID.randomUUID();\n    final UUID existingPhoneNumberIdentifier = UUID.randomUUID();\n\n    // Artificially inject a conflicting PNI entry\n    DYNAMO_DB_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()\n        .tableName(Tables.PNI_ASSIGNMENTS.tableName())\n        .item(Map.of(\n            Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(existingPhoneNumberIdentifier),\n            Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(existingAccountIdentifier)))\n        .conditionExpression(\n            \"attribute_not_exists(#pni) OR (attribute_exists(#pni) AND #uuid = :uuid)\")\n        .expressionAttributeNames(\n            Map.of(\"#uuid\", Accounts.KEY_ACCOUNT_UUID,\n                \"#pni\", Accounts.ATTR_PNI_UUID))\n        .expressionAttributeValues(\n            Map.of(\":uuid\", AttributeValues.fromUUID(existingAccountIdentifier)))\n        .build());\n\n    assertThrows(TransactionCanceledException.class, () -> accounts.changeNumber(account, targetNumber, existingPhoneNumberIdentifier, Optional.empty(), Collections.emptyList()));\n  }\n\n  @Test\n  public void testChangeNumberContestedOptimisticLock() {\n    final String originalNumber = \"+14151112222\";\n    final String targetNumber = \"+14151113333\";\n\n    final UUID originalPni = UUID.randomUUID();\n    final UUID targetPni = UUID.randomUUID();\n\n    final Device device = generateDevice(DEVICE_ID_1);\n    final Account firstAccountInstance = generateAccount(originalNumber, UUID.randomUUID(), originalPni,\n        List.of(device));\n\n    createAccount(firstAccountInstance);\n\n    final Account secondAccountInstance = accounts.getByAccountIdentifier(firstAccountInstance.getUuid()).orElseThrow();\n\n    // update via the first instance, which will update the version\n    firstAccountInstance.setCurrentProfileVersion(\"1\");\n    accounts.update(firstAccountInstance);\n\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.changeNumber(secondAccountInstance, targetNumber, targetPni, Optional.empty(),\n            Collections.emptyList()), \"Second account instance has stale version\");\n\n    final Account refreshedAccountInstance = accounts.getByAccountIdentifier(firstAccountInstance.getUuid())\n        .orElseThrow();\n    accounts.changeNumber(refreshedAccountInstance, targetNumber, targetPni, Optional.empty(),\n        Collections.emptyList());\n\n    assertPhoneNumberConstraintDoesNotExist(originalNumber);\n    assertPhoneNumberIdentifierConstraintDoesNotExist(originalPni);\n    assertPhoneNumberConstraintExists(targetNumber, firstAccountInstance.getUuid());\n    assertPhoneNumberIdentifierConstraintExists(targetPni, firstAccountInstance.getUuid());\n  }\n\n  @Test\n  void testSwitchUsernameHashes() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    final UUID oldHandle = account.getUsernameLinkHandle();\n\n    {\n      final Optional<Account> maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_1).join();\n      verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(), USERNAME_HASH_1, maybeAccount.orElseThrow(), account);\n\n      final Optional<Account> maybeAccount2 = accounts.getByUsernameLinkHandle(oldHandle).join();\n      verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(), USERNAME_HASH_1, maybeAccount2.orElseThrow(), account);\n    }\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_2, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_2, ENCRYPTED_USERNAME_2);\n    final UUID newHandle = account.getUsernameLinkHandle();\n\n    // switching usernames should put a hold on our original username\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n    assertThat(getUsernameConstraintTableItem(USERNAME_HASH_1)).containsExactlyInAnyOrderEntriesOf(Map.of(\n        Accounts.UsernameTable.KEY_USERNAME_HASH, AttributeValues.b(USERNAME_HASH_1),\n        Accounts.UsernameTable.ATTR_ACCOUNT_UUID, AttributeValues.b(account.getUuid()),\n        Accounts.UsernameTable.ATTR_CONFIRMED, AttributeValues.fromBool(false),\n        Accounts.UsernameTable.ATTR_TTL,\n        AttributeValues.n(clock.instant().plus(Accounts.USERNAME_HOLD_DURATION).getEpochSecond())));\n    assertThat(accounts.getByUsernameLinkHandle(oldHandle).join()).isEmpty();\n\n    {\n      final Optional<Account> maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_2).join();\n\n      assertThat(maybeAccount).isPresent();\n      verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(),\n          USERNAME_HASH_2, maybeAccount.orElseThrow(), account);\n      final Optional<Account> maybeAccount2 = accounts.getByUsernameLinkHandle(newHandle).join();\n      verifyStoredState(account.getNumber(), account.getUuid(), account.getPhoneNumberIdentifier(),\n          USERNAME_HASH_2, maybeAccount2.orElseThrow(), account);\n    }\n  }\n\n  @Test\n  void testUsernameHashNotAvailable() {\n    final Account firstAccount = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    final Account secondAccount = generateAccount(\"+18005559876\", UUID.randomUUID(), UUID.randomUUID());\n\n    createAccount(firstAccount);\n    createAccount(secondAccount);\n\n    // first account reserves and confirms username hash\n    assertThatNoException().isThrownBy(() -> {\n      accounts.reserveUsernameHash(firstAccount, USERNAME_HASH_1, Duration.ofDays(1));\n      accounts.confirmUsernameHash(firstAccount, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    });\n\n    final Optional<Account> maybeAccount = accounts.getByUsernameHash(USERNAME_HASH_1).join();\n\n    assertThat(maybeAccount).isPresent();\n    verifyStoredState(firstAccount.getNumber(), firstAccount.getUuid(), firstAccount.getPhoneNumberIdentifier(), USERNAME_HASH_1, maybeAccount.get(), firstAccount);\n\n    // throw an error if second account tries to reserve or confirm the same username hash\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.reserveUsernameHash(secondAccount, USERNAME_HASH_1, Duration.ofDays(1)));\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.confirmUsernameHash(secondAccount, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n\n    // throw an error if first account tries to reserve or confirm the username hash that it has already confirmed\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.reserveUsernameHash(firstAccount, USERNAME_HASH_1, Duration.ofDays(1)));\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.confirmUsernameHash(firstAccount, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n\n    assertThat(secondAccount.getReservedUsernameHash()).isEmpty();\n    assertThat(secondAccount.getUsernameHash()).isEmpty();\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void testReserveUsernameHashTransactionConflict(final Optional<String> constraintCancellationString,\n      final Optional<String> accountsCancellationString,\n      final Class<Exception> expectedException) {\n    final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);\n\n    accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        mock(DynamoDbAsyncClient.class),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n    final Account account = generateAccount(\"+14155551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    final CancellationReason constraintCancellationReason = constraintCancellationString.map(\n        reason -> CancellationReason.builder().code(reason).build()\n    ).orElse(CancellationReason.builder().build());\n\n    final CancellationReason accountsCancellationReason = accountsCancellationString.map(\n        reason -> CancellationReason.builder().code(reason).build()\n    ).orElse(CancellationReason.builder().build());\n\n    when(dynamoDbClient.transactWriteItems(any(TransactWriteItemsRequest.class)))\n        .thenThrow(TransactionCanceledException.builder()\n            .cancellationReasons(constraintCancellationReason, accountsCancellationReason)\n            .build());\n\n    assertThrows(expectedException,\n        () -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)));\n  }\n\n  private static Stream<Arguments> testReserveUsernameHashTransactionConflict() {\n    return Stream.of(\n        Arguments.of(Optional.of(\"TransactionConflict\"), Optional.empty(), ContestedOptimisticLockException.class),\n        Arguments.of(Optional.empty(), Optional.of(\"TransactionConflict\"), ContestedOptimisticLockException.class),\n        Arguments.of(Optional.of(\"ConditionalCheckFailed\"), Optional.of(\"TransactionConflict\"), UsernameHashNotAvailableException.class)\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void testConfirmUsernameHashTransactionConflict(final Optional<String> constraintCancellationString,\n      final Optional<String> accountsCancellationString,\n      final Class<Exception> expectedException) {\n    final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);\n\n    accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        mock(DynamoDbAsyncClient.class),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n    final Account account = generateAccount(\"+14155551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    final CancellationReason constraintCancellationReason = constraintCancellationString.map(\n        reason -> CancellationReason.builder().code(reason).build()\n    ).orElse(CancellationReason.builder().build());\n\n    final CancellationReason accountsCancellationReason = accountsCancellationString.map(\n        reason -> CancellationReason.builder().code(reason).build()\n    ).orElse(CancellationReason.builder().build());\n\n    when(dynamoDbClient.transactWriteItems(any(TransactWriteItemsRequest.class)))\n        .thenThrow(TransactionCanceledException.builder()\n            .cancellationReasons(constraintCancellationReason,\n                accountsCancellationReason,\n                CancellationReason.builder().build())\n            .build());\n\n    assertThrows(expectedException,\n        () -> accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n  }\n\n  private static Stream<Arguments> testConfirmUsernameHashTransactionConflict() {\n    return Stream.of(\n        Arguments.of(Optional.of(\"TransactionConflict\"), Optional.empty(), ContestedOptimisticLockException.class),\n        Arguments.of(Optional.empty(), Optional.of(\"TransactionConflict\"), ContestedOptimisticLockException.class),\n        Arguments.of(Optional.of(\"ConditionalCheckFailed\"), Optional.of(\"TransactionConflict\"), UsernameHashNotAvailableException.class)\n    );\n  }\n\n  @Test\n  void testConfirmUsernameHashVersionMismatch() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    account.setVersion(account.getVersion() + 77);\n\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n\n    assertThat(account.getUsernameHash()).isEmpty();\n  }\n\n  @Test\n  void testClearUsername() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isPresent();\n\n    accounts.clearUsernameHash(account);\n\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n    assertThat(accounts.getByAccountIdentifier(account.getUuid()))\n        .hasValueSatisfying(clearedAccount -> {\n          assertThat(clearedAccount.getUsernameHash()).isEmpty();\n          assertThat(clearedAccount.getUsernameLinkHandle()).isNull();\n          assertThat(clearedAccount.getEncryptedUsername()).isEmpty();\n        });\n  }\n\n  @Test\n  void testClearUsernameNoUsername() {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    assertThatNoException().isThrownBy(() -> accounts.clearUsernameHash(account));\n  }\n\n  @Test\n  void testClearUsernameVersionMismatch() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    account.setVersion(account.getVersion() + 12);\n\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.clearUsernameHash(account));\n\n    assertArrayEquals(USERNAME_HASH_1, account.getUsernameHash().orElseThrow());\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void testClearUsernameTransactionConflict(final Optional<String> constraintCancellationString,\n      final Optional<String> accountsCancellationString) throws UsernameHashNotAvailableException {\n    final DynamoDbClient dynamoDbClient = mock(DynamoDbClient.class);\n\n    accounts = new Accounts(\n        clock,\n        dynamoDbClient,\n        mock(DynamoDbAsyncClient.class),\n        Tables.ACCOUNTS.tableName(),\n        Tables.NUMBERS.tableName(),\n        Tables.PNI_ASSIGNMENTS.tableName(),\n        Tables.USERNAMES.tableName(),\n        Tables.DELETED_ACCOUNTS.tableName(),\n        Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n    final Account account = generateAccount(\"+14155551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    when(dynamoDbClient.transactWriteItems(any(TransactWriteItemsRequest.class)))\n        .thenReturn(mock(TransactWriteItemsResponse.class));\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    final CancellationReason constraintCancellationReason = constraintCancellationString\n        .map(reason -> CancellationReason.builder().code(reason).build())\n        .orElse(CancellationReason.builder().build());\n\n    final CancellationReason accountsCancellationReason = accountsCancellationString\n        .map(reason -> CancellationReason.builder().code(reason).build())\n        .orElse(CancellationReason.builder().build());\n\n    when(dynamoDbClient.transactWriteItems(any(TransactWriteItemsRequest.class)))\n        .thenThrow(TransactionCanceledException.builder()\n            .cancellationReasons(accountsCancellationReason, constraintCancellationReason)\n            .build());\n\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.clearUsernameHash(account));\n\n    assertArrayEquals(USERNAME_HASH_1, account.getUsernameHash().orElseThrow());\n  }\n\n  private static Stream<Arguments> testClearUsernameTransactionConflict() {\n    return Stream.of(\n        Arguments.of(Optional.empty(), Optional.of(\"TransactionConflict\"), ContestedOptimisticLockException.class),\n        Arguments.of(Optional.of(\"TransactionConflict\"), Optional.empty(), ContestedOptimisticLockException.class)\n    );\n  }\n\n  @Test\n  void testReservedUsernameHash() throws UsernameHashNotAvailableException {\n    final Account account1 = generateAccount(\"+18005551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account1);\n    final Account account2 = generateAccount(\"+18005552222\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n\n    accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1));\n    assertArrayEquals(USERNAME_HASH_1, account1.getReservedUsernameHash().orElseThrow());\n    assertThat(account1.getUsernameHash()).isEmpty();\n\n    // account 2 shouldn't be able to reserve or confirm the same username hash\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1)));\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.confirmUsernameHash(account2, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n\n    accounts.confirmUsernameHash(account1, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    assertThat(account1.getReservedUsernameHash()).isEmpty();\n    assertArrayEquals(USERNAME_HASH_1, account1.getUsernameHash().orElseThrow());\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join().orElseThrow().getUuid()).isEqualTo(account1.getUuid());\n\n    final Map<String, AttributeValue> usernameConstraintRecord = getUsernameConstraintTableItem(USERNAME_HASH_1);\n\n    assertThat(usernameConstraintRecord).containsKey(Accounts.UsernameTable.KEY_USERNAME_HASH);\n    assertThat(usernameConstraintRecord).doesNotContainKey(Accounts.UsernameTable.ATTR_TTL);\n  }\n\n  @Test\n  void switchBetweenReservedUsernameHashes() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    assertArrayEquals(USERNAME_HASH_1, account.getReservedUsernameHash().orElseThrow());\n    assertThat(account.getUsernameHash()).isEmpty();\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_2, Duration.ofDays(1));\n    assertArrayEquals(USERNAME_HASH_2, account.getReservedUsernameHash().orElseThrow());\n    assertThat(account.getUsernameHash()).isEmpty();\n\n    final Map<String, AttributeValue> usernameConstraintRecord1 = getUsernameConstraintTableItem(USERNAME_HASH_1);\n    final Map<String, AttributeValue> usernameConstraintRecord2 = getUsernameConstraintTableItem(USERNAME_HASH_2);\n    assertThat(usernameConstraintRecord1).containsKey(Accounts.UsernameTable.KEY_USERNAME_HASH);\n    assertThat(usernameConstraintRecord2).containsKey(Accounts.UsernameTable.KEY_USERNAME_HASH);\n    assertThat(usernameConstraintRecord1).containsKey(Accounts.UsernameTable.ATTR_TTL);\n    assertThat(usernameConstraintRecord2).containsKey(Accounts.UsernameTable.ATTR_TTL);\n\n    clock.pin(Instant.EPOCH.plus(Duration.ofMinutes(1)));\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    assertArrayEquals(USERNAME_HASH_1, account.getReservedUsernameHash().orElseThrow());\n    assertThat(account.getUsernameHash()).isEmpty();\n\n    final Map<String, AttributeValue> newUsernameConstraintRecord1 = getUsernameConstraintTableItem(USERNAME_HASH_1);\n    assertThat(newUsernameConstraintRecord1).containsKey(Accounts.UsernameTable.KEY_USERNAME_HASH);\n    assertThat(newUsernameConstraintRecord1).containsKey(Accounts.UsernameTable.ATTR_TTL);\n    assertThat(usernameConstraintRecord1.get(Accounts.UsernameTable.ATTR_TTL))\n        .isNotEqualTo(newUsernameConstraintRecord1.get(Accounts.UsernameTable.ATTR_TTL));\n  }\n\n  @Test\n  void reserveOwnConfirmedUsername() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    assertArrayEquals(USERNAME_HASH_1, account.getReservedUsernameHash().orElseThrow());\n    assertThat(account.getUsernameHash()).isEmpty();\n    assertThat(getUsernameConstraintTableItem(USERNAME_HASH_1)).containsKey(Accounts.UsernameTable.ATTR_TTL);\n\n\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n    assertThat(account.getReservedUsernameHash()).isEmpty();\n    assertArrayEquals(USERNAME_HASH_1, account.getUsernameHash().orElseThrow());\n    assertThat(getUsernameConstraintTableItem(USERNAME_HASH_1)).doesNotContainKey(Accounts.UsernameTable.ATTR_TTL);\n\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)));\n    assertThat(account.getReservedUsernameHash()).isEmpty();\n    assertArrayEquals(USERNAME_HASH_1, account.getUsernameHash().orElseThrow());\n    assertThat(getUsernameConstraintTableItem(USERNAME_HASH_1)).containsKey(Accounts.UsernameTable.KEY_USERNAME_HASH);\n    assertThat(getUsernameConstraintTableItem(USERNAME_HASH_1)).doesNotContainKey(Accounts.UsernameTable.ATTR_TTL);\n  }\n\n  @Test\n  void testConfirmReservedUsernameHashWrongAccountUuid() throws UsernameHashNotAvailableException {\n    final Account account1 = generateAccount(\"+18005551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account1);\n    final Account account2 = generateAccount(\"+18005552222\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n\n    accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(1));\n    assertArrayEquals(USERNAME_HASH_1, account1.getReservedUsernameHash().orElseThrow());\n    assertThat(account1.getUsernameHash()).isEmpty();\n\n    // only account1 should be able to confirm the reserved hash\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.confirmUsernameHash(account2, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n  }\n\n  @Test\n  void testConfirmExpiredReservedUsernameHash() throws UsernameHashNotAvailableException {\n    final Account account1 = generateAccount(\"+18005551111\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account1);\n    final Account account2 = generateAccount(\"+18005552222\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n\n    accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(2));\n\n    for (int i = 0; i <= 2; i++) {\n      clock.pin(Instant.EPOCH.plus(Duration.ofDays(i)));\n      assertThrows(UsernameHashNotAvailableException.class,\n          () -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1)));\n    }\n\n    // after 2 days, can reserve and confirm the hash\n    clock.pin(Instant.EPOCH.plus(Duration.ofDays(2)).plus(Duration.ofSeconds(1)));\n    accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1));\n    assertEquals(USERNAME_HASH_1, account2.getReservedUsernameHash().orElseThrow());\n\n    accounts.confirmUsernameHash(account2, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.reserveUsernameHash(account1, USERNAME_HASH_1, Duration.ofDays(2)));\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.confirmUsernameHash(account1, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join().orElseThrow().getUuid()).isEqualTo(account2.getUuid());\n  }\n\n  @Test\n  void testReserveConfirmUsernameHashVersionConflict() {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n    account.setVersion(account.getVersion() + 12);\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1)));\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1));\n    assertThat(account.getReservedUsernameHash()).isEmpty();\n    assertThat(account.getUsernameHash()).isEmpty();\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {false, true})\n  void testRemoveOldestHold(boolean clearUsername) throws UsernameHashNotAvailableException {\n    Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    final List<byte[]> usernames = IntStream.range(0, 7).mapToObj(_ -> TestRandomUtil.nextBytes(32)).toList();\n    final ArrayDeque<byte[]> expectedHolds = new ArrayDeque<>();\n    expectedHolds.add(USERNAME_HASH_1);\n\n    for (byte[] username : usernames) {\n      accounts.reserveUsernameHash(account, username, Duration.ofDays(1));\n      accounts.confirmUsernameHash(account, username, ENCRYPTED_USERNAME_1);\n      assertThat(accounts.getByUsernameHash(username).join()).isPresent();\n\n      final Account read = accounts.getByAccountIdentifier(account.getUuid()).orElseThrow();\n      assertThat(read.getUsernameHolds().stream().map(Account.UsernameHold::usernameHash).toList())\n          .containsExactlyElementsOf(expectedHolds);\n\n      expectedHolds.add(username);\n      if (expectedHolds.size() == Accounts.MAX_USERNAME_HOLDS + 1) {\n        expectedHolds.pop();\n      }\n\n      // clearing the username adds a hold, but the subsequent confirm in the next iteration should add the same hold\n      // (should be a noop) so we don't need to touch expectedHolds\n      if (clearUsername) {\n        accounts.clearUsernameHash(account);\n      }\n    }\n\n\n    final Account account2 = generateAccount(\"+18005554321\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n\n    // someone else should be able to get any of the usernames except the held usernames (MAX_HOLDS) +1 for the username\n    // currently held by the other account if we didn't clear it\n    final int numFree = usernames.size() - Accounts.MAX_USERNAME_HOLDS - (clearUsername ? 0 : 1);\n    final List<byte[]> freeUsernames = usernames.subList(0, numFree);\n    final List<byte[]> heldUsernames = usernames.subList(numFree, usernames.size());\n    for (byte[] username : freeUsernames) {\n      assertDoesNotThrow(() -> accounts.reserveUsernameHash(account2, username, Duration.ofDays(2)));\n    }\n    for (byte[] username : heldUsernames) {\n      assertThrows(UsernameHashNotAvailableException.class,\n          () -> accounts.reserveUsernameHash(account2, username, Duration.ofDays(2)));\n    }\n  }\n\n  @Test\n  void testHoldUsername() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    accounts.clearUsernameHash(account);\n\n    Account account2 = generateAccount(\"+18005554321\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n    assertThrows(UsernameHashNotAvailableException.class,\n        () -> accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1)),\n        \"account2 should not be able reserve username held by account\");\n\n    // but we should be able to get it back\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n  }\n\n  @Test\n  void testNoHoldsBarred() throws UsernameHashNotAvailableException {\n    // should be able to reserve all MAX_HOLDS usernames\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n    final List<byte[]> usernames = IntStream.range(0, Accounts.MAX_USERNAME_HOLDS + 1)\n        .mapToObj(_ -> TestRandomUtil.nextBytes(32))\n        .toList();\n    for (byte[] username : usernames) {\n      accounts.reserveUsernameHash(account, username, Duration.ofDays(1));\n      accounts.confirmUsernameHash(account, username, ENCRYPTED_USERNAME_1);\n    }\n\n    // someone else shouldn't be able to get any of our holds\n    Account account2 = generateAccount(\"+18005554321\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n    for (byte[] username : usernames) {\n      assertThrows(UsernameHashNotAvailableException.class,\n          () -> accounts.reserveUsernameHash(account2, username, Duration.ofDays(1)),\n          \"account2 should not be able reserve username held by account\");\n    }\n\n    // once the hold expires it's fine though\n    clock.pin(Instant.EPOCH.plus(Accounts.USERNAME_HOLD_DURATION).plus(Duration.ofSeconds(1)));\n    accounts.reserveUsernameHash(account2, usernames.getFirst(), Duration.ofDays(1));\n\n    // if account1 modifies their username, we should also clear out the old holds, leaving only their newly added hold\n    accounts.clearUsernameHash(account);\n    assertThat(account.getUsernameHolds().stream().map(Account.UsernameHold::usernameHash))\n        .containsExactly(usernames.getLast());\n  }\n\n  @Test\n  public void testCannotRemoveHold() throws UsernameHashNotAvailableException {\n    // Tests the case where we are trying to remove a hold we think we have, but it turns out we've already lost it.\n    // This means that the Account record an account has a hold on a particular username, but that hold is held by\n    // someone else in the username table. This can happen when the hold TTL expires while we are performing the update\n    // operation that attempts to remove the hold, and another user swoops in and takes the held username. In this\n    // case, a simple retry should let us check the clock again and notice that our hold in our account has expired.\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_2, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_2, ENCRYPTED_USERNAME_1);\n\n    // Now we have a hold on username_hash_1. Simulate a race where the TTL on username_hash_1 expires, and someone\n    // else picks up the username by going forward and then back in time\n    Account account2 = generateAccount(\"+18005554321\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account2);\n    clock.pin(Instant.EPOCH.plus(Accounts.USERNAME_HOLD_DURATION).plus(Duration.ofSeconds(1)));\n    accounts.reserveUsernameHash(account2, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account2, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    clock.pin(Instant.EPOCH);\n    // already have 1 hold, should be able to get to MAX_HOLDS without a problem\n    for (int i = 1; i < Accounts.MAX_USERNAME_HOLDS; i++) {\n      accounts.reserveUsernameHash(account, TestRandomUtil.nextBytes(32), Duration.ofDays(1));\n      accounts.confirmUsernameHash(account, TestRandomUtil.nextBytes(32), ENCRYPTED_USERNAME_1);\n    }\n\n    accounts.reserveUsernameHash(account, TestRandomUtil.nextBytes(32), Duration.ofDays(1));\n    // Should fail, because we cannot remove our hold on USERNAME_HASH_1\n    assertThrows(ContestedOptimisticLockException.class,\n        () -> accounts.confirmUsernameHash(account, TestRandomUtil.nextBytes(32), ENCRYPTED_USERNAME_1));\n\n    // Should now pass once we realize our hold's TTL is over\n    clock.pin(Instant.EPOCH.plus(Accounts.USERNAME_HOLD_DURATION).plus(Duration.ofSeconds(1)));\n    accounts.confirmUsernameHash(account, TestRandomUtil.nextBytes(32), ENCRYPTED_USERNAME_1);\n  }\n\n  @Test\n  void testDeduplicateHoldsOnSwappedUsernames() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    final Consumer<byte[]> assertSingleHold = (byte[] usernameToCheck) -> {\n      // our account should have exactly one hold for the username\n      assertThat(account.getUsernameHolds().stream().map(Account.UsernameHold::usernameHash).toList())\n          .containsExactly(usernameToCheck);\n\n      // the username should be reserved for USERNAME_HOLD_DURATION (a re-reservation shouldn't reduce our expiration to\n      // the provided reservation TTL)\n      assertThat(\n          AttributeValues.getLong(getUsernameConstraintTableItem(usernameToCheck), Accounts.UsernameTable.ATTR_TTL, 0L))\n          .isEqualTo(Accounts.USERNAME_HOLD_DURATION.getSeconds());\n    };\n\n    // Swap back and forth between username 1 and 2.  Username hashes shouldn't reappear in our holds if we already have\n    // a hold\n    for (int i = 0; i < 5; i++) {\n      accounts.reserveUsernameHash(account, USERNAME_HASH_2, Duration.ofSeconds(1));\n      accounts.confirmUsernameHash(account, USERNAME_HASH_2, ENCRYPTED_USERNAME_1);\n      assertSingleHold.accept(USERNAME_HASH_1);\n\n      accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofSeconds(1));\n      accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n      assertSingleHold.accept(USERNAME_HASH_2);\n    }\n  }\n\n  @Test\n  void testRemoveHoldAfterConfirm() throws UsernameHashNotAvailableException {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n    final List<byte[]> usernames = IntStream.range(0, Accounts.MAX_USERNAME_HOLDS)\n        .mapToObj(_ -> TestRandomUtil.nextBytes(32)).toList();\n    for (byte[] username : usernames) {\n      accounts.reserveUsernameHash(account, username, Duration.ofDays(1));\n      accounts.confirmUsernameHash(account, username, ENCRYPTED_USERNAME_1);\n    }\n\n    int holdToRereserve = (Accounts.MAX_USERNAME_HOLDS / 2) - 1;\n\n    // should have MAX_HOLDS - 1 holds (everything in usernames except the last username, which is our current)\n    assertThat(account.getUsernameHolds().stream().map(Account.UsernameHold::usernameHash).toList())\n        .containsExactlyElementsOf(usernames.subList(0, usernames.size() - 1));\n\n    // if we confirm a username we already have held, it should just drop out of the holds list\n    accounts.reserveUsernameHash(account, usernames.get(holdToRereserve), Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, usernames.get(holdToRereserve), ENCRYPTED_USERNAME_1);\n\n    // should have a hold on every username but the one we just confirmed\n    assertThat(account.getUsernameHolds().stream().map(Account.UsernameHold::usernameHash).toList())\n        .containsExactlyElementsOf(Stream.concat(\n                usernames.subList(0, holdToRereserve).stream(),\n                usernames.subList(holdToRereserve + 1, usernames.size()).stream())\n            .toList());\n  }\n\n\n  @Test\n  public void testIgnoredFieldsNotAddedToDataAttribute() throws Exception {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    account.setUsernameHash(TestRandomUtil.nextBytes(32));\n    account.setUsernameLinkDetails(UUID.randomUUID(), TestRandomUtil.nextBytes(32));\n    createAccount(account);\n    final Map<String, AttributeValue> accountRecord = DYNAMO_DB_EXTENSION.getDynamoDbClient()\n        .getItem(GetItemRequest.builder()\n            .tableName(Tables.ACCOUNTS.tableName())\n            .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))\n            .build())\n        .item();\n    final Map<?, ?> dataMap = SystemMapper.jsonMapper()\n        .readValue(accountRecord.get(Accounts.ATTR_ACCOUNT_DATA).b().asByteArray(), Map.class);\n    Accounts.ACCOUNT_FIELDS_TO_EXCLUDE_FROM_SERIALIZATION\n        .forEach(field -> assertFalse(dataMap.containsKey(field)));\n  }\n\n  @Test\n  void testGetByUsernameHashAsync() throws UsernameHashNotAvailableException {\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    createAccount(account);\n\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isEmpty();\n\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Duration.ofDays(1));\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    assertThat(accounts.getByUsernameHash(USERNAME_HASH_1).join()).isPresent();\n  }\n\n  @Test\n  void testInvalidDeviceIdDeserialization() throws Exception {\n    final Account account = generateAccount(\"+18005551234\", UUID.randomUUID(), UUID.randomUUID());\n    final Device device2 = generateDevice((byte) 64);\n    account.addDevice(device2);\n\n    createAccount(account);\n\n    final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient().getItem(GetItemRequest.builder()\n        .tableName(Tables.ACCOUNTS.tableName())\n        .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))\n        .build()).join();\n\n    final Map<?, ?> accountData = SystemMapper.jsonMapper()\n        .readValue(response.item().get(Accounts.ATTR_ACCOUNT_DATA).b().asByteArray(), Map.class);\n\n    @SuppressWarnings(\"unchecked\") final List<Map<Object, Object>> devices =\n        (List<Map<Object, Object>>) accountData.get(\"devices\");\n\n    assertEquals((int) device2.getId(), devices.get(1).get(\"id\"));\n\n    devices.get(1).put(\"id\", Byte.MAX_VALUE + 5);\n\n    DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient().updateItem(UpdateItemRequest.builder()\n        .tableName(Tables.ACCOUNTS.tableName())\n        .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid())))\n        .updateExpression(\"SET #data = :data\")\n        .expressionAttributeNames(Map.of(\"#data\", Accounts.ATTR_ACCOUNT_DATA))\n        .expressionAttributeValues(\n            Map.of(\":data\", AttributeValues.fromByteArray(SystemMapper.jsonMapper().writeValueAsBytes(accountData))))\n        .build()).join();\n\n    final CompletionException e = assertThrows(CompletionException.class,\n        () -> accounts.getByAccountIdentifierAsync(account.getUuid()).join());\n\n    Throwable cause = e.getCause();\n    while (cause.getCause() != null) {\n      cause = cause.getCause();\n    }\n\n    assertInstanceOf(DeviceIdDeserializer.DeviceIdDeserializationException.class, cause);\n  }\n\n  @Test\n  void testRegenerateConstraints() {\n    final Instant usernameHoldExpiration = clock.instant().plus(Accounts.USERNAME_HOLD_DURATION).truncatedTo(ChronoUnit.SECONDS);\n\n    final Account account = nextRandomAccount();\n    account.setUsernameHash(USERNAME_HASH_1);\n    account.setUsernameLinkDetails(UUID.randomUUID(), ENCRYPTED_USERNAME_1);\n    account.setUsernameHolds(List.of(new Account.UsernameHold(USERNAME_HASH_2, usernameHoldExpiration.getEpochSecond())));\n\n    writeAccountRecordWithoutConstraints(account);\n    accounts.regenerateConstraints(account).join();\n\n    // Check that constraints do what they should from a functional perspective\n    {\n      final Account conflictingNumberAccount = nextRandomAccount();\n      conflictingNumberAccount.setNumber(account.getNumber(), account.getIdentifier(IdentityType.PNI));\n\n      assertThrows(AccountAlreadyExistsException.class,\n          () -> accounts.create(conflictingNumberAccount, Collections.emptyList()));\n    }\n\n    {\n      final Account conflictingUsernameAccount = nextRandomAccount();\n      createAccount(conflictingUsernameAccount);\n\n      assertThrows(UsernameHashNotAvailableException.class,\n          () -> accounts.reserveUsernameHash(conflictingUsernameAccount, USERNAME_HASH_1, Accounts.USERNAME_HOLD_DURATION));\n    }\n\n    {\n      final Account conflictingUsernameHoldAccount = nextRandomAccount();\n      createAccount(conflictingUsernameHoldAccount);\n\n      assertThrows(UsernameHashNotAvailableException.class,\n          () -> accounts.reserveUsernameHash(conflictingUsernameHoldAccount, USERNAME_HASH_2, Accounts.USERNAME_HOLD_DURATION));\n    }\n\n    // Check that bare constraint records are written as expected\n    assertEquals(Optional.of(account.getIdentifier(IdentityType.ACI)),\n        getConstraintValue(Tables.NUMBERS.tableName(), Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber())));\n\n    assertEquals(Optional.of(account.getIdentifier(IdentityType.ACI)),\n        getConstraintValue(Tables.PNI_ASSIGNMENTS.tableName(), Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(account.getIdentifier(IdentityType.PNI))));\n\n    assertEquals(Optional.of(new UsernameConstraint(account.getIdentifier(IdentityType.ACI), true, Optional.empty())),\n        getUsernameConstraint(USERNAME_HASH_1));\n\n    assertEquals(Optional.of(new UsernameConstraint(account.getIdentifier(IdentityType.ACI), false, Optional.of(usernameHoldExpiration))),\n        getUsernameConstraint(USERNAME_HASH_2));\n  }\n\n  @Test\n  void testRegeneratedConstraintsMatchOriginalConstraints() throws UsernameHashNotAvailableException {\n    final Instant usernameHoldExpiration = clock.instant().plus(Accounts.USERNAME_HOLD_DURATION).truncatedTo(ChronoUnit.SECONDS);\n\n    final Account account = nextRandomAccount();\n    account.setUsernameHash(USERNAME_HASH_1);\n    account.setUsernameLinkDetails(UUID.randomUUID(), ENCRYPTED_USERNAME_1);\n    account.setUsernameHolds(List.of(new Account.UsernameHold(USERNAME_HASH_2, usernameHoldExpiration.getEpochSecond())));\n\n    createAccount(account);\n    accounts.reserveUsernameHash(account, USERNAME_HASH_2, Accounts.USERNAME_HOLD_DURATION);\n    accounts.confirmUsernameHash(account, USERNAME_HASH_2, ENCRYPTED_USERNAME_2);\n    accounts.reserveUsernameHash(account, USERNAME_HASH_1, Accounts.USERNAME_HOLD_DURATION);\n    accounts.confirmUsernameHash(account, USERNAME_HASH_1, ENCRYPTED_USERNAME_1);\n\n    final Map<String, AttributeValue> originalE164ConstraintItem =\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n                .tableName(Tables.NUMBERS.tableName())\n                .key(Map.of(Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber())))\n                .build())\n            .item();\n\n    final Map<String, AttributeValue> originalPniConstraintItem =\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n                .tableName(Tables.PNI_ASSIGNMENTS.tableName())\n                .key(Map.of(Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(account.getIdentifier(IdentityType.PNI))))\n                .build())\n            .item();\n\n    final Set<Map<String, AttributeValue>> originalUsernameConstraints = new HashSet<>(\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().scan(ScanRequest.builder()\n                .tableName(Tables.USERNAMES.tableName())\n                .build())\n            .items());\n\n    accounts.delete(account.getIdentifier(IdentityType.ACI), Collections.emptyList());\n\n    writeAccountRecordWithoutConstraints(account);\n    accounts.regenerateConstraints(account).join();\n\n    final Map<String, AttributeValue> regeneratedE164ConstraintItem =\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n                .tableName(Tables.NUMBERS.tableName())\n                .key(Map.of(Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber())))\n                .build())\n            .item();\n\n    final Map<String, AttributeValue> regeneratedPniConstraintItem =\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n                .tableName(Tables.PNI_ASSIGNMENTS.tableName())\n                .key(Map.of(Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(account.getIdentifier(IdentityType.PNI))))\n                .build())\n            .item();\n\n    final Set<Map<String, AttributeValue>> regeneratedUsernameConstraints = new HashSet<>(\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().scan(ScanRequest.builder()\n                .tableName(Tables.USERNAMES.tableName())\n                .build())\n            .items());\n\n    assertEquals(originalE164ConstraintItem, regeneratedE164ConstraintItem);\n    assertEquals(originalPniConstraintItem, regeneratedPniConstraintItem);\n    assertEquals(originalUsernameConstraints, regeneratedUsernameConstraints);\n  }\n\n  private void writeAccountRecordWithoutConstraints(final Account account) {\n    final AttributeValue accountData;\n\n    try {\n      accountData = AttributeValues.fromByteArray(Accounts.ACCOUNT_DDB_JSON_WRITER.writeValueAsBytes(account));\n    } catch (final JsonProcessingException e) {\n      throw new IllegalArgumentException(e);\n    }\n\n    final Map<String, AttributeValue> item = new HashMap<>(Map.of(\n        Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(account.getUuid()),\n        Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(account.getNumber()),\n        Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(account.getPhoneNumberIdentifier()),\n        Accounts.ATTR_ACCOUNT_DATA, accountData,\n        Accounts.ATTR_VERSION, AttributeValues.fromInt(account.getVersion()),\n        Accounts.ATTR_CANONICALLY_DISCOVERABLE, AttributeValues.fromBool(account.isDiscoverableByPhoneNumber())));\n\n    account.getUnidentifiedAccessKey()\n        .map(AttributeValues::fromByteArray)\n        .ifPresent(uak -> item.put(Accounts.ATTR_UAK, uak));\n\n    DYNAMO_DB_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()\n            .tableName(Tables.ACCOUNTS.tableName())\n            .item(item)\n        .build());\n  }\n\n  private Optional<UUID> getConstraintValue(final String tableName,\n      final String keyName,\n      final AttributeValue keyValue) {\n\n    final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n            .tableName(tableName)\n            .key(Map.of(keyName, keyValue))\n        .build());\n\n    return response.hasItem()\n        ? Optional.ofNullable(AttributeValues.getUUID(response.item(), Accounts.KEY_ACCOUNT_UUID, null))\n        : Optional.empty();\n  }\n\n  private Optional<UsernameConstraint> getUsernameConstraint(final byte[] usernameHash) {\n    final GetItemResponse response = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(GetItemRequest.builder()\n            .tableName(Tables.USERNAMES.tableName())\n            .key(Map.of(Accounts.UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash)))\n        .build());\n\n    if (response.hasItem()) {\n      final UUID accountIdentifier =\n          AttributeValues.getUUID(response.item(), Accounts.UsernameTable.ATTR_ACCOUNT_UUID, null);\n\n      final boolean confirmed = AttributeValues.getBool(response.item(), Accounts.UsernameTable.ATTR_CONFIRMED, false);\n\n      final Optional<Instant> expiration = response.item().containsKey(Accounts.UsernameTable.ATTR_TTL)\n          ? Optional.of(Instant.ofEpochSecond(AttributeValues.getLong(response.item(), Accounts.UsernameTable.ATTR_TTL, 0)))\n          : Optional.empty();\n\n      return Optional.of(new UsernameConstraint(accountIdentifier, confirmed, expiration));\n    }\n\n    return Optional.empty();\n  }\n\n  private static Device generateDevice(byte id) {\n    return DevicesHelper.createDevice(id);\n  }\n\n  private boolean createAccount(final Account account) {\n    try {\n      return accounts.create(account, Collections.emptyList());\n    } catch (AccountAlreadyExistsException e) {\n      throw new IllegalStateException(e);\n    }\n  }\n\n  private static Account nextRandomAccount() {\n    final String nextNumber = \"+1800%07d\".formatted(ACCOUNT_COUNTER.getAndIncrement());\n    return generateAccount(nextNumber, UUID.randomUUID(), UUID.randomUUID());\n  }\n\n  private static Account generateAccount(String number, UUID uuid, final UUID pni) {\n    Device device = generateDevice(DEVICE_ID_1);\n    return generateAccount(number, uuid, pni, List.of(device));\n  }\n\n  private static Account generateAccount(String number, UUID uuid, final UUID pni, List<Device> devices) {\n    final byte[] unidentifiedAccessKey = new byte[UnidentifiedAccessUtil.UNIDENTIFIED_ACCESS_KEY_LENGTH];\n    final Random random = new Random(System.currentTimeMillis());\n    Arrays.fill(unidentifiedAccessKey, (byte) random.nextInt(255));\n\n    return AccountsHelper.generateTestAccount(number, uuid, pni, devices, unidentifiedAccessKey);\n  }\n\n  private void assertPhoneNumberConstraintExists(final String number, final UUID uuid) {\n    final GetItemResponse numberConstraintResponse = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(\n        GetItemRequest.builder()\n            .tableName(Tables.NUMBERS.tableName())\n            .key(Map.of(Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(number)))\n            .build());\n\n    assertThat(numberConstraintResponse.hasItem()).isTrue();\n    assertThat(AttributeValues.getUUID(numberConstraintResponse.item(), Accounts.KEY_ACCOUNT_UUID, null)).isEqualTo(uuid);\n  }\n\n  private void assertPhoneNumberConstraintDoesNotExist(final String number) {\n    final GetItemResponse numberConstraintResponse = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(\n        GetItemRequest.builder()\n            .tableName(Tables.NUMBERS.tableName())\n            .key(Map.of(Accounts.ATTR_ACCOUNT_E164, AttributeValues.fromString(number)))\n            .build());\n\n    assertThat(numberConstraintResponse.hasItem()).isFalse();\n  }\n\n  private void assertPhoneNumberIdentifierConstraintExists(final UUID phoneNumberIdentifier, final UUID uuid) {\n    final GetItemResponse pniConstraintResponse = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(\n        GetItemRequest.builder()\n            .tableName(Tables.PNI_ASSIGNMENTS.tableName())\n            .key(Map.of(Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier)))\n            .build());\n\n    assertThat(pniConstraintResponse.hasItem()).isTrue();\n    assertThat(AttributeValues.getUUID(pniConstraintResponse.item(), Accounts.KEY_ACCOUNT_UUID, null)).isEqualTo(uuid);\n  }\n\n  private void assertPhoneNumberIdentifierConstraintDoesNotExist(final UUID phoneNumberIdentifier) {\n    final GetItemResponse pniConstraintResponse = DYNAMO_DB_EXTENSION.getDynamoDbClient().getItem(\n        GetItemRequest.builder()\n            .tableName(Tables.PNI_ASSIGNMENTS.tableName())\n            .key(Map.of(Accounts.ATTR_PNI_UUID, AttributeValues.fromUUID(phoneNumberIdentifier)))\n            .build());\n\n    assertThat(pniConstraintResponse.hasItem()).isFalse();\n  }\n\n  private Map<String, AttributeValue> readAccount(final UUID uuid) {\n    final DynamoDbClient db = DYNAMO_DB_EXTENSION.getDynamoDbClient();\n\n    final GetItemResponse get = db.getItem(GetItemRequest.builder()\n        .tableName(Tables.ACCOUNTS.tableName())\n        .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid)))\n        .consistentRead(true)\n        .build());\n    return get.item();\n  }\n\n  private Map<String, AttributeValue> getUsernameConstraintTableItem(final byte[] usernameHash) {\n    return DYNAMO_DB_EXTENSION.getDynamoDbClient()\n        .getItem(GetItemRequest.builder()\n            .tableName(Tables.USERNAMES.tableName())\n            .key(Map.of(Accounts.UsernameTable.KEY_USERNAME_HASH, AttributeValues.fromByteArray(usernameHash)))\n            .build())\n        .item();\n  }\n\n  @SuppressWarnings(\"SameParameterValue\")\n  private void verifyStoredState(String number, UUID uuid, UUID pni, byte[] usernameHash, Account expecting, boolean canonicallyDiscoverable) {\n    final DynamoDbClient db = DYNAMO_DB_EXTENSION.getDynamoDbClient();\n\n    final GetItemResponse get = db.getItem(GetItemRequest.builder()\n        .tableName(Tables.ACCOUNTS.tableName())\n        .key(Map.of(Accounts.KEY_ACCOUNT_UUID, AttributeValues.fromUUID(uuid)))\n        .consistentRead(true)\n        .build());\n\n    if (get.hasItem()) {\n      String data = new String(get.item().get(Accounts.ATTR_ACCOUNT_DATA).b().asByteArray(), StandardCharsets.UTF_8);\n      assertThat(data).isNotEmpty();\n\n      assertThat(AttributeValues.getInt(get.item(), Accounts.ATTR_VERSION, -1))\n          .isEqualTo(expecting.getVersion());\n\n      assertThat(AttributeValues.getBool(get.item(), Accounts.ATTR_CANONICALLY_DISCOVERABLE,\n          !canonicallyDiscoverable)).isEqualTo(canonicallyDiscoverable);\n\n      assertThat(AttributeValues.getByteArray(get.item(), Accounts.ATTR_UAK, null))\n          .isEqualTo(expecting.getUnidentifiedAccessKey().orElse(null));\n\n      assertArrayEquals(AttributeValues.getByteArray(get.item(), Accounts.ATTR_USERNAME_HASH, null), usernameHash);\n\n      Account result = Accounts.fromItem(get.item());\n      verifyStoredState(number, uuid, pni, usernameHash, result, expecting);\n    } else {\n      throw new AssertionError(\"No data\");\n    }\n  }\n\n  private void verifyStoredState(String number, UUID uuid, UUID pni, byte[] usernameHash, Account result, Account expecting) {\n    assertThat(result.getNumber()).isEqualTo(number);\n    assertThat(result.getPhoneNumberIdentifier()).isEqualTo(pni);\n    assertThat(result.getLastSeen()).isEqualTo(expecting.getLastSeen());\n    assertThat(result.getUuid()).isEqualTo(uuid);\n    assertThat(result.getVersion()).isEqualTo(expecting.getVersion());\n    assertArrayEquals(result.getUsernameHash().orElse(null), usernameHash);\n    assertArrayEquals(expecting.getUnidentifiedAccessKey().orElseThrow(), result.getUnidentifiedAccessKey().orElseThrow());\n\n    for (final Device expectingDevice : expecting.getDevices()) {\n      final Device resultDevice = result.getDevice(expectingDevice.getId()).orElseThrow();\n      assertThat(resultDevice.getApnId()).isEqualTo(expectingDevice.getApnId());\n      assertThat(resultDevice.getGcmId()).isEqualTo(expectingDevice.getGcmId());\n      assertThat(resultDevice.getLastSeen()).isEqualTo(expectingDevice.getLastSeen());\n      assertThat(resultDevice.getFetchesMessages()).isEqualTo(expectingDevice.getFetchesMessages());\n      assertThat(resultDevice.getUserAgent()).isEqualTo(expectingDevice.getUserAgent());\n      assertThat(resultDevice.getName()).isEqualTo(expectingDevice.getName());\n      assertThat(resultDevice.getCreated()).isEqualTo(expectingDevice.getCreated());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/AddRemoveDeviceIntegrationTest.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.entities.DeviceInfo;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.redis.RedisServerExtension;\nimport org.whispersystems.textsecuregcm.securestorage.SecureStorageClient;\nimport org.whispersystems.textsecuregcm.securevaluerecovery.SecureValueRecoveryClient;\nimport org.whispersystems.textsecuregcm.tests.util.AccountsHelper;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\npublic class AddRemoveDeviceIntegrationTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.ACCOUNTS,\n      DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS,\n      DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS_LOCK,\n      DynamoDbExtensionSchema.Tables.USED_LINK_DEVICE_TOKENS,\n      DynamoDbExtensionSchema.Tables.NUMBERS,\n      DynamoDbExtensionSchema.Tables.PNI,\n      DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS,\n      DynamoDbExtensionSchema.Tables.USERNAMES,\n      DynamoDbExtensionSchema.Tables.EC_KEYS,\n      DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS,\n      DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS,\n      DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  @RegisterExtension\n  static final RedisClusterExtension CACHE_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @RegisterExtension\n  static final RedisServerExtension PUBSUB_SERVER_EXTENSION = RedisServerExtension.builder().build();\n\n  @RegisterExtension\n  static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension(\"testbucket\");\n\n  private ExecutorService accountLockExecutor;\n  private ScheduledExecutorService scheduledExecutorService;\n\n  private KeysManager keysManager;\n  private MessagesManager messagesManager;\n  private AccountsManager accountsManager;\n  private TestClock clock;\n\n  @BeforeEach\n  void setUp() {\n    clock = TestClock.pinned(Instant.now());\n\n    final DynamoDbAsyncClient dynamoDbAsyncClient = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient();\n    keysManager = new KeysManager(\n        new SingleUseECPreKeyStore(dynamoDbAsyncClient, DynamoDbExtensionSchema.Tables.EC_KEYS.tableName()),\n        new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,\n            S3_EXTENSION.getS3Client(),\n            DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),\n            S3_EXTENSION.getBucketName()),\n        new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient,\n            DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),\n        new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient,\n            DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));\n\n    final Accounts accounts = new Accounts(\n        clock,\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.ACCOUNTS.tableName(),\n        DynamoDbExtensionSchema.Tables.NUMBERS.tableName(),\n        DynamoDbExtensionSchema.Tables.PNI_ASSIGNMENTS.tableName(),\n        DynamoDbExtensionSchema.Tables.USERNAMES.tableName(),\n        DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS.tableName(),\n        DynamoDbExtensionSchema.Tables.USED_LINK_DEVICE_TOKENS.tableName());\n\n    accountLockExecutor = Executors.newSingleThreadExecutor();\n    scheduledExecutorService = mock(ScheduledExecutorService.class);\n\n    final AccountLockManager accountLockManager = new AccountLockManager(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DynamoDbExtensionSchema.Tables.DELETED_ACCOUNTS_LOCK.tableName());\n\n    final SecureStorageClient secureStorageClient = mock(SecureStorageClient.class);\n    when(secureStorageClient.deleteStoredData(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final SecureValueRecoveryClient svr2Client = mock(SecureValueRecoveryClient.class);\n    when(svr2Client.removeData(any(UUID.class))).thenReturn(CompletableFuture.completedFuture(null));\n\n    final PhoneNumberIdentifiers phoneNumberIdentifiers =\n        new PhoneNumberIdentifiers(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n            DynamoDbExtensionSchema.Tables.PNI.tableName());\n\n    messagesManager = mock(MessagesManager.class);\n    when(messagesManager.clear(any(), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final ProfilesManager profilesManager = mock(ProfilesManager.class);\n    when(profilesManager.deleteAll(any(), anyBoolean())).thenReturn(CompletableFuture.completedFuture(null));\n\n    final RegistrationRecoveryPasswordsManager registrationRecoveryPasswordsManager =\n        mock(RegistrationRecoveryPasswordsManager.class);\n\n    when(registrationRecoveryPasswordsManager.remove(any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    PUBSUB_SERVER_EXTENSION.getRedisClient().useConnection(connection -> {\n      connection.sync().flushall();\n      connection.sync().configSet(\"notify-keyspace-events\", \"K$\");\n    });\n\n    accountsManager = new AccountsManager(\n        accounts,\n        phoneNumberIdentifiers,\n        CACHE_CLUSTER_EXTENSION.getRedisCluster(),\n        PUBSUB_SERVER_EXTENSION.getRedisClient(),\n        accountLockManager,\n        keysManager,\n        messagesManager,\n        profilesManager,\n        secureStorageClient,\n        svr2Client,\n        mock(DisconnectionRequestManager.class),\n        mock(RegistrationRecoveryPasswordsManager.class),\n        accountLockExecutor,\n        scheduledExecutorService,\n        scheduledExecutorService,\n        clock,\n        \"link-device-secret\".getBytes(StandardCharsets.UTF_8));\n\n    accountsManager.start();\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    accountsManager.stop();\n\n    accountLockExecutor.shutdown();\n\n    //noinspection ResultOfMethodCallIgnored\n    accountLockExecutor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void addDevice() throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n    assertEquals(1, accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getDevices().size());\n\n    final Pair<Account, Device> updatedAccountAndDevice =\n        accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI)));\n\n    assertEquals(2, updatedAccountAndDevice.first().getDevices().size());\n\n    assertEquals(2,\n        accountsManager.getByAccountIdentifier(updatedAccountAndDevice.first().getUuid()).orElseThrow().getDevices()\n            .size());\n\n    final byte addedDeviceId = updatedAccountAndDevice.second().getId();\n\n    assertTrue(\n        keysManager.getEcSignedPreKey(updatedAccountAndDevice.first().getUuid(), addedDeviceId).join().isPresent());\n    assertTrue(\n        keysManager.getEcSignedPreKey(updatedAccountAndDevice.first().getPhoneNumberIdentifier(), addedDeviceId).join()\n            .isPresent());\n    assertTrue(keysManager.getLastResort(updatedAccountAndDevice.first().getUuid(), addedDeviceId).join().isPresent());\n    assertTrue(\n        keysManager.getLastResort(updatedAccountAndDevice.first().getPhoneNumberIdentifier(), addedDeviceId).join()\n            .isPresent());\n  }\n\n  @Test\n  void addDeviceReusedToken() throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n    assertEquals(1, accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getDevices().size());\n\n    final String linkDeviceToken = accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI));\n\n    final Pair<Account, Device> updatedAccountAndDevice =\n        accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                linkDeviceToken);\n\n    assertEquals(2,\n        accountsManager.getByAccountIdentifier(updatedAccountAndDevice.first().getUuid()).orElseThrow().getDevices()\n            .size());\n\n    assertThrows(LinkDeviceTokenAlreadyUsedException.class,\n        () -> accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                linkDeviceToken));\n\n    assertEquals(2,\n        accountsManager.getByAccountIdentifier(updatedAccountAndDevice.first().getUuid()).orElseThrow().getDevices()\n            .size());\n  }\n\n  @Test\n  void removeDevice() throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n    assertEquals(1, accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getDevices().size());\n\n    final Pair<Account, Device> updatedAccountAndDevice =\n        accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI)));\n\n    final byte addedDeviceId = updatedAccountAndDevice.second().getId();\n\n    final Account updatedAccount = accountsManager.removeDevice(updatedAccountAndDevice.first(), addedDeviceId);\n\n    assertEquals(1, updatedAccount.getDevices().size());\n\n    assertFalse(keysManager.getEcSignedPreKey(updatedAccount.getUuid(), addedDeviceId).join().isPresent());\n    assertFalse(\n        keysManager.getEcSignedPreKey(updatedAccount.getPhoneNumberIdentifier(), addedDeviceId).join().isPresent());\n    assertFalse(keysManager.getLastResort(updatedAccount.getUuid(), addedDeviceId).join().isPresent());\n    assertFalse(keysManager.getLastResort(updatedAccount.getPhoneNumberIdentifier(), addedDeviceId).join().isPresent());\n\n    assertTrue(keysManager.getEcSignedPreKey(updatedAccount.getUuid(), Device.PRIMARY_ID).join().isPresent());\n    assertTrue(\n        keysManager.getEcSignedPreKey(updatedAccount.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join().isPresent());\n    assertTrue(keysManager.getLastResort(updatedAccount.getUuid(), Device.PRIMARY_ID).join().isPresent());\n    assertTrue(\n        keysManager.getLastResort(updatedAccount.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join().isPresent());\n  }\n\n  @Test\n  void removeDevicePartialFailure() throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n    assertEquals(1, accountsManager.getByAccountIdentifier(account.getUuid()).orElseThrow().getDevices().size());\n\n    final UUID aci = account.getIdentifier(IdentityType.ACI);\n\n    final Pair<Account, Device> updatedAccountAndDevice =\n        accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI)));\n\n    final byte addedDeviceId = updatedAccountAndDevice.second().getId();\n\n    when(messagesManager.clear(any(), anyByte()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException(\"OH NO\")));\n\n    assertThrows(RuntimeException.class,\n        () -> accountsManager.removeDevice(updatedAccountAndDevice.first(), addedDeviceId));\n\n    final Account retrievedAccount = accountsManager.getByAccountIdentifierAsync(aci).join().orElseThrow();\n\n    assertEquals(2, retrievedAccount.getDevices().size());\n\n    assertTrue(keysManager.getEcSignedPreKey(retrievedAccount.getUuid(), addedDeviceId).join().isPresent());\n    assertTrue(\n        keysManager.getEcSignedPreKey(retrievedAccount.getPhoneNumberIdentifier(), addedDeviceId).join().isPresent());\n    assertTrue(keysManager.getLastResort(retrievedAccount.getUuid(), addedDeviceId).join().isPresent());\n    assertTrue(\n        keysManager.getLastResort(retrievedAccount.getPhoneNumberIdentifier(), addedDeviceId).join().isPresent());\n\n    assertTrue(keysManager.getEcSignedPreKey(retrievedAccount.getUuid(), Device.PRIMARY_ID).join().isPresent());\n    assertTrue(keysManager.getEcSignedPreKey(retrievedAccount.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join()\n        .isPresent());\n    assertTrue(keysManager.getLastResort(retrievedAccount.getUuid(), Device.PRIMARY_ID).join().isPresent());\n    assertTrue(\n        keysManager.getLastResort(retrievedAccount.getPhoneNumberIdentifier(), Device.PRIMARY_ID).join().isPresent());\n  }\n\n  @Test\n  void waitForNewLinkedDevice() throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n\n    final String linkDeviceToken = accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI));\n    final String linkDeviceTokenIdentifier = AccountsManager.getLinkDeviceTokenIdentifier(linkDeviceToken);\n\n    final CompletableFuture<Optional<DeviceInfo>> displacedFuture = accountsManager.waitForNewLinkedDevice(\n        account.getUuid(), account.getPrimaryDevice(),\n        linkDeviceTokenIdentifier, Duration.ofSeconds(5));\n\n    when(messagesManager.getEarliestUndeliveredTimestampForDevice(account.getUuid(), account.getPrimaryDevice()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n    final CompletableFuture<Optional<DeviceInfo>> activeFuture =\n        accountsManager.waitForNewLinkedDevice(account.getUuid(), account.getPrimaryDevice(), linkDeviceTokenIdentifier,\n            Duration.ofSeconds(5));\n\n    assertEquals(Optional.empty(), displacedFuture.join());\n\n    final Pair<Account, Device> updatedAccountAndDevice =\n        accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                linkDeviceToken);\n\n    final Optional<DeviceInfo> maybeDeviceInfo = activeFuture.join();\n\n    assertTrue(maybeDeviceInfo.isPresent());\n    final DeviceInfo deviceInfo = maybeDeviceInfo.get();\n\n    assertEquals(updatedAccountAndDevice.second().getId(), deviceInfo.id());\n    assertEquals(updatedAccountAndDevice.second().getRegistrationId(IdentityType.ACI), deviceInfo.registrationId());\n    assertNotNull(deviceInfo.createdAtCiphertext());\n  }\n\n  @Test\n  void waitForNewLinkedDeviceAlreadyAdded() throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n\n    final String linkDeviceToken = accountsManager.generateLinkDeviceToken(account.getIdentifier(IdentityType.ACI));\n    final String linkDeviceTokenIdentifier = AccountsManager.getLinkDeviceTokenIdentifier(linkDeviceToken);\n\n    final Pair<Account, Device> updatedAccountAndDevice =\n        accountsManager.addDevice(account, new DeviceSpec(\n                    \"device-name\".getBytes(StandardCharsets.UTF_8),\n                    \"password\",\n                    \"OWT\",\n                    Set.of(),\n                    1,\n                    2,\n                    true,\n                    Optional.empty(),\n                    Optional.empty(),\n                    KeysHelper.signedECPreKey(1, aciKeyPair),\n                    KeysHelper.signedECPreKey(2, pniKeyPair),\n                    KeysHelper.signedKEMPreKey(3, aciKeyPair),\n                    KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n                linkDeviceToken);\n\n    when(messagesManager.getEarliestUndeliveredTimestampForDevice(account.getUuid(), account.getPrimaryDevice()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    final CompletableFuture<Optional<DeviceInfo>> linkedDeviceFuture = accountsManager.waitForNewLinkedDevice(\n        account.getUuid(), account.getPrimaryDevice(), linkDeviceTokenIdentifier, Duration.ofMinutes(1));\n\n    final Optional<DeviceInfo> maybeDeviceInfo = linkedDeviceFuture.join();\n\n    assertTrue(maybeDeviceInfo.isPresent());\n    final DeviceInfo deviceInfo = maybeDeviceInfo.get();\n\n    assertEquals(updatedAccountAndDevice.second().getId(), deviceInfo.id());\n    assertEquals(updatedAccountAndDevice.second().getRegistrationId(IdentityType.ACI), deviceInfo.registrationId());\n    assertNotNull(deviceInfo.createdAtCiphertext());\n  }\n\n  @Test\n  void waitForNewLinkedDeviceTimeout() throws InterruptedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n\n    final String linkDeviceToken = accountsManager.generateLinkDeviceToken(UUID.randomUUID());\n    final String linkDeviceTokenIdentifier = AccountsManager.getLinkDeviceTokenIdentifier(linkDeviceToken);\n\n    final CompletableFuture<Optional<DeviceInfo>> linkedDeviceFuture = accountsManager.waitForNewLinkedDevice(\n        account.getUuid(), account.getPrimaryDevice(), linkDeviceTokenIdentifier, Duration.ofMillis(1));\n\n    final Optional<DeviceInfo> maybeDeviceInfo = linkedDeviceFuture.join();\n\n    assertTrue(maybeDeviceInfo.isEmpty());\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"10_000,,false\",         // no pending messages\n      \"10_000,9999,true\",      // pending message right before now\n      \"10_000,10_000,false\",   // pending message at now\n      \"10_000,10_001,false\",   // pending message after now\n  })\n  void waitForMessageFetch(long currentTime, Long oldestMessage, boolean shouldWait)\n      throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n\n    final String linkDeviceToken = accountsManager.generateLinkDeviceToken(UUID.randomUUID());\n    final String linkDeviceTokenIdentifier = AccountsManager.getLinkDeviceTokenIdentifier(linkDeviceToken);\n\n    accountsManager.addDevice(account, new DeviceSpec(\n            \"device-name\".getBytes(StandardCharsets.UTF_8),\n            \"password\",\n            \"OWT\",\n            Set.of(),\n            1,\n            2,\n            true,\n            Optional.empty(),\n            Optional.empty(),\n            KeysHelper.signedECPreKey(1, aciKeyPair),\n            KeysHelper.signedECPreKey(2, pniKeyPair),\n            KeysHelper.signedKEMPreKey(3, aciKeyPair),\n            KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n        linkDeviceToken);\n\n    when(messagesManager.getEarliestUndeliveredTimestampForDevice(account.getUuid(), account.getPrimaryDevice()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.ofNullable(oldestMessage).map(Instant::ofEpochMilli)));\n\n    clock.pin(Instant.ofEpochMilli(currentTime));\n    Duration timeout = shouldWait ? Duration.ofMillis(5) : Duration.ofMillis(1000);\n    Optional<DeviceInfo> result = accountsManager.waitForNewLinkedDevice(account.getUuid(),\n        account.getPrimaryDevice(), linkDeviceTokenIdentifier, timeout).join();\n    assertEquals(result.isEmpty(), shouldWait);\n  }\n\n  // ThreadMode.SEPARATE_THREAD protects against hangs in the async calls, as this mode allows the test code to be\n  // preempted by the timeout check\n  @Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\n  @Test\n  void waitForMessageFetchRetries()\n      throws InterruptedException, LinkDeviceTokenAlreadyUsedException {\n    final String number = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"),\n        PhoneNumberUtil.PhoneNumberFormat.E164);\n    final ECKeyPair aciKeyPair = ECKeyPair.generate();\n    final ECKeyPair pniKeyPair = ECKeyPair.generate();\n    final Account account = AccountsHelper.createAccount(accountsManager, number);\n\n    final String linkDeviceToken = accountsManager.generateLinkDeviceToken(UUID.randomUUID());\n    final String linkDeviceTokenIdentifier = AccountsManager.getLinkDeviceTokenIdentifier(linkDeviceToken);\n\n    clock.pin(Instant.ofEpochMilli(0));\n    accountsManager.addDevice(account, new DeviceSpec(\n            \"device-name\".getBytes(StandardCharsets.UTF_8),\n            \"password\",\n            \"OWT\",\n            Set.of(),\n            1,\n            2,\n            true,\n            Optional.empty(),\n            Optional.empty(),\n            KeysHelper.signedECPreKey(1, aciKeyPair),\n            KeysHelper.signedECPreKey(2, pniKeyPair),\n            KeysHelper.signedKEMPreKey(3, aciKeyPair),\n            KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n        linkDeviceToken);\n\n    when(messagesManager.getEarliestUndeliveredTimestampForDevice(account.getUuid(), account.getPrimaryDevice()))\n        // Has a message older than the message epoch\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(Instant.ofEpochMilli(1000))))\n        // The message was fetched\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n    clock.pin(Instant.ofEpochMilli(10_000));\n    // Run any scheduled job right away\n    when(scheduledExecutorService.schedule(any(Runnable.class), anyLong(), any())).thenAnswer(x -> {\n      x.getArgument(0, Runnable.class).run();\n      return null;\n    });\n    Optional<DeviceInfo> result = accountsManager.waitForNewLinkedDevice(account.getUuid(),\n        account.getPrimaryDevice(), linkDeviceTokenIdentifier, Duration.ofSeconds(10)).join();\n    assertTrue(result.isPresent());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ChangeNumberManagerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.protobuf.ByteString;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.stubbing.Answer;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.IncomingMessage;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.push.MessageSender;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\npublic class ChangeNumberManagerTest {\n  private AccountsManager accountsManager;\n  private MessageSender messageSender;\n  private ChangeNumberManager changeNumberManager;\n\n  private Map<Account, UUID> updatedPhoneNumberIdentifiersByAccount;\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  @BeforeEach\n  void setUp() throws Exception {\n    accountsManager = mock(AccountsManager.class);\n    messageSender = mock(MessageSender.class);\n    changeNumberManager = new ChangeNumberManager(messageSender, accountsManager, CLOCK);\n\n    updatedPhoneNumberIdentifiersByAccount = new HashMap<>();\n\n    when(accountsManager.changeNumber(any(), any(), any(), any(), any(), any())).thenAnswer((Answer<Account>)invocation -> {\n      final Account account = invocation.getArgument(0, Account.class);\n      final String number = invocation.getArgument(1, String.class);\n\n      final UUID uuid = account.getIdentifier(IdentityType.ACI);\n      final List<Device> devices = account.getDevices();\n\n      final UUID updatedPni = UUID.randomUUID();\n      updatedPhoneNumberIdentifiersByAccount.put(account, updatedPni);\n\n      final Account updatedAccount = mock(Account.class);\n      when(updatedAccount.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n      when(updatedAccount.getIdentifier(IdentityType.PNI)).thenReturn(updatedPni);\n      when(updatedAccount.isIdentifiedBy(any())).thenReturn(false);\n      when(updatedAccount.isIdentifiedBy(new AciServiceIdentifier(uuid))).thenReturn(true);\n      when(updatedAccount.isIdentifiedBy(new PniServiceIdentifier(updatedPni))).thenReturn(true);\n      when(updatedAccount.getNumber()).thenReturn(number);\n      when(updatedAccount.getDevices()).thenReturn(devices);\n      when(updatedAccount.getDevice(anyByte())).thenReturn(Optional.empty());\n\n      account.getDevices().forEach(device ->\n          when(updatedAccount.getDevice(device.getId())).thenReturn(Optional.of(device)));\n\n      return updatedAccount;\n    });\n  }\n\n  @Test\n  void changeNumberSingleDevice() throws Exception {\n    final String targetNumber = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n    final IdentityKey pniIdentityKey = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n    final Map<Byte, ECSignedPreKey> ecSignedPreKeys =\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedECPreKey(1, pniIdentityKeyPair));\n\n    final Map<Byte, KEMSignedPreKey> kemLastResortPreKeys =\n        Map.of(Device.PRIMARY_ID, KeysHelper.signedKEMPreKey(2, pniIdentityKeyPair));\n\n    final UUID accountIdentifier = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.isIdentifiedBy(any())).thenReturn(false);\n    when(account.isIdentifiedBy(new AciServiceIdentifier(accountIdentifier))).thenReturn(true);\n\n    changeNumberManager.changeNumber(account, targetNumber, pniIdentityKey, ecSignedPreKeys, kemLastResortPreKeys, Collections.emptyList(), Collections.emptyMap(), null);\n    verify(accountsManager).changeNumber(account, targetNumber, pniIdentityKey, ecSignedPreKeys, kemLastResortPreKeys, Collections.emptyMap());\n    verify(messageSender, never()).sendMessages(eq(account), any(), any(), any(), any(), any());\n  }\n\n  @Test\n  void changeNumberLinkedDevices() throws Exception {\n    final String targetNumber = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final UUID aci = UUID.randomUUID();\n\n    final byte primaryDeviceId = Device.PRIMARY_ID;\n    final byte linkedDeviceId = primaryDeviceId + 1;\n\n    final int primaryDeviceRegistrationId = 17;\n    final int linkedDeviceRegistrationId = primaryDeviceRegistrationId + 1;\n\n    final Device primaryDevice = mock(Device.class);\n    when(primaryDevice.getId()).thenReturn(primaryDeviceId);\n    when(primaryDevice.getRegistrationId(IdentityType.ACI)).thenReturn(primaryDeviceRegistrationId);\n\n    final Device linkedDevice = mock(Device.class);\n    when(linkedDevice.getId()).thenReturn(linkedDeviceId);\n    when(linkedDevice.getRegistrationId(IdentityType.ACI)).thenReturn(linkedDeviceRegistrationId);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(aci);\n    when(account.isIdentifiedBy(any())).thenReturn(false);\n    when(account.isIdentifiedBy(new AciServiceIdentifier(aci))).thenReturn(true);\n    when(account.getDevice(anyByte())).thenReturn(Optional.empty());\n    when(account.getDevice(primaryDeviceId)).thenReturn(Optional.of(primaryDevice));\n    when(account.getDevice(linkedDeviceId)).thenReturn(Optional.of(linkedDevice));\n    when(account.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n\n    final ECKeyPair pniIdentityKeyPair = ECKeyPair.generate();\n    final IdentityKey pniIdentityKey = new IdentityKey(pniIdentityKeyPair.getPublicKey());\n    final Map<Byte, ECSignedPreKey> ecSignedPreKeys = Map.of(\n        primaryDeviceId, KeysHelper.signedECPreKey(1, pniIdentityKeyPair),\n        linkedDeviceId, KeysHelper.signedECPreKey(2, pniIdentityKeyPair));\n\n    final Map<Byte, KEMSignedPreKey> kemLastResortPreKeys = Map.of(\n        primaryDeviceId, KeysHelper.signedKEMPreKey(3, pniIdentityKeyPair),\n        linkedDeviceId, KeysHelper.signedKEMPreKey(4, pniIdentityKeyPair));\n\n    final Map<Byte, Integer> registrationIds = Map.of(\n        primaryDeviceId, primaryDeviceRegistrationId,\n        linkedDeviceId, linkedDeviceRegistrationId);\n\n    final IncomingMessage incomingMessage =\n        new IncomingMessage(1, linkedDeviceId, linkedDeviceRegistrationId, new byte[] { 1 });\n\n    changeNumberManager.changeNumber(account,\n        targetNumber,\n        pniIdentityKey,\n        ecSignedPreKeys,\n        kemLastResortPreKeys,\n        List.of(incomingMessage),\n        registrationIds,\n        null);\n\n    verify(accountsManager).changeNumber(account,\n        targetNumber,\n        pniIdentityKey,\n        ecSignedPreKeys,\n        kemLastResortPreKeys,\n        registrationIds);\n\n    final MessageProtos.Envelope expectedEnvelope = MessageProtos.Envelope.newBuilder()\n        .setType(MessageProtos.Envelope.Type.forNumber(incomingMessage.type()))\n        .setClientTimestamp(CLOCK.millis())\n        .setServerTimestamp(CLOCK.millis())\n        .setDestinationServiceId(new AciServiceIdentifier(aci).toServiceIdentifierString())\n        .setContent(ByteString.copyFrom(incomingMessage.content()))\n        .setSourceServiceId(new AciServiceIdentifier(aci).toServiceIdentifierString())\n        .setSourceDevice(primaryDeviceId)\n        .setUpdatedPni(updatedPhoneNumberIdentifiersByAccount.get(account).toString())\n        .setUrgent(true)\n        .setEphemeral(false)\n        .build();\n\n    verify(messageSender).sendMessages(argThat(a -> a.getIdentifier(IdentityType.ACI).equals(aci)),\n        eq(new AciServiceIdentifier(aci)),\n        eq(Map.of(linkedDeviceId, expectedEnvelope)),\n        eq(Map.of(linkedDeviceId, linkedDeviceRegistrationId)),\n        eq(Optional.of(primaryDeviceId)),\n        any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientReleaseManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.vdurmont.semver4j.Semver;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.Map;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\n\nclass ClientReleaseManagerTest {\n\n  private ClientReleases clientReleases;\n  private Clock clock;\n\n  private ClientReleaseManager clientReleaseManager;\n\n  @BeforeEach\n  void setUp() {\n    clientReleases = mock(ClientReleases.class);\n    clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());\n\n    clientReleaseManager =\n        new ClientReleaseManager(clientReleases, mock(ScheduledExecutorService.class), Duration.ofHours(4), clock);\n  }\n\n  @Test\n  void isVersionActive() {\n    final Semver iosVersion = new Semver(\"1.2.3\");\n    final Semver desktopVersion = new Semver(\"4.5.6\");\n\n    when(clientReleases.getClientReleases()).thenReturn(Map.of(\n        ClientPlatform.DESKTOP, Map.of(desktopVersion, new ClientRelease(ClientPlatform.DESKTOP, desktopVersion, clock.instant(), clock.instant().plus(Duration.ofDays(90)))),\n        ClientPlatform.IOS, Map.of(iosVersion, new ClientRelease(ClientPlatform.IOS, iosVersion, clock.instant().minus(Duration.ofDays(91)), clock.instant().minus(Duration.ofDays(1))))\n    ));\n\n    clientReleaseManager.refreshClientVersions();\n\n    assertTrue(clientReleaseManager.isVersionActive(ClientPlatform.DESKTOP, desktopVersion));\n    assertFalse(clientReleaseManager.isVersionActive(ClientPlatform.DESKTOP, iosVersion));\n    assertFalse(clientReleaseManager.isVersionActive(ClientPlatform.IOS, iosVersion));\n    assertFalse(clientReleaseManager.isVersionActive(ClientPlatform.ANDROID, new Semver(\"7.8.9\")));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ClientReleasesTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport com.vdurmont.semver4j.Semver;\nimport java.time.Instant;\nimport java.time.temporal.ChronoUnit;\nimport java.util.Map;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.util.ua.ClientPlatform;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.PutItemRequest;\n\nclass ClientReleasesTest {\n\n  private ClientReleases clientReleases;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION =\n      new DynamoDbExtension(DynamoDbExtensionSchema.Tables.CLIENT_RELEASES);\n\n  @BeforeEach\n  void setUp() {\n    clientReleases = new ClientReleases(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.CLIENT_RELEASES.tableName());\n  }\n\n  @Test\n  void getClientReleases() {\n    final Instant releaseTimestamp = Instant.now().truncatedTo(ChronoUnit.SECONDS);\n    final Instant expiration = releaseTimestamp.plusSeconds(60);\n\n    storeClientRelease(\"IOS\", \"1.2.3\", releaseTimestamp, expiration);\n    storeClientRelease(\"IOS\", \"not-a-valid-version\", releaseTimestamp, expiration);\n    storeClientRelease(\"ANDROID\", \"4.5.6\", releaseTimestamp, expiration);\n    storeClientRelease(\"UNRECOGNIZED_PLATFORM\", \"7.8.9\", releaseTimestamp, expiration);\n\n    final Map<ClientPlatform, Map<Semver, ClientRelease>> expectedVersions = Map.of(\n        ClientPlatform.IOS, Map.of(new Semver(\"1.2.3\"), new ClientRelease(ClientPlatform.IOS, new Semver(\"1.2.3\"), releaseTimestamp, expiration)),\n        ClientPlatform.ANDROID, Map.of(new Semver(\"4.5.6\"), new ClientRelease(ClientPlatform.ANDROID, new Semver(\"4.5.6\"), releaseTimestamp, expiration)));\n\n    assertEquals(expectedVersions, clientReleases.getClientReleases());\n  }\n\n  private void storeClientRelease(final String platform, final String version, final Instant release, final Instant expiration) {\n    DYNAMO_DB_EXTENSION.getDynamoDbClient().putItem(PutItemRequest.builder()\n        .tableName(DynamoDbExtensionSchema.Tables.CLIENT_RELEASES.tableName())\n        .item(Map.of(\n            ClientReleases.ATTR_PLATFORM, AttributeValue.builder().s(platform).build(),\n            ClientReleases.ATTR_VERSION, AttributeValue.builder().s(version).build(),\n            ClientReleases.ATTR_RELEASE_TIMESTAMP,\n            AttributeValue.builder().n(String.valueOf(release.getEpochSecond())).build(),\n            ClientReleases.ATTR_EXPIRATION,\n            AttributeValue.builder().n(String.valueOf(expiration.getEpochSecond())).build()))\n        .build());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/DeviceTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\nclass DeviceTest {\n\n  @ParameterizedTest\n  @CsvSource({\n      \"true, P1D, false\",\n      \"true, P30D, false\",\n      \"true, P31D, false\",\n      \"true, P180D, false\",\n      \"true, P181D, true\",\n      \"false, P1D, false\",\n      \"false, P45D, false\",\n      \"false, P46D, true\",\n      \"false, P180D, true\",\n  })\n  public void testIsExpired(final boolean primary, final Duration timeSinceLastSeen, final boolean expectExpired) {\n\n    final long lastSeen = Instant.now()\n        .minus(timeSinceLastSeen)\n        // buffer for test runtime\n        .plusSeconds(1)\n        .toEpochMilli();\n\n    final Device device = new Device();\n    device.setId(primary ? Device.PRIMARY_ID : Device.PRIMARY_ID + 1);\n    device.setCreated(lastSeen);\n    device.setLastSeen(lastSeen);\n\n    assertEquals(expectExpired, device.isExpired());\n  }\n\n  @Test\n  void deserializeCapabilities() throws JsonProcessingException {\n    {\n      final Device device = SystemMapper.jsonMapper().readValue(\"\"\"\n          {\n            \"capabilities\": null\n          }\n          \"\"\", Device.class);\n\n      assertNotNull(device.getCapabilities(),\n          \"Device deserialization should populate null capabilities with an empty set\");\n    }\n\n    {\n      final Device device = SystemMapper.jsonMapper().readValue(\"{}\", Device.class);\n\n      assertNotNull(device.getCapabilities(),\n          \"Device deserialization should populate null capabilities with an empty set\");\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamicConfigurationManagerTest.java",
    "content": "package org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.mockito.Mockito.any;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.InputStream;\nimport java.time.Duration;\nimport java.util.concurrent.BrokenBarrierException;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.CyclicBarrier;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.function.Consumer;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.AdditionalAnswers;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.s3.S3ObjectMonitor;\n\nclass DynamicConfigurationManagerTest {\n\n  private static final byte[] VALID_CONFIG = \"\"\"\n    test: true\n    captcha:\n      scoreFloor: 1.0\n    \"\"\".getBytes();\n  private static final ExecutorService BACKGROUND_THREAD = Executors.newSingleThreadExecutor();\n\n  private DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager;\n  private S3ObjectMonitor configMonitor;\n\n  @BeforeEach\n  void setup() {\n    this.configMonitor = mock(S3ObjectMonitor.class);\n    this.dynamicConfigurationManager = new DynamicConfigurationManager<>(configMonitor, DynamicConfiguration.class);\n  }\n\n  @Test\n  void testGetInitialConfig() {\n    // supply real config on start, then never send updates\n    doAnswer(AdditionalAnswers.<Consumer<InputStream>>answerVoid(cb -> cb.accept(new ByteArrayInputStream(VALID_CONFIG))))\n        .when(configMonitor).start(any());\n\n    assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {\n      dynamicConfigurationManager.start();\n      assertThat(dynamicConfigurationManager.getConfiguration()).isNotNull();\n    });\n  }\n\n  @Test\n  void testBadConfig() {\n    // supply a bad config, then wait for the test to signal, then supply a good config\n    doAnswer(AdditionalAnswers.<Consumer<InputStream>>answerVoid(cb -> {\n              cb.accept(new ByteArrayInputStream(\"zzz\".getBytes()));\n              BACKGROUND_THREAD.submit(() -> cb.accept(new ByteArrayInputStream(VALID_CONFIG)));\n            })).when(configMonitor).start(any());\n\n    assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {\n          dynamicConfigurationManager.start();\n          assertThat(dynamicConfigurationManager.getConfiguration()).isNotNull();\n    });\n  }\n\n  @Test\n  void testGetConfigMultiple() {\n    final CyclicBarrier barrier = new CyclicBarrier(2);\n    // supply an initial config, wait for the test to signal, then supply a distinct good config\n    doAnswer(AdditionalAnswers.<Consumer<InputStream>>answerVoid(cb -> {\n              cb.accept(new ByteArrayInputStream(VALID_CONFIG));\n              BACKGROUND_THREAD.submit(() -> {\n                    try {\n                      barrier.await(); // wait for initial config to be consumed\n                      cb.accept(\n                          new ByteArrayInputStream(\"\"\"\n                              experiments:\n                                test:\n                                  enrollmentPercentage: 50\n                              captcha:\n                                scoreFloor: 1.0\n                              \"\"\".getBytes()));\n                      barrier.await(); // signal availability of new config\n                    } catch (InterruptedException | BrokenBarrierException e) {}\n                  });\n            })).when(configMonitor).start(any());\n\n    // the internal waiting done by dynamic configuration manager catches the InterruptedException used\n    // by JUnit’s @Timeout, so we use assertTimeoutPreemptively\n    assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {\n      dynamicConfigurationManager.start();\n      DynamicConfiguration config = dynamicConfigurationManager.getConfiguration();\n      assertThat(config).isNotNull();\n      assertThat(config.getExperimentEnrollmentConfiguration(\"test\")).isEmpty();\n      barrier.await();          // signal consumption of initial config\n      barrier.await();          // wait for availability of new config\n      config = dynamicConfigurationManager.getConfiguration();\n      assertThat(config).isNotNull();\n      assertThat(config.getExperimentEnrollmentConfiguration(\"test\")).isNotEmpty();\n      assertThat(config.getExperimentEnrollmentConfiguration(\"test\").get().getEnrollmentPercentage())\n          .isEqualTo(50);\n    });\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtension.java",
    "content": "/*\n * Copyright 2021-2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.net.URI;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport org.junit.jupiter.api.extension.AfterEachCallback;\nimport org.junit.jupiter.api.extension.BeforeAllCallback;\nimport org.junit.jupiter.api.extension.BeforeEachCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.testcontainers.containers.GenericContainer;\nimport org.testcontainers.utility.DockerImageName;\nimport org.whispersystems.textsecuregcm.util.TestcontainersImages;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;\nimport software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;\nimport software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;\nimport software.amazon.awssdk.services.dynamodb.model.KeyType;\nimport software.amazon.awssdk.services.dynamodb.model.LocalSecondaryIndex;\nimport software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput;\nimport software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException;\n\npublic class DynamoDbExtension implements BeforeAllCallback, BeforeEachCallback, AfterEachCallback, ExtensionContext.Store.CloseableResource {\n\n  public interface TableSchema {\n    String tableName();\n    String hashKeyName();\n    String rangeKeyName();\n    List<AttributeDefinition> attributeDefinitions();\n    List<GlobalSecondaryIndex> globalSecondaryIndexes();\n    List<LocalSecondaryIndex> localSecondaryIndexes();\n  }\n\n  record RawSchema(\n    String tableName,\n    String hashKeyName,\n    String rangeKeyName,\n    List<AttributeDefinition> attributeDefinitions,\n    List<GlobalSecondaryIndex> globalSecondaryIndexes,\n    List<LocalSecondaryIndex> localSecondaryIndexes\n  ) implements TableSchema { }\n\n  private static final Logger logger = LoggerFactory.getLogger(DynamoDbExtension.class);\n\n  static final ProvisionedThroughput DEFAULT_PROVISIONED_THROUGHPUT = ProvisionedThroughput.builder()\n      .readCapacityUnits(20L)\n      .writeCapacityUnits(20L)\n      .build();\n\n  private static final DockerImageName DYNAMO_DB_IMAGE = DockerImageName.parse(TestcontainersImages.getDynamoDb());\n  private static final int CONTAINER_PORT = 8000;\n  private static final GenericContainer<?> dynamoDbContainer =  new GenericContainer<>(DYNAMO_DB_IMAGE)\n      .withExposedPorts(CONTAINER_PORT)\n      .withCommand(\"-jar DynamoDBLocal.jar -inMemory -sharedDb -disableTelemetry\");\n\n  // These are static to simplify configuration in WhisperServerServiceTest\n  private static String endpointOverride;\n  private static Region region = Region.of(\"local\");\n  private static AwsCredentialsProvider awsCredentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(\"test\", \"test\"));\n  private static DynamoDbClient dynamoDb;\n  private static DynamoDbAsyncClient dynamoDbAsync;\n\n  private final List<TableSchema> schemas;\n\n  public DynamoDbExtension(TableSchema... schemas) {\n    this.schemas = List.of(schemas);\n  }\n\n  public void setEndpointOverride(String endpointOverride) {\n    DynamoDbExtension.endpointOverride = endpointOverride;\n  }\n\n  public void setRegion(String region) {\n    DynamoDbExtension.region = Region.of(region);\n  }\n\n  public void setAwsCredentialsProvider(AwsCredentialsProvider awsCredentialsProvider) {\n    DynamoDbExtension.awsCredentialsProvider = awsCredentialsProvider;\n  }\n\n  /**\n   * Starts the DynamoDB server\n   */\n  @Override\n  public void beforeAll(ExtensionContext context) throws Exception {\n    startServer();\n  }\n\n  /**\n   * Creates the tables from {@link #schemas}\n   */\n  @Override\n  public void beforeEach(final ExtensionContext context) throws Exception {\n    createTables();\n  }\n\n  /**\n   * Deletes the tables from {@link #schemas}\n   */\n  @Override\n  public void afterEach(ExtensionContext context) {\n    final Instant timeout = Instant.now().plus(Duration.ofSeconds(1));\n\n    schemas.stream().map(tableSchema -> dynamoDb.deleteTable(builder -> builder.tableName(tableSchema.tableName())))\n        .forEach(deleteTableResponse -> {\n          while (Instant.now().isBefore(timeout)) {\n            try {\n              // `deleteTable` is technically asynchronous, although it seems to be uncommon with DynamoDB Local,\n              // so this will usually throw and very rarely sleep().\n              dynamoDb.describeTable(builder -> builder.tableName(deleteTableResponse.tableDescription().tableName()));\n              Thread.sleep(50);\n            } catch (ResourceNotFoundException ignored) {\n              // success\n              break;\n            } catch (InterruptedException e) {\n              throw new RuntimeException(e);\n            }\n          }\n        });\n  }\n\n  @Override\n  public void close() throws Throwable {\n    stopServer();\n  }\n\n  private void startServer() {\n    if (endpointOverride == null) {\n      dynamoDbContainer.start();\n    }\n    if (dynamoDbAsync == null || dynamoDb == null) {\n      initializeClient();\n    }\n  }\n\n  private void stopServer() {\n    try {\n      if (dynamoDbContainer != null) {\n        dynamoDb.close();\n        dynamoDb = null;\n\n        dynamoDbAsync.close();\n        dynamoDbAsync = null;\n\n        dynamoDbContainer.stop();\n      }\n    } catch (Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  /**\n   * For use in integration tests that want to test resiliency/error handling\n   */\n  public void resetServer() {\n    stopServer();\n    startServer();\n    createTables();\n  }\n\n  private void createTables() {\n    logger.debug(\"Creating tables\");\n    schemas.forEach(this::createTable);\n  }\n\n  private void createTable(TableSchema schema) {\n    KeySchemaElement[] keySchemaElements;\n    if (schema.rangeKeyName() == null) {\n      keySchemaElements = new KeySchemaElement[] {\n          KeySchemaElement.builder().attributeName(schema.hashKeyName()).keyType(KeyType.HASH).build(),\n      };\n    } else {\n      keySchemaElements = new KeySchemaElement[] {\n          KeySchemaElement.builder().attributeName(schema.hashKeyName()).keyType(KeyType.HASH).build(),\n          KeySchemaElement.builder().attributeName(schema.rangeKeyName()).keyType(KeyType.RANGE).build(),\n      };\n    }\n\n    final CreateTableRequest createTableRequest = CreateTableRequest.builder()\n        .tableName(schema.tableName())\n        .keySchema(keySchemaElements)\n        .attributeDefinitions(schema.attributeDefinitions().isEmpty() ? null : schema.attributeDefinitions())\n        .globalSecondaryIndexes(schema.globalSecondaryIndexes().isEmpty() ? null : schema.globalSecondaryIndexes())\n        .localSecondaryIndexes(schema.localSecondaryIndexes().isEmpty() ? null : schema.localSecondaryIndexes())\n        .provisionedThroughput(DEFAULT_PROVISIONED_THROUGHPUT)\n        .build();\n\n    getDynamoDbClient().createTable(createTableRequest);\n  }\n\n  private void initializeClient() {\n    final URI endpoint = endpointOverride == null ?\n        URI.create(String.format(\"http://%s:%d\", dynamoDbContainer.getHost(), dynamoDbContainer.getMappedPort(CONTAINER_PORT)))\n        : URI.create(endpointOverride);\n\n    dynamoDb = DynamoDbClient.builder()\n        .region(region)\n        .credentialsProvider(awsCredentialsProvider)\n        .endpointOverride(endpoint)\n        .build();\n    dynamoDbAsync = DynamoDbAsyncClient.builder()\n        .region(region)\n        .credentialsProvider(awsCredentialsProvider)\n        .endpointOverride(endpoint)\n        .build();\n  }\n\n  public DynamoDbClient getDynamoDbClient() {\n    return dynamoDb;\n  }\n\n  public DynamoDbAsyncClient getDynamoDbAsyncClient() {\n    return dynamoDbAsync;\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/DynamoDbExtensionSchema.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.util.Collections;\nimport java.util.List;\nimport org.whispersystems.textsecuregcm.backup.BackupsDb;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;\nimport org.whispersystems.textsecuregcm.scheduler.JobScheduler;\nimport org.whispersystems.textsecuregcm.storage.devicecheck.AppleDeviceChecks;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;\nimport software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;\nimport software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;\nimport software.amazon.awssdk.services.dynamodb.model.KeyType;\nimport software.amazon.awssdk.services.dynamodb.model.LocalSecondaryIndex;\nimport software.amazon.awssdk.services.dynamodb.model.Projection;\nimport software.amazon.awssdk.services.dynamodb.model.ProjectionType;\nimport software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput;\nimport software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;\n\npublic final class DynamoDbExtensionSchema {\n\n  public enum Tables implements DynamoDbExtension.TableSchema {\n\n    ACCOUNTS(\"accounts_test\",\n        Accounts.KEY_ACCOUNT_UUID,\n        null,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(Accounts.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(Accounts.ATTR_USERNAME_LINK_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(\n            GlobalSecondaryIndex.builder()\n                .indexName(Accounts.USERNAME_LINK_TO_UUID_INDEX)\n                .keySchema(\n                    KeySchemaElement.builder()\n                        .attributeName(Accounts.ATTR_USERNAME_LINK_UUID)\n                        .keyType(KeyType.HASH)\n                        .build()\n                )\n                .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build())\n                .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build())\n                .build()\n        ),\n        List.of()),\n\n    BACKUPS(\"backups_test\",\n        BackupsDb.KEY_BACKUP_ID_HASH,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(BackupsDb.KEY_BACKUP_ID_HASH)\n            .attributeType(ScalarAttributeType.B).build()),\n        Collections.emptyList(), Collections.emptyList()),\n\n    CLIENT_RELEASES(\"client_releases_test\",\n        ClientReleases.ATTR_PLATFORM,\n        ClientReleases.ATTR_VERSION,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(ClientReleases.ATTR_PLATFORM)\n                .attributeType(ScalarAttributeType.S)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(ClientReleases.ATTR_VERSION)\n                .attributeType(ScalarAttributeType.S)\n                .build()),\n        List.of(),\n        List.of()),\n\n    DELETED_ACCOUNTS(\"deleted_accounts_test\",\n        Accounts.DELETED_ACCOUNTS_KEY_ACCOUNT_PNI,\n        null,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(Accounts.DELETED_ACCOUNTS_KEY_ACCOUNT_PNI)\n                .attributeType(ScalarAttributeType.S).build(),\n            AttributeDefinition.builder()\n                .attributeName(Accounts.DELETED_ACCOUNTS_ATTR_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(\n            GlobalSecondaryIndex.builder()\n                .indexName(Accounts.DELETED_ACCOUNTS_UUID_TO_PNI_INDEX_NAME)\n                .keySchema(\n                    KeySchemaElement.builder().attributeName(Accounts.DELETED_ACCOUNTS_ATTR_ACCOUNT_UUID).keyType(KeyType.HASH).build()\n                )\n                .projection(Projection.builder().projectionType(ProjectionType.KEYS_ONLY).build())\n                .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build())\n                .build()),\n        List.of()\n    ),\n\n    DELETED_ACCOUNTS_LOCK(\"deleted_accounts_lock_test\",\n        AccountLockManager.KEY_ACCOUNT_PNI,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(AccountLockManager.KEY_ACCOUNT_PNI)\n            .attributeType(ScalarAttributeType.S).build()),\n        List.of(), List.of()),\n\n    NUMBERS(\"numbers_test\",\n        Accounts.ATTR_ACCOUNT_E164,\n        null,\n        List.of(AttributeDefinition.builder()\n              .attributeName(Accounts.ATTR_ACCOUNT_E164)\n              .attributeType(ScalarAttributeType.S)\n            .build()),\n        List.of(), List.of()),\n\n    EC_KEYS(\"keys_test\",\n        SingleUseECPreKeyStore.KEY_ACCOUNT_UUID,\n        SingleUseECPreKeyStore.KEY_DEVICE_ID_KEY_ID,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(SingleUseECPreKeyStore.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(SingleUseECPreKeyStore.KEY_DEVICE_ID_KEY_ID)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(), List.of()),\n\n    PAGED_PQ_KEYS(\"paged_pq_keys_test\",\n        PagedSingleUseKEMPreKeyStore.KEY_ACCOUNT_UUID,\n        PagedSingleUseKEMPreKeyStore.KEY_DEVICE_ID,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(PagedSingleUseKEMPreKeyStore.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(PagedSingleUseKEMPreKeyStore.KEY_DEVICE_ID)\n                .attributeType(ScalarAttributeType.N)\n                .build()),\n        List.of(), List.of()),\n\n    PUSH_NOTIFICATION_EXPERIMENT_SAMPLES(\"push_notification_experiment_samples_test\",\n        PushNotificationExperimentSamples.KEY_EXPERIMENT_NAME,\n        PushNotificationExperimentSamples.ATTR_ACI_AND_DEVICE_ID,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(PushNotificationExperimentSamples.KEY_EXPERIMENT_NAME)\n                .attributeType(ScalarAttributeType.S)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(PushNotificationExperimentSamples.ATTR_ACI_AND_DEVICE_ID)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(), List.of()),\n\n    REPEATED_USE_EC_SIGNED_PRE_KEYS(\"repeated_use_signed_ec_pre_keys_test\",\n        RepeatedUseSignedPreKeyStore.KEY_ACCOUNT_UUID,\n        RepeatedUseSignedPreKeyStore.KEY_DEVICE_ID,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(RepeatedUseSignedPreKeyStore.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(RepeatedUseSignedPreKeyStore.KEY_DEVICE_ID)\n                .attributeType(ScalarAttributeType.N)\n                .build()),\n        List.of(), List.of()),\n\n    REPEATED_USE_KEM_SIGNED_PRE_KEYS(\"repeated_use_signed_kem_pre_keys_test\",\n        RepeatedUseSignedPreKeyStore.KEY_ACCOUNT_UUID,\n        RepeatedUseSignedPreKeyStore.KEY_DEVICE_ID,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(RepeatedUseSignedPreKeyStore.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(RepeatedUseSignedPreKeyStore.KEY_DEVICE_ID)\n                .attributeType(ScalarAttributeType.N)\n                .build()),\n        List.of(), List.of()),\n\n    PNI(\"pni_test\",\n        PhoneNumberIdentifiers.KEY_E164,\n        null,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(PhoneNumberIdentifiers.KEY_E164)\n                .attributeType(ScalarAttributeType.S)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(PhoneNumberIdentifiers.ATTR_PHONE_NUMBER_IDENTIFIER)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(GlobalSecondaryIndex.builder()\n            .indexName(PhoneNumberIdentifiers.INDEX_NAME)\n            .projection(Projection.builder()\n                .projectionType(ProjectionType.KEYS_ONLY)\n                .build())\n            .keySchema(KeySchemaElement.builder().keyType(KeyType.HASH)\n                .attributeName(PhoneNumberIdentifiers.ATTR_PHONE_NUMBER_IDENTIFIER)\n                .build())\n            .provisionedThroughput(ProvisionedThroughput.builder().readCapacityUnits(10L).writeCapacityUnits(10L).build())\n            .build()),\n        List.of()),\n\n    PNI_ASSIGNMENTS(\"pni_assignment_test\",\n        Accounts.ATTR_PNI_UUID,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(Accounts.ATTR_PNI_UUID)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(), List.of()),\n\n    ISSUED_RECEIPTS(\"issued_receipts_test\",\n        IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID)\n            .attributeType(ScalarAttributeType.S)\n            .build()),\n        List.of(), List.of()),\n\n    MESSAGES(\"messages_test\",\n        MessagesDynamoDb.KEY_PARTITION,\n        MessagesDynamoDb.KEY_SORT,\n        List.of(\n            AttributeDefinition.builder().attributeName(MessagesDynamoDb.KEY_PARTITION).attributeType(ScalarAttributeType.B).build(),\n            AttributeDefinition.builder().attributeName(MessagesDynamoDb.KEY_SORT).attributeType(ScalarAttributeType.B).build()),\n        List.of(), List.of()),\n\n    ONETIME_DONATIONS(\"onetime_donations_test\",\n        OneTimeDonationsManager.KEY_PAYMENT_ID,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(OneTimeDonationsManager.KEY_PAYMENT_ID)\n            .attributeType(ScalarAttributeType.S)\n            .build()),\n        List.of(), List.of()),\n\n    PROFILES(\"profiles_test\",\n        Profiles.KEY_ACCOUNT_UUID,\n        Profiles.ATTR_VERSION,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(Profiles.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(Profiles.ATTR_VERSION)\n                .attributeType(ScalarAttributeType.S)\n                .build()),\n        List.of(), List.of()),\n\n    PUSH_CHALLENGES(\"push_challenge_test\",\n        PushChallengeDynamoDb.KEY_ACCOUNT_UUID,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(PushChallengeDynamoDb.KEY_ACCOUNT_UUID)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(), List.of()),\n\n    REDEEMED_RECEIPTS(\"redeemed_receipts_test\",\n        RedeemedReceiptsManager.KEY_SERIAL,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(RedeemedReceiptsManager.KEY_SERIAL)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(), List.of()),\n\n    REGISTRATION_RECOVERY_PASSWORDS(\"registration_recovery_passwords_test\",\n        RegistrationRecoveryPasswords.KEY_PNI,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(RegistrationRecoveryPasswords.KEY_PNI)\n            .attributeType(ScalarAttributeType.S)\n            .build()),\n        List.of(), List.of()),\n\n    REMOTE_CONFIGS(\"remote_configs_test\",\n        RemoteConfigs.KEY_NAME,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(RemoteConfigs.KEY_NAME)\n            .attributeType(ScalarAttributeType.S)\n            .build()),\n        List.of(), List.of()),\n\n    REPORT_MESSAGES(\"report_messages_test\",\n        ReportMessageDynamoDb.KEY_HASH,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(ReportMessageDynamoDb.KEY_HASH)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(), List.of()),\n\n    SCHEDULED_JOBS(\"scheduled_jobs_test\",\n        JobScheduler.KEY_SCHEDULER_NAME,\n        JobScheduler.ATTR_RUN_AT,\n        List.of(AttributeDefinition.builder()\n                .attributeName(JobScheduler.KEY_SCHEDULER_NAME)\n                .attributeType(ScalarAttributeType.S)\n                .build(),\n\n            AttributeDefinition.builder()\n                .attributeName(JobScheduler.ATTR_RUN_AT)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(),\n        List.of()),\n\n    SUBSCRIPTIONS(\"subscriptions_test\",\n        Subscriptions.KEY_USER,\n        null,\n        List.of(\n            AttributeDefinition.builder()\n                .attributeName(Subscriptions.KEY_USER)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(GlobalSecondaryIndex.builder()\n            .indexName(Subscriptions.INDEX_NAME)\n            .keySchema(KeySchemaElement.builder()\n                .attributeName(Subscriptions.KEY_PROCESSOR_ID_CUSTOMER_ID)\n                .keyType(KeyType.HASH)\n                .build())\n            .projection(Projection.builder()\n                .projectionType(ProjectionType.KEYS_ONLY)\n                .build())\n            .provisionedThroughput(ProvisionedThroughput.builder()\n                .readCapacityUnits(20L)\n                .writeCapacityUnits(20L)\n                .build())\n            .build()),\n        List.of()),\n\n    USED_LINK_DEVICE_TOKENS(\"used_link_device_tokens_test\",\n        Accounts.KEY_LINK_DEVICE_TOKEN_HASH,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(Accounts.KEY_LINK_DEVICE_TOKEN_HASH)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(),\n        List.of()),\n\n    USERNAMES(\"usernames_test\",\n        Accounts.ATTR_USERNAME_HASH,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(Accounts.ATTR_USERNAME_HASH)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(), List.of()),\n\n    VERIFICATION_SESSIONS(\"verification_sessions_test\",\n        VerificationSessions.KEY_KEY,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(VerificationSessions.KEY_KEY)\n            .attributeType(ScalarAttributeType.S)\n            .build()),\n        List.of(), List.of()),\n\n    APPLE_DEVICE_CHECKS(\"apple_device_check\",\n        AppleDeviceChecks.KEY_ACCOUNT_UUID,\n        AppleDeviceChecks.KEY_PUBLIC_KEY_ID,\n        List.of(AttributeDefinition.builder()\n                .attributeName(AppleDeviceChecks.KEY_ACCOUNT_UUID)\n                .attributeType(ScalarAttributeType.B)\n                .build(),\n            AttributeDefinition.builder()\n                .attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY_ID)\n                .attributeType(ScalarAttributeType.B)\n                .build()),\n        List.of(), List.of()),\n\n    APPLE_DEVICE_CHECKS_KEY_CONSTRAINT(\"apple_device_check_key_constraint\",\n        AppleDeviceChecks.KEY_PUBLIC_KEY,\n        null,\n        List.of(AttributeDefinition.builder()\n            .attributeName(AppleDeviceChecks.KEY_PUBLIC_KEY)\n            .attributeType(ScalarAttributeType.B)\n            .build()),\n        List.of(), List.of());\n\n    private final String tableName;\n    private final String hashKeyName;\n    private final String rangeKeyName;\n    private final List<AttributeDefinition> attributeDefinitions;\n    private final List<GlobalSecondaryIndex> globalSecondaryIndexes;\n    private final List<LocalSecondaryIndex> localSecondaryIndexes;\n\n    Tables(\n        final String tableName,\n        final String hashKeyName,\n        final String rangeKeyName,\n        final List<AttributeDefinition> attributeDefinitions,\n        final List<GlobalSecondaryIndex> globalSecondaryIndexes,\n        final List<LocalSecondaryIndex> localSecondaryIndexes\n    ) {\n      this.tableName = tableName;\n      this.hashKeyName = hashKeyName;\n      this.rangeKeyName = rangeKeyName;\n      this.attributeDefinitions = attributeDefinitions;\n      this.globalSecondaryIndexes = globalSecondaryIndexes;\n      this.localSecondaryIndexes = localSecondaryIndexes;\n    }\n\n    public String tableName() {\n      return tableName;\n    }\n\n    public String hashKeyName() {\n      return hashKeyName;\n    }\n\n    public String rangeKeyName() {\n      return rangeKeyName;\n    }\n\n    public List<AttributeDefinition> attributeDefinitions() {\n      return attributeDefinitions;\n    }\n\n    public List<GlobalSecondaryIndex> globalSecondaryIndexes() {\n      return globalSecondaryIndexes;\n    }\n\n    public List<LocalSecondaryIndex> localSecondaryIndexes() {\n      return localSecondaryIndexes;\n    }\n\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/EnvelopeUtilTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport java.util.UUID;\nimport java.util.concurrent.ThreadLocalRandom;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.grpc.ServiceIdentifierUtil;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass EnvelopeUtilTest {\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void compressExpand(final boolean includeBinaryServiceIdentifiers) {\n    final ExperimentEnrollmentManager experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);\n    when(experimentEnrollmentManager.isEnrolled(any(UUID.class), eq(EnvelopeUtil.INCLUDE_BINARY_SERVICE_ID_EXPERIMENT_NAME)))\n        .thenReturn(includeBinaryServiceIdentifiers);\n\n    {\n      final MessageProtos.Envelope compressibleFieldsNullMessage = generateRandomMessageBuilder().build();\n      final MessageProtos.Envelope compressed = EnvelopeUtil.compress(compressibleFieldsNullMessage);\n\n      assertFalse(compressed.hasSourceServiceId());\n      assertFalse(compressed.hasSourceServiceIdBinary());\n      assertFalse(compressed.hasDestinationServiceId());\n      assertFalse(compressed.hasDestinationServiceIdBinary());\n      assertFalse(compressed.hasServerGuid());\n      assertFalse(compressed.hasServerGuidBinary());\n      assertFalse(compressed.hasUpdatedPni());\n      assertFalse(compressed.hasUpdatedPniBinary());\n\n      final MessageProtos.Envelope expanded = EnvelopeUtil.expand(compressed, experimentEnrollmentManager);\n\n      assertFalse(expanded.hasSourceServiceId());\n      assertFalse(expanded.hasSourceServiceIdBinary());\n      assertFalse(expanded.hasDestinationServiceId());\n      assertFalse(expanded.hasDestinationServiceIdBinary());\n      assertFalse(expanded.hasServerGuid());\n      assertFalse(expanded.hasServerGuidBinary());\n      assertFalse(compressed.hasUpdatedPni());\n      assertFalse(compressed.hasUpdatedPniBinary());\n    }\n\n    {\n      final ServiceIdentifier sourceServiceId = generateRandomServiceIdentifier();\n      final ServiceIdentifier destinationServiceId = generateRandomServiceIdentifier();\n      final UUID serverGuid = UUID.randomUUID();\n      final UUID updatedPni = UUID.randomUUID();\n\n      final MessageProtos.Envelope compressibleFieldsExpandedMessage = generateRandomMessageBuilder()\n          .setSourceServiceId(sourceServiceId.toServiceIdentifierString())\n          .setDestinationServiceId(destinationServiceId.toServiceIdentifierString())\n          .setServerGuid(serverGuid.toString())\n          .setUpdatedPni(updatedPni.toString())\n          .build();\n\n      final MessageProtos.Envelope compressed = EnvelopeUtil.compress(compressibleFieldsExpandedMessage);\n\n      assertFalse(compressed.hasSourceServiceId());\n      assertEquals(ServiceIdentifierUtil.toCompactByteString(sourceServiceId), compressed.getSourceServiceIdBinary());\n      assertFalse(compressed.hasDestinationServiceId());\n      assertEquals(ServiceIdentifierUtil.toCompactByteString(destinationServiceId), compressed.getDestinationServiceIdBinary());\n      assertFalse(compressed.hasServerGuid());\n      assertEquals(UUIDUtil.toByteString(serverGuid), compressed.getServerGuidBinary());\n      assertFalse(compressed.hasUpdatedPni());\n      assertEquals(UUIDUtil.toByteString(updatedPni), compressed.getUpdatedPniBinary());\n\n      assertEquals(compressed, EnvelopeUtil.compress(compressed), \"Double compression should make no changes\");\n\n      final MessageProtos.Envelope expanded = EnvelopeUtil.expand(compressed, experimentEnrollmentManager);\n\n      assertEquals(sourceServiceId.toServiceIdentifierString(), expanded.getSourceServiceId());\n      assertEquals(destinationServiceId.toServiceIdentifierString(), expanded.getDestinationServiceId());\n      assertEquals(serverGuid.toString(), expanded.getServerGuid());\n      assertEquals(updatedPni.toString(), expanded.getUpdatedPni());\n      assertEquals(UUIDUtil.toByteString(updatedPni), expanded.getUpdatedPniBinary());\n\n      if (includeBinaryServiceIdentifiers) {\n        assertEquals(ServiceIdentifierUtil.toCompactByteString(sourceServiceId), expanded.getSourceServiceIdBinary());\n        assertEquals(ServiceIdentifierUtil.toCompactByteString(destinationServiceId), expanded.getDestinationServiceIdBinary());\n        assertEquals(UUIDUtil.toByteString(serverGuid), expanded.getServerGuidBinary());\n      } else {\n        assertFalse(expanded.hasSourceServiceIdBinary());\n        assertFalse(expanded.hasDestinationServiceIdBinary());\n        assertFalse(expanded.hasServerGuidBinary());\n      }\n\n      assertEquals(expanded, EnvelopeUtil.expand(expanded, experimentEnrollmentManager),\n          \"Double expansion should make no changes\");\n\n      // Expanded envelopes include both representations of the `updatedPni` field\n      assertTrue(expanded.hasUpdatedPni());\n      assertTrue(expanded.hasUpdatedPniBinary());\n      assertEquals(UUID.fromString(expanded.getUpdatedPni()), UUIDUtil.fromByteString(expanded.getUpdatedPniBinary()));\n    }\n  }\n\n  private static ServiceIdentifier generateRandomServiceIdentifier() {\n    final IdentityType identityType = ThreadLocalRandom.current().nextBoolean() ? IdentityType.ACI : IdentityType.PNI;\n\n    return switch (identityType) {\n      case ACI -> new AciServiceIdentifier(UUID.randomUUID());\n      case PNI -> new PniServiceIdentifier(UUID.randomUUID());\n    };\n  }\n\n  private MessageProtos.Envelope.Builder generateRandomMessageBuilder() {\n    return MessageProtos.Envelope.newBuilder()\n        .setClientTimestamp(ThreadLocalRandom.current().nextLong())\n        .setServerTimestamp(ThreadLocalRandom.current().nextLong())\n        .setContent(ByteString.copyFrom(TestRandomUtil.nextBytes(256)))\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/FoundationDbClusterExtension.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.apple.foundationdb.Database;\nimport com.apple.foundationdb.FDB;\nimport org.junit.jupiter.api.extension.BeforeAllCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\n\nimport java.io.IOException;\n\npublic class FoundationDbClusterExtension implements BeforeAllCallback, ExtensionContext.Store.CloseableResource {\n\n  private FoundationDbDatabaseLifecycleManager[] databaseLifecycleManagers;\n  private Database[] databases;\n\n  public FoundationDbClusterExtension(final int numInstances) {\n    this.databaseLifecycleManagers = new FoundationDbDatabaseLifecycleManager[numInstances];\n    this.databases = new Database[numInstances];\n  }\n\n  @Override\n  public void beforeAll(final ExtensionContext context) throws IOException {\n    if (databaseLifecycleManagers[0] == null) {\n      final String serviceContainerNamePrefix = System.getProperty(\"foundationDb.serviceContainerNamePrefix\");\n\n      for (int i = 0; i < databaseLifecycleManagers.length; i++) {\n        final FoundationDbDatabaseLifecycleManager databaseLifecycleManager = serviceContainerNamePrefix != null\n                ? new ServiceContainerFoundationDbDatabaseLifecycleManager(serviceContainerNamePrefix + i)\n                : new TestcontainersFoundationDbDatabaseLifecycleManager();\n        databaseLifecycleManager.initializeDatabase(FDB.selectAPIVersion(FoundationDbVersion.getFoundationDbApiVersion()));\n        databaseLifecycleManagers[i] = databaseLifecycleManager;\n        databases[i] = databaseLifecycleManager.getDatabase();\n      }\n\n    }\n  }\n\n  public Database[] getDatabases() {\n    return databases;\n  }\n\n  @Override\n  public void close() throws Throwable {\n    if (databaseLifecycleManagers[0] != null) {\n      for (final FoundationDbDatabaseLifecycleManager databaseLifecycleManager : databaseLifecycleManagers) {\n        databaseLifecycleManager.closeDatabase();\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/FoundationDbDatabaseLifecycleManager.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.apple.foundationdb.Database;\nimport com.apple.foundationdb.FDB;\nimport java.io.IOException;\n\ninterface FoundationDbDatabaseLifecycleManager {\n\n  void initializeDatabase(final FDB fdb) throws IOException;\n\n  Database getDatabase();\n\n  void closeDatabase();\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/IssuedReceiptsManagerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.ws.rs.ClientErrorException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.EnumMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport org.assertj.core.api.Condition;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequest;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemResponse;\n\nclass IssuedReceiptsManagerTest {\n\n  private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.ISSUED_RECEIPTS);\n\n  private static EnumMap<PaymentProvider, Integer> MAX_TAGS_MAP = new EnumMap<>(Map.of(\n      PaymentProvider.STRIPE, 1,\n      PaymentProvider.BRAINTREE, 2,\n      PaymentProvider.GOOGLE_PLAY_BILLING, 3,\n      PaymentProvider.APPLE_APP_STORE, 4));\n\n  private IssuedReceiptsManager issuedReceiptsManager;\n\n  @BeforeEach\n  void beforeEach() {\n    issuedReceiptsManager = new IssuedReceiptsManager(\n        Tables.ISSUED_RECEIPTS.tableName(),\n        Duration.ofDays(90),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        TestRandomUtil.nextBytes(16),\n        MAX_TAGS_MAP);\n  }\n\n  @Test\n  void testRecordIssuance() {\n    Instant now = Instant.ofEpochSecond(NOW_EPOCH_SECONDS);\n    final ReceiptCredentialRequest receiptCredentialRequest = randomReceiptCredentialRequest();\n    CompletableFuture<Void> future = issuedReceiptsManager.recordIssuance(\"item-1\", PaymentProvider.STRIPE,\n        receiptCredentialRequest, now);\n    assertThat(future).succeedsWithin(Duration.ofSeconds(3));\n\n    final Map<String, AttributeValue> item = getItem(PaymentProvider.STRIPE, \"item-1\").item();\n    final Set<byte[]> tagSet = item.get(IssuedReceiptsManager.KEY_ISSUED_RECEIPT_TAG_SET).bs()\n        .stream()\n        .map(SdkBytes::asByteArray)\n        .collect(Collectors.toSet());\n    assertThat(tagSet).containsExactly(issuedReceiptsManager.generateIssuedReceiptTag(receiptCredentialRequest));\n\n    // same request should succeed\n    future = issuedReceiptsManager.recordIssuance(\"item-1\", PaymentProvider.STRIPE, receiptCredentialRequest,\n        now);\n    assertThat(future).succeedsWithin(Duration.ofSeconds(3));\n\n    // same item with new request should fail\n    byte[] request2 = TestRandomUtil.nextBytes(20);\n    when(receiptCredentialRequest.serialize()).thenReturn(request2);\n    future = issuedReceiptsManager.recordIssuance(\"item-1\", PaymentProvider.STRIPE, receiptCredentialRequest,\n        now);\n    assertThat(future).failsWithin(Duration.ofSeconds(3)).\n        withThrowableOfType(Throwable.class).\n        havingCause().\n        isExactlyInstanceOf(ClientErrorException.class).\n        has(new Condition<>(\n            e -> e instanceof ClientErrorException && ((ClientErrorException) e).getResponse().getStatus() == 409,\n            \"status 409\"));\n\n    // different item with new request should be okay though\n    future = issuedReceiptsManager.recordIssuance(\"item-2\", PaymentProvider.STRIPE, receiptCredentialRequest,\n        now);\n    assertThat(future).succeedsWithin(Duration.ofSeconds(3));\n  }\n\n  @ParameterizedTest\n  @EnumSource(PaymentProvider.class)\n  void testIssueMax(PaymentProvider processor) {\n    final Instant now = Instant.ofEpochSecond(NOW_EPOCH_SECONDS);\n\n    final int maxTags = MAX_TAGS_MAP.get(processor);\n    final List<ReceiptCredentialRequest> requests = IntStream.range(0, maxTags)\n        .mapToObj(i -> randomReceiptCredentialRequest())\n        .toList();\n    for (int i = 0; i < maxTags; i++) {\n      // Should be allowed to insert up to maxTags\n        assertThat(issuedReceiptsManager.recordIssuance(\"item-1\", processor, requests.get(i), now))\n            .succeedsWithin(Duration.ofSeconds(3));\n      for (int j = 0; j < i; j++) {\n        // Also should be allowed to repeat any previous tag\n        assertThat(issuedReceiptsManager.recordIssuance(\"item-1\", processor, requests.get(j), now))\n            .succeedsWithin(Duration.ofSeconds(3));\n      }\n    }\n\n    assertThat(getItem(processor, \"item-1\").item().get(IssuedReceiptsManager.KEY_ISSUED_RECEIPT_TAG_SET).bs()\n        .stream()\n        .map(SdkBytes::asByteArray)\n        .collect(Collectors.toSet()))\n        .containsExactlyInAnyOrder(requests.stream()\n            .map(issuedReceiptsManager::generateIssuedReceiptTag)\n            .toArray(byte[][]::new));\n\n    // Should not be allowed to insert past maxTags\n    assertThat(issuedReceiptsManager.recordIssuance(\"item-1\", processor, randomReceiptCredentialRequest(), now))\n        .failsWithin(Duration.ofSeconds(3))\n        .withThrowableOfType(Throwable.class)\n        .havingCause()\n        .isExactlyInstanceOf(ClientErrorException.class)\n        .has(new Condition<>(\n            e -> e instanceof ClientErrorException && ((ClientErrorException) e).getResponse().getStatus() == 409,\n            \"status 409\"));\n  }\n\n\n  private GetItemResponse getItem(final PaymentProvider processor, final String itemId) {\n    final DynamoDbClient client = DYNAMO_DB_EXTENSION.getDynamoDbClient();\n    return client.getItem(GetItemRequest.builder()\n        .tableName(Tables.ISSUED_RECEIPTS.tableName())\n        .key(Map.of(IssuedReceiptsManager.KEY_PROCESSOR_ITEM_ID, IssuedReceiptsManager.dynamoDbKey(processor, itemId)))\n        .build());\n  }\n\n  private static ReceiptCredentialRequest randomReceiptCredentialRequest() {\n    final ReceiptCredentialRequest request = mock(ReceiptCredentialRequest.class);\n    final byte[] bytes = TestRandomUtil.nextBytes(20);\n    when(request.serialize()).thenReturn(bytes);\n    return request;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/KEMPreKeyPageTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport java.nio.ByteBuffer;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.List;\nimport org.junit.jupiter.api.Test;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nclass KEMPreKeyPageTest {\n\n  private static final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n\n  @Test\n  void serializeSinglePreKey() {\n    final ByteBuffer page = KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, List.of(generatePreKey(5)));\n    final int actualMagic = page.getInt();\n    assertEquals(KEMPreKeyPage.HEADER_MAGIC, actualMagic);\n    final int version = page.getInt();\n    assertEquals(1, version);\n    assertEquals(KEMPreKeyPage.SERIALIZED_PREKEY_LENGTH, page.remaining());\n  }\n\n  @Test\n  void emptyPreKeys() {\n    assertThrows(IllegalArgumentException.class, () -> KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, Collections.emptyList()));\n  }\n\n  @Test\n  void roundTripSingleton() throws InvalidKeyException {\n    final KEMSignedPreKey preKey = generatePreKey(5);\n    final ByteBuffer buffer = KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, List.of(preKey));\n    final long serializedLength = buffer.remaining();\n    assertEquals(KEMPreKeyPage.HEADER_SIZE + KEMPreKeyPage.SERIALIZED_PREKEY_LENGTH, serializedLength);\n\n    final KEMPreKeyPage.KeyLocation keyLocation = KEMPreKeyPage.keyLocation(1, 0);\n    assertEquals(KEMPreKeyPage.HEADER_SIZE, keyLocation.getStartInclusive());\n    assertEquals(serializedLength, KEMPreKeyPage.HEADER_SIZE + keyLocation.length());\n\n    buffer.position(keyLocation.getStartInclusive());\n    final KEMSignedPreKey deserializedPreKey = KEMPreKeyPage.deserializeKey(1, buffer);\n\n    assertEquals(5L, deserializedPreKey.keyId());\n    assertEquals(preKey, deserializedPreKey);\n  }\n\n  @Test\n  void roundTripMultiple() throws InvalidKeyException {\n    final List<KEMSignedPreKey> keys = Arrays.asList(generatePreKey(1), generatePreKey(2), generatePreKey(5));\n    final ByteBuffer page = KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, keys);\n\n    assertEquals(KEMPreKeyPage.HEADER_SIZE + KEMPreKeyPage.SERIALIZED_PREKEY_LENGTH * 3, page.remaining());\n\n    for (int i = 0; i < keys.size(); i++) {\n      final KEMPreKeyPage.KeyLocation keyLocation = KEMPreKeyPage.keyLocation(1, i);\n      assertEquals(\n          KEMPreKeyPage.HEADER_SIZE + KEMPreKeyPage.SERIALIZED_PREKEY_LENGTH * i,\n          keyLocation.getStartInclusive());\n      final ByteBuffer buf = page.slice(keyLocation.getStartInclusive(), keyLocation.length());\n      final KEMSignedPreKey actual = KEMPreKeyPage.deserializeKey(1, buf);\n      assertEquals(keys.get(i), actual);\n    }\n  }\n\n  @Test\n  void wrongFormat() {\n    assertThrows(IllegalArgumentException.class, () ->\n        KEMPreKeyPage.deserializeKey(2,\n            ByteBuffer.allocate(KEMPreKeyPage.HEADER_SIZE + KEMPreKeyPage.SERIALIZED_PREKEY_LENGTH)));\n  }\n\n  @Test\n  void wrongSize() {\n    assertThrows(IllegalArgumentException.class, () -> KEMPreKeyPage.deserializeKey(1, ByteBuffer.allocate(100)));\n  }\n\n\n  @Test\n  void negativeKeyId() throws InvalidKeyException {\n    final KEMSignedPreKey preKey = generatePreKey(-1);\n    ByteBuffer page = KEMPreKeyPage.serialize(KEMPreKeyPage.FORMAT, List.of(preKey));\n    page.position(KEMPreKeyPage.HEADER_SIZE);\n    KEMSignedPreKey deserializedPreKey = KEMPreKeyPage.deserializeKey(1, page);\n    assertEquals(-1L, deserializedPreKey.keyId());\n  }\n\n  private static KEMSignedPreKey generatePreKey(long keyId) {\n    return KeysHelper.signedKEMPreKey((int) keyId, IDENTITY_KEY_PAIR);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/KeysManagerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\n\nclass KeysManagerTest {\n\n  private KeysManager keysManager;\n\n  private PagedSingleUseKEMPreKeyStore pagedSingleUseKEMPreKeyStore;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      Tables.EC_KEYS, Tables.PAGED_PQ_KEYS,\n      Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS, Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  @RegisterExtension\n  static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension(\"testbucket\");\n\n  private static final UUID ACCOUNT_UUID = UUID.randomUUID();\n  private static final AciServiceIdentifier ACI_SERVICE_IDENTIFIER = new AciServiceIdentifier(ACCOUNT_UUID);\n  private static final byte DEVICE_ID = 1;\n\n  private static final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n\n  @BeforeEach\n  void setup() {\n    final DynamoDbAsyncClient dynamoDbAsyncClient = DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient();\n    pagedSingleUseKEMPreKeyStore = new PagedSingleUseKEMPreKeyStore(dynamoDbAsyncClient,\n        S3_EXTENSION.getS3Client(),\n        DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),\n        S3_EXTENSION.getBucketName());\n\n    keysManager = new KeysManager(\n        new SingleUseECPreKeyStore(dynamoDbAsyncClient, Tables.EC_KEYS.tableName()),\n        pagedSingleUseKEMPreKeyStore,\n        new RepeatedUseECSignedPreKeyStore(dynamoDbAsyncClient, Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName()),\n        new RepeatedUseKEMSignedPreKeyStore(dynamoDbAsyncClient, Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName()));\n  }\n\n  @Test\n  void storeEcOneTimePreKeys() {\n    assertEquals(0, keysManager.getEcCount(ACCOUNT_UUID, DEVICE_ID).join(),\n        \"Initial pre-key count for an account should be zero\");\n\n    keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestPreKey(1))).join();\n    assertEquals(1, keysManager.getEcCount(ACCOUNT_UUID, DEVICE_ID).join());\n\n    keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestPreKey(1))).join();\n    assertEquals(1, keysManager.getEcCount(ACCOUNT_UUID, DEVICE_ID).join(),\n        \"Repeatedly storing same key should have no effect\");\n  }\n\n  @Test\n  void storeKemOneTimePreKeys() {\n    assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join(),\n        \"Initial pre-key count for an account should be zero\");\n\n    keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();\n    assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(1, pagedSingleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());\n\n    keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();\n    assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(1, pagedSingleUseKEMPreKeyStore.getCount(ACCOUNT_UUID, DEVICE_ID).join());\n  }\n\n\n  @Test\n  void storeEcSignedPreKeys() {\n    assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, DEVICE_ID).join().isEmpty());\n\n    final ECSignedPreKey signedPreKey = generateTestECSignedPreKey(1);\n\n    keysManager.storeEcSignedPreKeys(ACCOUNT_UUID, DEVICE_ID, signedPreKey).join();\n\n    assertEquals(Optional.of(signedPreKey), keysManager.getEcSignedPreKey(ACCOUNT_UUID, DEVICE_ID).join());\n  }\n\n  @Test\n  void testTakeAccountAndDeviceId() {\n    assertEquals(Optional.empty(), keysManager.takeEC(ACCOUNT_UUID, DEVICE_ID).join());\n\n    final ECPreKey preKey = generateTestPreKey(1);\n\n    keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(preKey, generateTestPreKey(2))).join();\n\n    final Optional<ECPreKey> takenKey = keysManager.takeEC(ACCOUNT_UUID, DEVICE_ID).join();\n    assertEquals(Optional.of(preKey), takenKey);\n    assertEquals(1, keysManager.getEcCount(ACCOUNT_UUID, DEVICE_ID).join());\n  }\n\n  @Test\n  void testTakePQ() {\n    assertEquals(Optional.empty(), keysManager.takeEC(ACCOUNT_UUID, DEVICE_ID).join());\n\n    final KEMSignedPreKey preKey1 = generateTestKEMSignedPreKey(1);\n    final KEMSignedPreKey preKey2 = generateTestKEMSignedPreKey(2);\n    final KEMSignedPreKey preKeyLast = generateTestKEMSignedPreKey(1001);\n\n    keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(preKey1, preKey2)).join();\n    keysManager.storePqLastResort(ACCOUNT_UUID, DEVICE_ID, preKeyLast).join();\n\n    assertEquals(Optional.of(preKey1), keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n\n    assertEquals(Optional.of(preKey2), keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n\n    assertEquals(Optional.of(preKeyLast), keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n\n    assertEquals(Optional.of(preKeyLast), keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n  }\n\n  @Test\n  void takeWithExistingExperimentalKey() {\n    // Put a key in the new store, even though we're not in the experiment. This simulates a take when operating\n    // in mixed mode on experiment rollout\n    pagedSingleUseKEMPreKeyStore.store(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestKEMSignedPreKey(1))).join();\n\n    assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(1, keysManager.takePQ(ACCOUNT_UUID, DEVICE_ID).join().orElseThrow().keyId());\n    assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n  }\n\n  @Test\n  void testDeleteSingleUsePreKeysByAccount() {\n    int keyId = 1;\n\n    for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {\n      keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestPreKey(keyId++))).join();\n      keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestKEMSignedPreKey(keyId++))).join();\n      keysManager.storeEcSignedPreKeys(ACCOUNT_UUID, deviceId, generateTestECSignedPreKey(keyId++)).join();\n      keysManager.storePqLastResort(ACCOUNT_UUID, deviceId, generateTestKEMSignedPreKey(keyId++)).join();\n    }\n\n    for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {\n      assertEquals(1, keysManager.getEcCount(ACCOUNT_UUID, deviceId).join());\n      assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, deviceId).join());\n      assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, deviceId).join().isPresent());\n      assertTrue(keysManager.getLastResort(ACCOUNT_UUID, deviceId).join().isPresent());\n    }\n\n    keysManager.deleteSingleUsePreKeys(ACCOUNT_UUID).join();\n\n    for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {\n      assertEquals(0, keysManager.getEcCount(ACCOUNT_UUID, deviceId).join());\n      assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, deviceId).join());\n      assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, deviceId).join().isPresent());\n      assertTrue(keysManager.getLastResort(ACCOUNT_UUID, deviceId).join().isPresent());\n    }\n  }\n\n  @Test\n  void testDeleteSingleUsePreKeysByAccountAndDevice() {\n    int keyId = 1;\n\n    for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {\n      keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestPreKey(keyId++))).join();\n      keysManager.storeKemOneTimePreKeys(ACCOUNT_UUID, deviceId, List.of(generateTestKEMSignedPreKey(keyId++))).join();\n      keysManager.storeEcSignedPreKeys(ACCOUNT_UUID, deviceId, generateTestECSignedPreKey(keyId++)).join();\n      keysManager.storePqLastResort(ACCOUNT_UUID, deviceId, generateTestKEMSignedPreKey(keyId++)).join();\n    }\n\n    for (byte deviceId : new byte[] {DEVICE_ID, DEVICE_ID + 1}) {\n      assertEquals(1, keysManager.getEcCount(ACCOUNT_UUID, deviceId).join());\n      assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, deviceId).join());\n      assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, deviceId).join().isPresent());\n      assertTrue(keysManager.getLastResort(ACCOUNT_UUID, deviceId).join().isPresent());\n    }\n\n    keysManager.deleteSingleUsePreKeys(ACCOUNT_UUID, DEVICE_ID).join();\n\n    assertEquals(0, keysManager.getEcCount(ACCOUNT_UUID, DEVICE_ID).join());\n    assertEquals(0, keysManager.getPqCount(ACCOUNT_UUID, DEVICE_ID).join());\n    assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, DEVICE_ID).join().isPresent());\n    assertTrue(keysManager.getLastResort(ACCOUNT_UUID, DEVICE_ID).join().isPresent());\n\n    assertEquals(1, keysManager.getEcCount(ACCOUNT_UUID, (byte) (DEVICE_ID + 1)).join());\n    assertEquals(1, keysManager.getPqCount(ACCOUNT_UUID, (byte) (DEVICE_ID + 1)).join());\n    assertTrue(keysManager.getEcSignedPreKey(ACCOUNT_UUID, (byte) (DEVICE_ID + 1)).join().isPresent());\n    assertTrue(keysManager.getLastResort(ACCOUNT_UUID, (byte) (DEVICE_ID + 1)).join().isPresent());\n  }\n\n  @Test\n  void testStorePqLastResort() {\n    final ECKeyPair identityKeyPair = ECKeyPair.generate();\n\n    final byte deviceId2 = 2;\n    final byte deviceId3 = 3;\n\n    keysManager.storePqLastResort(ACCOUNT_UUID, DEVICE_ID, KeysHelper.signedKEMPreKey(1, identityKeyPair)).join();\n    keysManager.storePqLastResort(ACCOUNT_UUID, (byte) 2, KeysHelper.signedKEMPreKey(2, identityKeyPair)).join();\n\n    assertEquals(1L, keysManager.getLastResort(ACCOUNT_UUID, DEVICE_ID).join().orElseThrow().keyId());\n    assertEquals(2L, keysManager.getLastResort(ACCOUNT_UUID, deviceId2).join().orElseThrow().keyId());\n    assertFalse(keysManager.getLastResort(ACCOUNT_UUID, deviceId3).join().isPresent());\n\n    keysManager.storePqLastResort(ACCOUNT_UUID, DEVICE_ID, KeysHelper.signedKEMPreKey(3, identityKeyPair)).join();\n    keysManager.storePqLastResort(ACCOUNT_UUID, deviceId3, KeysHelper.signedKEMPreKey(4, identityKeyPair)).join();\n\n    assertEquals(3L, keysManager.getLastResort(ACCOUNT_UUID, DEVICE_ID).join().orElseThrow().keyId(),\n        \"storing new last-resort keys should overwrite old ones\");\n    assertEquals(2L, keysManager.getLastResort(ACCOUNT_UUID, deviceId2).join().orElseThrow().keyId(),\n        \"storing new last-resort keys should leave untouched ones alone\");\n    assertEquals(4L, keysManager.getLastResort(ACCOUNT_UUID, deviceId3).join().orElseThrow().keyId(),\n        \"storing new last-resort keys should overwrite old ones\");\n  }\n\n  private enum MissingKeyType {\n    EC,\n    SIGNED_EC,\n    PQ,\n    NONE\n  }\n\n  @ParameterizedTest\n  @EnumSource(MissingKeyType.class)\n  void testTakeWithMissingKeys(final MissingKeyType missingKeyType) {\n    if (missingKeyType != MissingKeyType.PQ) {\n      keysManager.storePqLastResort(ACCOUNT_UUID, DEVICE_ID, generateTestKEMSignedPreKey(1)).join();\n    }\n    if (missingKeyType != MissingKeyType.SIGNED_EC) {\n      keysManager.storeEcSignedPreKeys(ACCOUNT_UUID, DEVICE_ID, generateTestECSignedPreKey(2)).join();\n    }\n    if (missingKeyType != MissingKeyType.EC) {\n      keysManager.storeEcOneTimePreKeys(ACCOUNT_UUID, DEVICE_ID, List.of(generateTestPreKey(3))).join();\n    }\n\n    final Optional<KeysManager.DevicePreKeys> keys =\n        keysManager.takeDevicePreKeys(DEVICE_ID, ACI_SERVICE_IDENTIFIER, null).join();\n\n    assertEquals(keys.isPresent(), switch (missingKeyType) {\n      // We should successfully get keys if every key is present, or if only EC one-time keys are missing\n      case EC, NONE -> true;\n      // If the signed EC key or the last-resort PQ key is missing, we shouldn't get keys back\n      case SIGNED_EC, PQ -> false;\n    });\n\n    final boolean hasEcPreKey = keys.flatMap(KeysManager.DevicePreKeys::ecPreKey).isPresent();\n    assertEquals(hasEcPreKey, missingKeyType == MissingKeyType.NONE);\n  }\n\n  private static ECPreKey generateTestPreKey(final long keyId) {\n    return new ECPreKey(keyId, ECKeyPair.generate().getPublicKey());\n  }\n\n  private static ECSignedPreKey generateTestECSignedPreKey(final long keyId) {\n    return KeysHelper.signedECPreKey(keyId, IDENTITY_KEY_PAIR);\n  }\n\n  private static KEMSignedPreKey generateTestKEMSignedPreKey(final long keyId) {\n    return KeysHelper.signedKEMPreKey(keyId, IDENTITY_KEY_PAIR);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterIntegrationTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.junit.jupiter.api.Assertions.fail;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport io.micrometer.core.instrument.Tags;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.RepeatedTest;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicMessagePersisterConfiguration;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.push.MessageAvailabilityListener;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\n\nclass MessagePersisterIntegrationTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.MESSAGES);\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private Scheduler messageDeliveryScheduler;\n  private Scheduler persistQueueScheduler;\n  private ExecutorService messageDeletionExecutorService;\n  private ExecutorService websocketConnectionEventExecutor;\n  private ExecutorService asyncOperationQueueingExecutor;\n  private MessagesCache messagesCache;\n  private RedisMessageAvailabilityManager redisMessageAvailabilityManager;\n  private MessagePersister messagePersister;\n  private Account account;\n  private ExperimentEnrollmentManager experimentEnrollmentManager;\n\n  private static final Duration PERSIST_DELAY = Duration.ofMinutes(10);\n\n  @BeforeEach\n  void setUp() throws Exception {\n    REDIS_CLUSTER_EXTENSION.getRedisCluster().useCluster(connection -> connection.sync().flushall());\n\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n\n    final DynamicConfiguration dynamicConfiguration = mock(DynamicConfiguration.class);\n\n    when(dynamicConfiguration.getMessagePersisterConfiguration())\n        .thenReturn(new DynamicMessagePersisterConfiguration(true, 1.5, Duration.ofMinutes(5), Duration.ZERO));\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n\n    messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n    persistQueueScheduler = Schedulers.newBoundedElastic(10, 10_000, \"persistQueue\");\n    messageDeletionExecutorService = Executors.newSingleThreadExecutor();\n    experimentEnrollmentManager = mock(ExperimentEnrollmentManager.class);\n    final MessagesDynamoDb messagesDynamoDb = new MessagesDynamoDb(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), Tables.MESSAGES.tableName(), Duration.ofDays(14),\n        messageDeletionExecutorService, experimentEnrollmentManager);\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n\n    messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        messageDeliveryScheduler, messageDeletionExecutorService, mock(ScheduledExecutorService.class), Clock.systemUTC(), experimentEnrollmentManager);\n\n    final MessagesManager messagesManager = new MessagesManager(messagesDynamoDb,\n        messagesCache,\n        mock(RedisMessageAvailabilityManager.class),\n        mock(ReportMessageManager.class),\n        messageDeletionExecutorService,\n        Clock.systemUTC());\n\n    websocketConnectionEventExecutor = Executors.newVirtualThreadPerTaskExecutor();\n    asyncOperationQueueingExecutor = Executors.newSingleThreadExecutor();\n    redisMessageAvailabilityManager = new RedisMessageAvailabilityManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        websocketConnectionEventExecutor,\n        asyncOperationQueueingExecutor);\n\n    redisMessageAvailabilityManager.start();\n\n    messagePersister = new MessagePersister(messagesCache,\n        messagesManager,\n        accountsManager,\n        dynamicConfigurationManager,\n        persistQueueScheduler,\n        Clock.systemUTC(),\n        PERSIST_DELAY,\n        1,\n        1024);\n\n    account = mock(Account.class);\n\n    final UUID accountUuid = UUID.randomUUID();\n\n    when(account.getNumber()).thenReturn(\"+18005551234\");\n    when(account.getUuid()).thenReturn(accountUuid);\n    when(accountsManager.getByAccountIdentifier(accountUuid)).thenReturn(Optional.of(account));\n    when(accountsManager.getByAccountIdentifierAsync(accountUuid)).thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n    when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(DevicesHelper.createDevice(Device.PRIMARY_ID)));\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());\n  }\n\n  @SuppressWarnings(\"ResultOfMethodCallIgnored\")\n  @AfterEach\n  void tearDown() throws Exception {\n    messageDeletionExecutorService.shutdown();\n    messageDeletionExecutorService.awaitTermination(15, TimeUnit.SECONDS);\n\n    websocketConnectionEventExecutor.shutdown();\n    websocketConnectionEventExecutor.awaitTermination(15, TimeUnit.SECONDS);\n\n    asyncOperationQueueingExecutor.shutdown();\n    asyncOperationQueueingExecutor.awaitTermination(15, TimeUnit.SECONDS);\n\n    messageDeliveryScheduler.dispose();\n    persistQueueScheduler.dispose();\n\n    redisMessageAvailabilityManager.stop();\n  }\n\n  @Test\n  void testPersistMessages() throws InterruptedException {\n\n    final int messageCount = 377;\n    final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);\n    final Instant now = Instant.now();\n\n    for (int i = 0; i < messageCount; i++) {\n      final UUID messageGuid = UUID.randomUUID();\n      final long timestamp = now.minus(PERSIST_DELAY.multipliedBy(2)).toEpochMilli() + i;\n\n      final MessageProtos.Envelope message = generateRandomMessage(messageGuid, timestamp, false);\n\n      messagesCache.insert(messageGuid, account.getUuid(), Device.PRIMARY_ID, message).join();\n      expectedMessages.add(message);\n    }\n\n    final CountDownLatch messagesPersistedLatch = new CountDownLatch(1);\n\n    redisMessageAvailabilityManager.handleClientConnected(account.getUuid(), Device.PRIMARY_ID,\n            new MessageAvailabilityListener() {\n              @Override\n              public void handleNewMessageAvailable() {\n              }\n\n              @Override\n              public void handleMessagesPersisted() {\n                messagesPersistedLatch.countDown();\n              }\n\n              @Override\n              public void handleConflictingMessageConsumer() {\n              }\n            })\n        .toCompletableFuture()\n        .join();\n\n    messagePersister.start();\n\n    assertTrue(messagesPersistedLatch.await(15, TimeUnit.SECONDS));\n\n    messagePersister.stop();\n\n    final DynamoDbClient dynamoDB = DYNAMO_DB_EXTENSION.getDynamoDbClient();\n\n    final List<MessageProtos.Envelope> persistedMessages =\n        dynamoDB.scan(ScanRequest.builder().tableName(Tables.MESSAGES.tableName()).build()).items().stream()\n            .map(item -> {\n              try {\n                return MessagesDynamoDb.convertItemToEnvelope(item, experimentEnrollmentManager);\n              } catch (InvalidProtocolBufferException e) {\n                fail(\"Could not parse stored message\", e);\n                return null;\n              }\n            })\n            .toList();\n\n    assertEquals(expectedMessages, persistedMessages);\n  }\n\n  @Test\n  void testPersistFirstPageDiscarded() {\n    final int discardableMessages = MessagePersister.MESSAGE_BATCH_LIMIT * 2;\n    final int persistableMessages = MessagePersister.MESSAGE_BATCH_LIMIT + 1;\n\n    final Instant now = Instant.now();\n\n    for (int i = 0; i < discardableMessages; i++) {\n      final UUID messageGuid = UUID.randomUUID();\n      final long timestamp = now.minus(PERSIST_DELAY.multipliedBy(2)).toEpochMilli() + i;\n\n      final MessageProtos.Envelope message = generateRandomMessage(messageGuid, timestamp, true);\n\n      messagesCache.insert(messageGuid, account.getUuid(), Device.PRIMARY_ID, message).join();\n    }\n\n    final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistableMessages);\n\n    for (int i = 0; i < persistableMessages; i++) {\n      final UUID messageGuid = UUID.randomUUID();\n      final long timestamp = now.minus(PERSIST_DELAY.multipliedBy(2)).toEpochMilli() + i;\n\n      final MessageProtos.Envelope message = generateRandomMessage(messageGuid, timestamp, false);\n\n      messagesCache.insert(messageGuid, account.getUuid(), Device.PRIMARY_ID, message).join();\n      expectedMessages.add(message);\n    }\n\n    messagePersister.persistQueue(account, account.getDevice(Device.PRIMARY_ID).orElseThrow(), Tags.empty()).block();\n\n    final DynamoDbClient dynamoDB = DYNAMO_DB_EXTENSION.getDynamoDbClient();\n\n    final List<MessageProtos.Envelope> persistedMessages =\n        dynamoDB.scan(ScanRequest.builder().tableName(Tables.MESSAGES.tableName()).build()).items().stream()\n            .map(item -> {\n              try {\n                return MessagesDynamoDb.convertItemToEnvelope(item, experimentEnrollmentManager);\n              } catch (InvalidProtocolBufferException e) {\n                fail(\"Could not parse stored message\", e);\n                return null;\n              }\n            })\n            .toList();\n\n    assertEquals(expectedMessages, persistedMessages);\n    assertFalse(messagesCache.hasMessagesAsync(account.getUuid(), Device.PRIMARY_ID).join());\n  }\n\n  private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final long serverTimestamp, final boolean ephemeral) {\n    return MessageProtos.Envelope.newBuilder()\n        .setClientTimestamp(serverTimestamp * 2) // client timestamp may not be accurate\n        .setServerTimestamp(serverTimestamp)\n        .setContent(ByteString.copyFromUtf8(RandomStringUtils.secure().nextAlphanumeric(256)))\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .setServerGuid(messageGuid.toString())\n        .setDestinationServiceId(UUID.randomUUID().toString())\n        .setEphemeral(ephemeral)\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagePersisterTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyList;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.atLeastOnce;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.spy;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.util.MockUtils.exactly;\n\nimport com.google.protobuf.ByteString;\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.cluster.SlotHash;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.micrometer.core.instrument.Tags;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.Stream;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.stubbing.Answer;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicMessagePersisterConfiguration;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.test.StepVerifier;\nimport reactor.util.retry.Retry;\nimport software.amazon.awssdk.services.dynamodb.model.ItemCollectionSizeLimitExceededException;\n\n@Timeout(value = 15, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass MessagePersisterTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private ExecutorService sharedExecutorService;\n  private ScheduledExecutorService resubscribeRetryExecutorService;\n  private Scheduler messageDeliveryScheduler;\n  private Scheduler persistQueueScheduler;\n  private MessagesCache messagesCache;\n  private MessagesDynamoDb messagesDynamoDb;\n  private MessagePersister messagePersister;\n  private AccountsManager accountsManager;\n  private MessagesManager messagesManager;\n  private DynamicConfiguration dynamicConfiguration;\n\n  private Account destinationAccount;\n\n  private Answer<Integer> persistMessagesAnswer;\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  private static final UUID DESTINATION_ACCOUNT_UUID = UUID.randomUUID();\n  private static final String DESTINATION_ACCOUNT_NUMBER = \"+18005551234\";\n  private static final byte DESTINATION_DEVICE_ID = 7;\n  private static final Device DESTINATION_DEVICE = DevicesHelper.createDevice(DESTINATION_DEVICE_ID);\n\n  private static final Duration PERSIST_DELAY = Duration.ofMinutes(5);\n\n  private static final double EXTRA_ROOM_RATIO = 2.0;\n\n  @BeforeEach\n  void setUp() throws Exception {\n\n    messagesManager = mock(MessagesManager.class);\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n\n    messagesDynamoDb = mock(MessagesDynamoDb.class);\n    accountsManager = mock(AccountsManager.class);\n    destinationAccount = mock(Account.class);\n\n    when(accountsManager.getByAccountIdentifier(DESTINATION_ACCOUNT_UUID)).thenReturn(Optional.of(destinationAccount));\n    when(accountsManager.getByAccountIdentifierAsync(DESTINATION_ACCOUNT_UUID))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n    when(accountsManager.removeDevice(any(), anyByte()))\n        .thenAnswer(invocation -> invocation.getArgument(0));\n\n    when(destinationAccount.getUuid()).thenReturn(DESTINATION_ACCOUNT_UUID);\n    when(destinationAccount.getIdentifier(IdentityType.ACI)).thenReturn(DESTINATION_ACCOUNT_UUID);\n    when(destinationAccount.getNumber()).thenReturn(DESTINATION_ACCOUNT_NUMBER);\n    when(destinationAccount.getDevice(DESTINATION_DEVICE_ID)).thenReturn(Optional.of(DESTINATION_DEVICE));\n\n    dynamicConfiguration = mock(DynamicConfiguration.class);\n    when(dynamicConfiguration.getMessagePersisterConfiguration())\n        .thenReturn(new DynamicMessagePersisterConfiguration(true, EXTRA_ROOM_RATIO, Duration.ofHours(1), Duration.ZERO));\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(dynamicConfiguration);\n\n    sharedExecutorService = Executors.newSingleThreadExecutor();\n    resubscribeRetryExecutorService = Executors.newSingleThreadScheduledExecutor();\n    messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n    persistQueueScheduler = Schedulers.newBoundedElastic(10, 10_000, \"persistQueue\");\n    messagesCache = spy(new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        messageDeliveryScheduler, sharedExecutorService, mock(ScheduledExecutorService.class), CLOCK, mock(ExperimentEnrollmentManager.class)));\n    messagePersister = new MessagePersister(messagesCache,\n        messagesManager,\n        accountsManager,\n        dynamicConfigurationManager,\n        persistQueueScheduler,\n        CLOCK,\n        PERSIST_DELAY,\n        1,\n        1024,\n        Retry.backoff(1, Duration.ZERO));\n\n    when(messagesManager.clear(any(UUID.class), anyByte())).thenReturn(CompletableFuture.completedFuture(null));\n\n    persistMessagesAnswer = invocation -> {\n      final UUID destinationUuid = invocation.getArgument(0);\n      final Device destinationDevice = invocation.getArgument(1);\n      final List<MessageProtos.Envelope> messages = invocation.getArgument(2);\n\n      messagesDynamoDb.store(messages, destinationUuid, destinationDevice);\n\n      for (final MessageProtos.Envelope message : messages) {\n        messagesCache.remove(destinationUuid, destinationDevice.getId(), UUID.fromString(message.getServerGuid())).get();\n      }\n\n      return messages.size();\n    };\n\n    when(messagesManager.persistMessages(any(UUID.class), any(), any())).thenAnswer(persistMessagesAnswer);\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    sharedExecutorService.shutdown();\n    //noinspection ResultOfMethodCallIgnored\n    sharedExecutorService.awaitTermination(1, TimeUnit.SECONDS);\n\n    messageDeliveryScheduler.dispose();\n    persistQueueScheduler.dispose();\n\n    resubscribeRetryExecutorService.shutdown();\n    //noinspection ResultOfMethodCallIgnored\n    resubscribeRetryExecutorService.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void persistQueue() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    messagePersister.persistQueue(destinationAccount, destinationAccount.getDevice(DESTINATION_DEVICE_ID).orElseThrow(), Tags.empty())\n        .block();\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<List<MessageProtos.Envelope>> messagesCaptor =\n        ArgumentCaptor.forClass(List.class);\n\n    verify(messagesDynamoDb, atLeastOnce())\n        .store(messagesCaptor.capture(), eq(DESTINATION_ACCOUNT_UUID), eq(DESTINATION_DEVICE));\n\n    assertEquals(messageCount, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum());\n  }\n\n  @Test\n  void persistNextNodeNoQueues() {\n    assertEquals(0, messagePersister.persistNextNode());\n\n    verify(accountsManager, never()).getByAccountIdentifier(any(UUID.class));\n  }\n\n  @Test\n  void persistNodeSingleQueue() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    assertEquals(1, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<List<MessageProtos.Envelope>> messagesCaptor =\n        ArgumentCaptor.forClass(List.class);\n\n    verify(messagesDynamoDb, atLeastOnce())\n        .store(messagesCaptor.capture(), eq(DESTINATION_ACCOUNT_UUID), eq(DESTINATION_DEVICE));\n\n    assertEquals(messageCount, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum());\n  }\n\n  @Test\n  void persistNodeSingleQueueTooSoon() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, CLOCK.instant());\n\n    assertEquals(0, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    verify(messagesDynamoDb, never()).store(any(), any(), any());\n  }\n\n  @Test\n  void testPersistNextQueuesMultiplePages() {\n    final int queueCount = (MessagePersister.QUEUE_BATCH_LIMIT * 3) + 7;\n    final int messagesPerQueue = 10;\n\n    for (int i = 0; i < queueCount; i++) {\n      final UUID accountUuid = UUID.randomUUID();\n      final byte deviceId = Device.PRIMARY_ID;\n\n      final Account account = mock(Account.class);\n\n      when(accountsManager.getByAccountIdentifierAsync(accountUuid))\n          .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n      when(account.getUuid()).thenReturn(accountUuid);\n      when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountUuid);\n      when(account.getDevice(anyByte())).thenAnswer(invocation -> Optional.of(DevicesHelper.createDevice(invocation.getArgument(0))));\n\n      insertMessages(accountUuid, deviceId, messagesPerQueue, CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n    }\n\n    final Set<RedisClusterNode> persistedNodes = new HashSet<>();\n    boolean addedNode;\n    int queuesPersisted = 0;\n\n    do {\n      final RedisClusterNode node =\n          messagesCache.claimNextNodeToPersist(messagePersister.getPersisterId(), Duration.ofHours(1)).orElseThrow();\n\n      queuesPersisted += messagePersister.persistNode(node);\n\n      messagesCache.releaseNodeClaim(node, messagePersister.getPersisterId());\n      addedNode = persistedNodes.add(node);\n    } while (addedNode);\n\n    assertEquals(queueCount, queuesPersisted);\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<List<MessageProtos.Envelope>> messagesCaptor =\n        ArgumentCaptor.forClass(List.class);\n\n    verify(messagesDynamoDb, atLeastOnce()).store(messagesCaptor.capture(), any(UUID.class), any());\n    assertEquals(queueCount * messagesPerQueue, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum());\n  }\n\n  @Test\n  void testPersistNodePersistenceDisabled() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    when(dynamicConfiguration.getMessagePersisterConfiguration())\n        .thenReturn(new DynamicMessagePersisterConfiguration(false, EXTRA_ROOM_RATIO, Duration.ofHours(1), Duration.ZERO));\n\n    assertEquals(0, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    verify(messagesDynamoDb, never()).store(any(), any(), any());\n  }\n\n  @Test\n  void testPersistNodeShouldPersistException() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    doReturn(Mono.error(new RedisException(\"Badness 10,000\")))\n        .when(messagesCache).getEarliestUndeliveredTimestamp(any(), anyByte());\n\n    assertEquals(0, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    verify(messagesDynamoDb, never()).store(any(), any(), any());\n  }\n\n  @Test\n  void testPersistNodeShouldPersistExceptionRetry() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    doReturn(Mono.error(new RedisException(\"Badness 10,000\")))\n        .doReturn(Mono.fromSupplier(() -> CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)).toEpochMilli()))\n        .when(messagesCache).getEarliestUndeliveredTimestamp(any(), anyByte());\n\n    assertEquals(1, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<List<MessageProtos.Envelope>> messagesCaptor =\n        ArgumentCaptor.forClass(List.class);\n\n    verify(messagesDynamoDb, atLeastOnce())\n        .store(messagesCaptor.capture(), eq(DESTINATION_ACCOUNT_UUID), eq(DESTINATION_DEVICE));\n\n    assertEquals(messageCount, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum());\n  }\n\n  @Test\n  void persistNodeAccountNotFound() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    when(accountsManager.getByAccountIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    assertEquals(0, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    verify(messagesDynamoDb, never()).store(any(), any(), any());\n  }\n\n  @Test\n  void persistNodeFetchAccountException() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    when(accountsManager.getByAccountIdentifierAsync(any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException(\"Badness 10,000\")));\n\n    assertEquals(0, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    verify(messagesDynamoDb, never()).store(any(), any(), any());\n  }\n\n  @Test\n  void persistNodeFetchAccountExceptionRetry() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    when(accountsManager.getByAccountIdentifierAsync(DESTINATION_ACCOUNT_UUID))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException(\"Badness 10,000\")))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(destinationAccount)));\n\n    assertEquals(1, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<List<MessageProtos.Envelope>> messagesCaptor =\n        ArgumentCaptor.forClass(List.class);\n\n    verify(messagesDynamoDb, atLeastOnce())\n        .store(messagesCaptor.capture(), eq(DESTINATION_ACCOUNT_UUID), eq(DESTINATION_DEVICE));\n\n    assertEquals(messageCount, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum());\n  }\n\n  @Test\n  void persistNodePersistQueueException() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    when(messagesManager.persistMessages(any(), any(), any()))\n        .thenThrow(new RuntimeException(\"Badness 10,000\"));\n\n    assertEquals(0, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    verify(messagesDynamoDb, never()).store(any(), any(), any());\n  }\n\n  @Test\n  void persistNodePersistQueueExceptionRetry() {\n    final int messageCount = (MessagePersister.MESSAGE_BATCH_LIMIT * 3) + 7;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount,\n        CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    when(messagesManager.persistMessages(any(), any(), any()))\n        .thenThrow(new RuntimeException(\"Badness 10,000\"))\n        .thenAnswer(persistMessagesAnswer);\n\n    assertEquals(1, messagePersister.persistNode(\n        getNodeWithKey(MessagesCache.getMessageQueueKey(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID))));\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<List<MessageProtos.Envelope>> messagesCaptor =\n        ArgumentCaptor.forClass(List.class);\n\n    verify(messagesDynamoDb, atLeastOnce())\n        .store(messagesCaptor.capture(), eq(DESTINATION_ACCOUNT_UUID), eq(DESTINATION_DEVICE));\n\n    assertEquals(messageCount, messagesCaptor.getAllValues().stream().mapToInt(List::size).sum());\n  }\n\n  @Test\n  void testUnlinkOnFullQueue() {\n    final int messageCount = 1;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    final Device primary = mock(Device.class);\n    when(primary.getId()).thenReturn((byte) 1);\n    when(primary.isPrimary()).thenReturn(true);\n    when(primary.getFetchesMessages()).thenReturn(true);\n\n    final Device activeA = mock(Device.class);\n    when(activeA.getId()).thenReturn((byte) 2);\n    when(activeA.getFetchesMessages()).thenReturn(true);\n\n    final Device inactiveB = mock(Device.class);\n    final byte inactiveId = 3;\n    when(inactiveB.getId()).thenReturn(inactiveId);\n\n    final Device inactiveC = mock(Device.class);\n    when(inactiveC.getId()).thenReturn((byte) 4);\n\n    final Device activeD = mock(Device.class);\n    when(activeD.getId()).thenReturn((byte) 5);\n    when(activeD.getFetchesMessages()).thenReturn(true);\n\n    final Device destination = mock(Device.class);\n    when(destination.getId()).thenReturn(DESTINATION_DEVICE_ID);\n\n    when(destinationAccount.getDevices())\n        .thenReturn(List.of(primary, activeA, inactiveB, inactiveC, activeD, destination));\n\n    when(messagesManager.persistMessages(any(), any(), any()))\n        .thenThrow(ItemCollectionSizeLimitExceededException.builder().build());\n\n    assertTimeoutPreemptively(Duration.ofSeconds(1), () ->\n        messagePersister.persistQueue(destinationAccount, DESTINATION_DEVICE, Tags.empty()).block());\n\n    verify(accountsManager, exactly()).removeDevice(destinationAccount, DESTINATION_DEVICE_ID);\n  }\n\n  @Test\n  void testTrimOnFullPrimaryQueue() {\n    final List<MessageProtos.Envelope> cachedMessages = Stream.generate(() -> generateMessage(\n            DESTINATION_ACCOUNT_UUID, UUID.randomUUID(), CLOCK.instant().getEpochSecond(), ThreadLocalRandom.current().nextInt(100)))\n        .limit(10)\n        .toList();\n    final long cacheSize = cachedMessages.stream().mapToLong(MessageProtos.Envelope::getSerializedSize).sum();\n    for (final MessageProtos.Envelope envelope : cachedMessages) {\n      messagesCache.insert(UUID.fromString(envelope.getServerGuid()), DESTINATION_ACCOUNT_UUID, Device.PRIMARY_ID, envelope).join();\n    }\n\n    final long expectedClearedBytes = (long) (cacheSize * EXTRA_ROOM_RATIO);\n\n    final int persistedMessageCount = 100;\n    final List<MessageProtos.Envelope> persistedMessages = new ArrayList<>(persistedMessageCount);\n    final List<UUID> expectedClearedGuids = new ArrayList<>();\n    long total = 0L;\n    for (int i = 0; i < 100; i++) {\n      final UUID guid = UUID.randomUUID();\n      final MessageProtos.Envelope envelope = generateMessage(DESTINATION_ACCOUNT_UUID, guid, CLOCK.instant().getEpochSecond(), 13);\n      persistedMessages.add(envelope);\n      if (total < expectedClearedBytes) {\n        total += envelope.getSerializedSize();\n        expectedClearedGuids.add(guid);\n      }\n    }\n\n    final Device primary = mock(Device.class);\n    when(primary.getId()).thenReturn((byte) 1);\n    when(primary.isPrimary()).thenReturn(true);\n    when(primary.getFetchesMessages()).thenReturn(true);\n    when(destinationAccount.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(primary));\n\n    when(messagesManager.persistMessages(any(UUID.class), any(), anyList()))\n        .thenThrow(ItemCollectionSizeLimitExceededException.builder().build());\n    when(messagesManager.getMessagesForDeviceReactive(DESTINATION_ACCOUNT_UUID, primary, false))\n        .thenReturn(Flux.concat(\n            Flux.fromIterable(persistedMessages),\n            Flux.fromIterable(cachedMessages)));\n    when(messagesManager.delete(any(), any(), any(), anyLong()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    StepVerifier.create(messagePersister.persistQueue(destinationAccount, primary, Tags.empty()))\n        .expectError(MessagePersistenceException.class)\n        .verify();\n\n    verify(messagesManager, times(expectedClearedGuids.size()))\n        .delete(eq(DESTINATION_ACCOUNT_UUID), eq(primary), argThat(expectedClearedGuids::contains), anyLong());\n    verify(messagesManager, never())\n        .delete(any(), any(), argThat(guid -> !expectedClearedGuids.contains(guid)), anyLong());\n  }\n\n  @Test\n  void testFailedUnlinkOnFullQueueThrowsForRetry() {\n    final int messageCount = 1;\n\n    insertMessages(DESTINATION_ACCOUNT_UUID, DESTINATION_DEVICE_ID, messageCount, CLOCK.instant().minus(PERSIST_DELAY.plusSeconds(1)));\n\n    final Device primary = mock(Device.class);\n    when(primary.getId()).thenReturn((byte) 1);\n    when(primary.isPrimary()).thenReturn(true);\n    when(primary.getFetchesMessages()).thenReturn(true);\n\n    final Device activeA = mock(Device.class);\n    when(activeA.getId()).thenReturn((byte) 2);\n    when(activeA.getFetchesMessages()).thenReturn(true);\n\n    final Device inactiveB = mock(Device.class);\n    final byte inactiveId = 3;\n    when(inactiveB.getId()).thenReturn(inactiveId);\n\n    final Device inactiveC = mock(Device.class);\n    when(inactiveC.getId()).thenReturn((byte) 4);\n\n    final Device activeD = mock(Device.class);\n    when(activeD.getId()).thenReturn((byte) 5);\n    when(activeD.getFetchesMessages()).thenReturn(true);\n\n    final Device destination = mock(Device.class);\n    when(destination.getId()).thenReturn(DESTINATION_DEVICE_ID);\n\n    when(destinationAccount.getDevices()).thenReturn(List.of(primary, activeA, inactiveB, inactiveC, activeD, destination));\n\n    when(messagesManager.persistMessages(any(UUID.class), any(), anyList())).thenThrow(ItemCollectionSizeLimitExceededException.builder().build());\n    when(accountsManager.removeDevice(destinationAccount, DESTINATION_DEVICE_ID)).thenThrow(new RuntimeException());\n\n    assertThrows(RuntimeException.class, () -> messagePersister.persistQueue(destinationAccount, DESTINATION_DEVICE, Tags.empty()).block());\n  }\n\n  private static RedisClusterNode getNodeWithKey(final byte[] key) {\n    return REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection ->\n            connection.getPartitions().stream().filter(node -> node.hasSlot(SlotHash.getSlot(key))).findFirst())\n        .orElseThrow();\n  }\n\n  private void insertMessages(final UUID accountUuid, final byte deviceId, final int messageCount,\n      final Instant firstMessageTimestamp) {\n    for (int i = 0; i < messageCount; i++) {\n      final UUID messageGuid = UUID.randomUUID();\n      final MessageProtos.Envelope envelope = generateMessage(\n          accountUuid, messageGuid, firstMessageTimestamp.toEpochMilli() + i, 256);\n      messagesCache.insert(messageGuid, accountUuid, deviceId, envelope).join();\n    }\n  }\n\n  private MessageProtos.Envelope generateMessage(UUID accountUuid, UUID messageGuid, long messageTimestamp, int contentSize) {\n    return MessageProtos.Envelope.newBuilder()\n        .setDestinationServiceId(accountUuid.toString())\n        .setClientTimestamp(messageTimestamp)\n        .setServerTimestamp(messageTimestamp)\n        .setContent(ByteString.copyFromUtf8(RandomStringUtils.secure().nextAlphanumeric(contentSize)))\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .setServerGuid(messageGuid.toString())\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheGetItemsScriptTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.Mockito.mock;\n\nimport io.lettuce.core.RedisCommandExecutionException;\nimport io.lettuce.core.ScriptOutputType;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.redis.ClusterLuaScript;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\n\nclass MessagesCacheGetItemsScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @Test\n  void testCacheGetItemsScript() throws Exception {\n    final MessagesCacheInsertScript insertScript = new MessagesCacheInsertScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        mock(ScheduledExecutorService.class));\n\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final String serverGuid = UUID.randomUUID().toString();\n    final MessageProtos.Envelope envelope1 = MessageProtos.Envelope.newBuilder()\n        .setServerTimestamp(Instant.now().getEpochSecond())\n        .setServerGuid(serverGuid)\n        .build();\n\n    insertScript.executeAsync(destinationUuid, deviceId, envelope1);\n\n    final MessagesCacheGetItemsScript getItemsScript = new MessagesCacheGetItemsScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster());\n\n    final List<byte[]> messageAndScores = getItemsScript.execute(destinationUuid, deviceId, 1, -1, false)\n        .block(Duration.ofSeconds(1));\n\n    assertNotNull(messageAndScores);\n    assertEquals(2, messageAndScores.size());\n    final MessageProtos.Envelope resultEnvelope =\n        EnvelopeUtil.expand(MessageProtos.Envelope.parseFrom(messageAndScores.getFirst()),\n            mock(ExperimentEnrollmentManager.class));\n\n    assertEquals(serverGuid, resultEnvelope.getServerGuid());\n  }\n\n  @Test\n  void testCacheGetItemsInvalidParameter() throws Exception {\n    final ClusterLuaScript getItemsScript = ClusterLuaScript.fromResource(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        \"lua/get_items.lua\", ScriptOutputType.OBJECT);\n\n    final byte[] fakeKey = new byte[]{1};\n\n    final Exception e = assertThrows(RedisCommandExecutionException.class,\n        () -> getItemsScript.executeBinaryReactive(List.of(fakeKey, fakeKey),\n                List.of(\"1\".getBytes(StandardCharsets.UTF_8)))\n            .next()\n            .block(Duration.ofSeconds(1)));\n\n    assertEquals(\"ERR afterMessageId is required\", e.getMessage());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheInsertScriptTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\n\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubClusterConnection;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\n\nclass MessagesCacheInsertScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @Test\n  void testCacheInsertScript() throws Exception {\n    final MessagesCacheInsertScript insertScript =\n        new MessagesCacheInsertScript(REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final MessageProtos.Envelope envelope1 = MessageProtos.Envelope.newBuilder()\n        .setServerTimestamp(Instant.now().getEpochSecond())\n        .setServerGuid(UUID.randomUUID().toString())\n        .build();\n\n    insertScript.executeAsync(destinationUuid, deviceId, envelope1).toCompletableFuture().join();\n\n    assertEquals(List.of(EnvelopeUtil.compress(envelope1)), getStoredMessages(destinationUuid, deviceId));\n\n    final MessageProtos.Envelope envelope2 = MessageProtos.Envelope.newBuilder()\n        .setServerTimestamp(Instant.now().getEpochSecond())\n        .setServerGuid(UUID.randomUUID().toString())\n        .build();\n\n    insertScript.executeAsync(destinationUuid, deviceId, envelope2).toCompletableFuture().join();\n\n    assertEquals(List.of(EnvelopeUtil.compress(envelope1), EnvelopeUtil.compress(envelope2)),\n        getStoredMessages(destinationUuid, deviceId));\n\n    insertScript.executeAsync(destinationUuid, deviceId, envelope1).toCompletableFuture().join();\n\n    assertEquals(List.of(EnvelopeUtil.compress(envelope1), EnvelopeUtil.compress(envelope2)),\n        getStoredMessages(destinationUuid, deviceId),\n        \"Messages with same GUID should be deduplicated\");\n  }\n\n  private List<MessageProtos.Envelope> getStoredMessages(final UUID destinationUuid, final byte deviceId) throws IOException {\n    final MessagesCacheGetItemsScript getItemsScript =\n        new MessagesCacheGetItemsScript(REDIS_CLUSTER_EXTENSION.getRedisCluster());\n\n    final List<byte[]> queueItems = getItemsScript.execute(destinationUuid, deviceId, 1024, 0, false)\n        .blockOptional()\n        .orElseGet(Collections::emptyList);\n\n    final List<MessageProtos.Envelope> messages = new ArrayList<>(queueItems.size() / 2);\n\n    for (int i = 0; i < queueItems.size(); i += 2) {\n      try {\n        messages.add(MessageProtos.Envelope.parseFrom(queueItems.get(i)));\n      } catch (final InvalidProtocolBufferException e) {\n        throw new UncheckedIOException(e);\n      }\n    }\n\n    return messages;\n  }\n\n  @Test\n  void returnPresence() throws IOException {\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    final MessagesCacheInsertScript insertScript =\n        new MessagesCacheInsertScript(REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n    assertFalse(insertScript.executeAsync(destinationUuid, deviceId, MessageProtos.Envelope.newBuilder()\n            .setServerTimestamp(Instant.now().getEpochSecond())\n            .setServerGuid(UUID.randomUUID().toString())\n            .build())\n        .toCompletableFuture()\n        .join());\n\n    final FaultTolerantPubSubClusterConnection<byte[], byte[]> pubSubClusterConnection =\n        REDIS_CLUSTER_EXTENSION.getRedisCluster().createBinaryPubSubConnection();\n\n    pubSubClusterConnection.usePubSubConnection(connection ->\n        connection.sync().ssubscribe(RedisMessageAvailabilityManager.getClientEventChannel(destinationUuid, deviceId)));\n\n    assertTrue(insertScript.executeAsync(destinationUuid, deviceId, MessageProtos.Envelope.newBuilder()\n            .setServerTimestamp(Instant.now().getEpochSecond())\n            .setServerGuid(UUID.randomUUID().toString())\n            .build())\n        .toCompletableFuture()\n        .join());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScriptTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\n\nimport io.lettuce.core.RedisException;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.util.Pair;\n\nclass MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @ParameterizedTest\n  @MethodSource\n  void testInsert(final int count, final Map<ServiceIdentifier, List<Byte>> destinations) throws Exception {\n\n    final MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript insertMrmScript = new MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n    final byte[] sharedMrmKey = MessagesCache.getSharedMrmKey(UUID.randomUUID());\n    insertMrmScript.executeAsync(sharedMrmKey,\n        MessagesCacheTest.generateRandomMrmMessage(destinations)).toCompletableFuture().join();\n\n    final int totalDevices = destinations.values().stream().mapToInt(List::size).sum();\n    final long hashFieldCount = REDIS_CLUSTER_EXTENSION.getRedisCluster()\n        .withBinaryCluster(conn -> conn.sync().hlen(sharedMrmKey));\n    // + 1 because of \"data\" field\n    assertEquals(1 + totalDevices, hashFieldCount);\n  }\n\n  public static List<Arguments> testInsert() {\n    final Map<ServiceIdentifier, List<Byte>> singleAccount = Map.of(\n        new AciServiceIdentifier(UUID.randomUUID()), List.of((byte) 1, (byte) 2));\n\n    final List<Arguments> testCases = new ArrayList<>();\n    testCases.add(Arguments.of(1, singleAccount));\n\n    for (int j = 1000; j <= 30000; j += 1000) {\n\n      final Map<Integer, List<Byte>> deviceLists = new HashMap<>();\n      final Map<ServiceIdentifier, List<Byte>> manyAccounts = IntStream.range(0, j)\n          .mapToObj(i -> {\n            final int deviceCount = 1 + i % 5;\n            final List<Byte> devices = deviceLists.computeIfAbsent(deviceCount, count -> IntStream.rangeClosed(1, count)\n                .mapToObj(v -> (byte) v)\n                .toList());\n\n            return new Pair<>(new AciServiceIdentifier(UUID.randomUUID()), devices);\n          })\n          .collect(Collectors.toMap(Pair::first, Pair::second));\n\n      testCases.add(Arguments.of(j, manyAccounts));\n    }\n\n    return testCases;\n  }\n\n  @Test\n  void testInsertDuplicateKey() throws Exception {\n    final MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript insertMrmScript = new MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n    final byte[] sharedMrmKey = MessagesCache.getSharedMrmKey(UUID.randomUUID());\n    insertMrmScript.executeAsync(sharedMrmKey,\n        MessagesCacheTest.generateRandomMrmMessage(new AciServiceIdentifier(UUID.randomUUID()), Device.PRIMARY_ID))\n        .toCompletableFuture()\n        .join();\n\n    final CompletionException completionException = assertThrows(CompletionException.class,\n        () -> insertMrmScript.executeAsync(sharedMrmKey,\n            MessagesCacheTest.generateRandomMrmMessage(new AciServiceIdentifier(UUID.randomUUID()),\n                Device.PRIMARY_ID)).toCompletableFuture().join());\n\n    assertInstanceOf(RedisException.class, completionException.getCause());\n    assertTrue(completionException.getCause().getMessage()\n        .contains(MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript.ERROR_KEY_EXISTS));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheRemoveByGuidScriptTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\n\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass MessagesCacheRemoveByGuidScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @Test\n  void testCacheRemoveByGuid() throws Exception {\n    final MessagesCacheInsertScript insertScript = new MessagesCacheInsertScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        mock(ScheduledExecutorService.class));\n\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final UUID serverGuid = UUID.randomUUID();\n    final MessageProtos.Envelope envelope1 = MessageProtos.Envelope.newBuilder()\n        .setServerTimestamp(Instant.now().getEpochSecond())\n        .setServerGuid(serverGuid.toString())\n        .build();\n\n    insertScript.executeAsync(destinationUuid, deviceId, envelope1);\n\n    final MessagesCacheRemoveByGuidScript removeByGuidScript = new MessagesCacheRemoveByGuidScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n    final List<byte[]> removedMessages =\n        removeByGuidScript.execute(destinationUuid, deviceId, List.of(serverGuid))\n            .toCompletableFuture()\n            .get(1, TimeUnit.SECONDS);\n\n    assertEquals(1, removedMessages.size());\n\n    final MessageProtos.Envelope resultMessage = MessageProtos.Envelope.parseFrom(removedMessages.getFirst());\n\n    assertEquals(serverGuid, UUIDUtil.fromByteString(resultMessage.getServerGuidBinary()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheRemoveQueueScriptTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\n\nclass MessagesCacheRemoveQueueScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n\n  @Test\n  void testCacheRemoveQueueScript() throws Exception {\n    final MessagesCacheInsertScript insertScript = new MessagesCacheInsertScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        mock(ScheduledExecutorService.class));\n\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte deviceId = 1;\n    final MessageProtos.Envelope envelope1 = MessageProtos.Envelope.newBuilder()\n        .setServerTimestamp(Instant.now().getEpochSecond())\n        .setServerGuid(UUID.randomUUID().toString())\n        .build();\n\n    insertScript.executeAsync(destinationUuid, deviceId, envelope1).toCompletableFuture().join();\n\n    final MessagesCacheRemoveQueueScript removeScript = new MessagesCacheRemoveQueueScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster());\n\n    final List<byte[]> messagesToCheckForMrmKeys = removeScript.execute(destinationUuid, deviceId,\n            Collections.emptyList())\n        .block(Duration.ofSeconds(1));\n\n    assertEquals(1, messagesToCheckForMrmKeys.size());\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheRemoveRecipientViewFromMrmDataScriptTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\n\nimport io.lettuce.core.cluster.SlotHash;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.UUID;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.util.Pair;\nimport reactor.core.publisher.Flux;\nimport reactor.util.function.Tuples;\n\nclass MessagesCacheRemoveRecipientViewFromMrmDataScriptTest {\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @ParameterizedTest\n  @MethodSource\n  void testUpdateSingleKey(final Map<ServiceIdentifier, List<Byte>> destinations) throws Exception {\n\n    final MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript insertMrmScript = new MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n    final byte[] sharedMrmKey = MessagesCache.getSharedMrmKey(UUID.randomUUID());\n    insertMrmScript.executeAsync(sharedMrmKey, MessagesCacheTest.generateRandomMrmMessage(destinations))\n        .toCompletableFuture()\n        .join();\n\n    final MessagesCacheRemoveRecipientViewFromMrmDataScript removeRecipientViewFromMrmDataScript = new MessagesCacheRemoveRecipientViewFromMrmDataScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster());\n\n    final long keysRemoved = Objects.requireNonNull(Flux.fromIterable(destinations.entrySet())\n        .flatMap(e -> Flux.fromStream(e.getValue().stream().map(deviceId -> Tuples.of(e.getKey(), deviceId))))\n        .flatMap(serviceIdentifierByteTuple -> removeRecipientViewFromMrmDataScript.execute(List.of(sharedMrmKey),\n            serviceIdentifierByteTuple.getT1(), serviceIdentifierByteTuple.getT2()))\n        .reduce(Long::sum)\n        .block(Duration.ofSeconds(35)));\n\n    assertEquals(1, keysRemoved);\n\n    final long keyExists = REDIS_CLUSTER_EXTENSION.getRedisCluster()\n        .withBinaryCluster(conn -> conn.sync().exists(sharedMrmKey));\n    assertEquals(0, keyExists);\n  }\n\n  public static List<Map<ServiceIdentifier, List<Byte>>> testUpdateSingleKey() {\n    final Map<ServiceIdentifier, List<Byte>> singleAccount = Map.of(\n        new AciServiceIdentifier(UUID.randomUUID()), List.of((byte) 1, (byte) 2));\n\n    final List<Map<ServiceIdentifier, List<Byte>>> testCases = new ArrayList<>();\n    testCases.add(singleAccount);\n\n    // Generate a more, from smallish to very large\n    for (int j = 1000; j <= 81000; j *= 3) {\n\n      final Map<Integer, List<Byte>> deviceLists = new HashMap<>();\n      final Map<ServiceIdentifier, List<Byte>> manyAccounts = IntStream.range(0, j)\n          .mapToObj(i -> {\n            final int deviceCount = 1 + i % 5;\n            final List<Byte> devices = deviceLists.computeIfAbsent(deviceCount, count -> IntStream.rangeClosed(1, count)\n                .mapToObj(v -> (byte) v)\n                .toList());\n\n            return new Pair<>(new AciServiceIdentifier(UUID.randomUUID()), devices);\n          })\n          .collect(Collectors.toMap(Pair::first, Pair::second));\n\n      testCases.add(manyAccounts);\n    }\n\n    return testCases;\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {1, 10, 100, 1000, 10000})\n  void testUpdateManyKeys(int keyCount) throws Exception {\n\n    final List<byte[]> sharedMrmKeys = new ArrayList<>(keyCount);\n    final ServiceIdentifier serviceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final byte deviceId = 1;\n\n    for (int i = 0; i < keyCount; i++) {\n\n      final MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript insertMrmScript = new MessagesCacheInsertSharedMultiRecipientPayloadAndViewsScript(\n          REDIS_CLUSTER_EXTENSION.getRedisCluster(), mock(ScheduledExecutorService.class));\n\n      final byte[] sharedMrmKey = MessagesCache.getSharedMrmKey(UUID.randomUUID());\n      insertMrmScript.executeAsync(sharedMrmKey,\n          MessagesCacheTest.generateRandomMrmMessage(serviceIdentifier, deviceId)).toCompletableFuture().join();\n\n      sharedMrmKeys.add(sharedMrmKey);\n    }\n\n    final MessagesCacheRemoveRecipientViewFromMrmDataScript removeRecipientViewFromMrmDataScript = new MessagesCacheRemoveRecipientViewFromMrmDataScript(\n        REDIS_CLUSTER_EXTENSION.getRedisCluster());\n\n    final long keysRemoved = Objects.requireNonNull(Flux.fromIterable(sharedMrmKeys)\n        .collectMultimap(SlotHash::getSlot)\n        .flatMapMany(slotsAndKeys -> Flux.fromIterable(slotsAndKeys.values()))\n        .flatMap(keys -> removeRecipientViewFromMrmDataScript.execute(keys, serviceIdentifier, deviceId))\n        .reduce(Long::sum)\n        .block(Duration.ofSeconds(5)));\n\n    assertEquals(sharedMrmKeys.size(), keysRemoved);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesCacheTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.atLeast;\nimport static org.mockito.Mockito.atMost;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.lettuce.core.RedisFuture;\nimport io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;\nimport io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport io.lettuce.core.protocol.AsyncCommand;\nimport io.lettuce.core.protocol.RedisCommand;\nimport java.io.ByteArrayOutputStream;\nimport java.nio.ByteBuffer;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.ArrayDeque;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Deque;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Random;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.stream.Collectors;\nimport org.apache.commons.lang3.ArrayUtils;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Disabled;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.reactivestreams.Publisher;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;\nimport reactor.core.publisher.Flux;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.test.StepVerifier;\nimport reactor.test.publisher.TestPublisher;\n\nclass MessagesCacheTest {\n\n  private final Random random = new Random();\n  private long serialTimestamp = 0;\n\n  @Nested\n  class WithRealCluster {\n\n    @RegisterExtension\n    static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n    private ExecutorService sharedExecutorService;\n    private ScheduledExecutorService resubscribeRetryExecutorService;\n    private Scheduler messageDeliveryScheduler;\n    private MessagesCache messagesCache;\n\n    private static final UUID DESTINATION_UUID = UUID.randomUUID();\n    private static final byte DESTINATION_DEVICE_ID = 7;\n\n    private static final Duration NODE_CLAIM_TTL = Duration.ofHours(1);\n\n    @BeforeEach\n    void setUp() throws Exception {\n      sharedExecutorService = Executors.newSingleThreadExecutor();\n      resubscribeRetryExecutorService = Executors.newSingleThreadScheduledExecutor();\n      messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n      messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n          messageDeliveryScheduler, sharedExecutorService, mock(ScheduledExecutorService.class), Clock.systemUTC(), mock(ExperimentEnrollmentManager.class));\n    }\n\n    @AfterEach\n    void tearDown() throws Exception {\n      sharedExecutorService.shutdown();\n      sharedExecutorService.awaitTermination(1, TimeUnit.SECONDS);\n\n      messageDeliveryScheduler.dispose();\n      resubscribeRetryExecutorService.shutdown();\n      resubscribeRetryExecutorService.awaitTermination(1, TimeUnit.SECONDS);\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testInsert(final boolean sealedSender) {\n      final UUID messageGuid = UUID.randomUUID();\n      assertDoesNotThrow(() -> messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID,\n          generateRandomMessage(messageGuid, sealedSender))).join();\n    }\n\n    @Test\n    void testDoubleInsertGuid() {\n      final UUID duplicateGuid = UUID.randomUUID();\n      final MessageProtos.Envelope duplicateMessage = generateRandomMessage(duplicateGuid, false);\n\n      messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, duplicateMessage).join();\n      messagesCache.insert(duplicateGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, duplicateMessage).join();\n\n      assertEquals(1, messagesCache.getAllMessages(DESTINATION_UUID, DESTINATION_DEVICE_ID, 0, 10, false)\n          .count()\n          .blockOptional()\n          .orElse(0L));\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testRemoveByUUID(final boolean sealedSender) throws Exception {\n      final UUID messageGuid = UUID.randomUUID();\n\n      assertEquals(Optional.empty(),\n          messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageGuid).get(5, TimeUnit.SECONDS));\n\n      final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender);\n\n      messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n      final Optional<RemovedMessage> maybeRemovedMessage = messagesCache.remove(DESTINATION_UUID,\n          DESTINATION_DEVICE_ID, messageGuid).get(5, TimeUnit.SECONDS);\n\n      assertEquals(Optional.of(RemovedMessage.fromEnvelope(message)), maybeRemovedMessage);\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testRemoveBatchByUUID(final boolean sealedSender) throws Exception {\n      final int messageCount = 10;\n\n      final List<MessageProtos.Envelope> messagesToRemove = new ArrayList<>(messageCount);\n      final List<MessageProtos.Envelope> messagesToPreserve = new ArrayList<>(messageCount);\n\n      for (int i = 0; i < 10; i++) {\n        messagesToRemove.add(generateRandomMessage(UUID.randomUUID(), sealedSender));\n        messagesToPreserve.add(generateRandomMessage(UUID.randomUUID(), sealedSender));\n      }\n\n      assertEquals(Collections.emptyList(), messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,\n          messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid()))\n              .collect(Collectors.toList())).get(5, TimeUnit.SECONDS));\n\n      for (final MessageProtos.Envelope message : messagesToRemove) {\n        messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID,\n            message).join();\n      }\n\n      for (final MessageProtos.Envelope message : messagesToPreserve) {\n        messagesCache.insert(UUID.fromString(message.getServerGuid()), DESTINATION_UUID, DESTINATION_DEVICE_ID,\n            message).join();\n      }\n\n      final List<RemovedMessage> removedMessages = messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,\n          messagesToRemove.stream().map(message -> UUID.fromString(message.getServerGuid()))\n              .collect(Collectors.toList())).get(5, TimeUnit.SECONDS);\n\n      assertEquals(messagesToRemove.stream().map(RemovedMessage::fromEnvelope).toList(), removedMessages);\n      assertEquals(messagesToPreserve, get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));\n    }\n\n    @Test\n    void testHasMessagesAsync() {\n      assertFalse(messagesCache.hasMessagesAsync(DESTINATION_UUID, DESTINATION_DEVICE_ID).join());\n\n      final UUID messageGuid = UUID.randomUUID();\n      final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true);\n      messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n\n      assertTrue(messagesCache.hasMessagesAsync(DESTINATION_UUID, DESTINATION_DEVICE_ID).join());\n    }\n\n    @Test\n    void getOldestTimestamp() {\n      final int messageCount = 100;\n\n      final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);\n\n      long expectedOldestTimestamp = serialTimestamp;\n      for (int i = 0; i < messageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope message = generateRandomMessage(messageGuid, i % 2 == 0);\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n        assertEquals(expectedOldestTimestamp,\n            messagesCache.getEarliestUndeliveredTimestamp(DESTINATION_UUID, DESTINATION_DEVICE_ID).block());\n        expectedMessages.add(message);\n      }\n\n      for (final MessageProtos.Envelope message : expectedMessages) {\n        assertEquals(expectedOldestTimestamp,\n            messagesCache.getEarliestUndeliveredTimestamp(DESTINATION_UUID, DESTINATION_DEVICE_ID).block());\n        messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, UUID.fromString(message.getServerGuid())).join();\n        expectedOldestTimestamp += 1;\n      }\n      assertNull(messagesCache.getEarliestUndeliveredTimestamp(DESTINATION_UUID, DESTINATION_DEVICE_ID).block());\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testGetMessages(final boolean sealedSender) throws Exception {\n      final int messageCount = 100;\n\n      final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);\n\n      for (int i = 0; i < messageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender);\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n        expectedMessages.add(message);\n      }\n\n      assertEquals(expectedMessages, get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));\n\n      messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,\n          expectedMessages.stream()\n              .map(MessageProtos.Envelope::getServerGuid)\n              .map(UUID::fromString)\n              .collect(Collectors.toList()));\n\n      final UUID message1Guid = UUID.randomUUID();\n      final MessageProtos.Envelope message1 = generateRandomMessage(message1Guid, sealedSender);\n      messagesCache.insert(message1Guid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message1).join();\n      final List<MessageProtos.Envelope> get1 = get(DESTINATION_UUID, DESTINATION_DEVICE_ID,\n          1);\n      assertEquals(List.of(message1), get1);\n\n      messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID, message1Guid).get(5, TimeUnit.SECONDS);\n\n      final UUID message2Guid = UUID.randomUUID();\n      final MessageProtos.Envelope message2 = generateRandomMessage(message2Guid, sealedSender);\n\n      messagesCache.insert(message2Guid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message2).join();\n\n      assertEquals(List.of(message2), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, 1));\n    }\n\n    @Test\n    void testGetMessagesFirstPageDiscarded() {\n      final int discardableMessages = MessagesCache.PAGE_SIZE * 2;\n      final int deliverableMessages = MessagesCache.PAGE_SIZE + 1;\n\n      final Instant now = Instant.now();\n      final ServiceIdentifier destinationServiceIdentifier = new AciServiceIdentifier(DESTINATION_UUID);\n\n      for (int i = 0; i < discardableMessages; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final long timestamp = now.minus(MessagesCache.MAX_EPHEMERAL_MESSAGE_DELAY.multipliedBy(2)).toEpochMilli() + i;\n\n        final MessageProtos.Envelope message =\n            generateRandomMessage(messageGuid, destinationServiceIdentifier, true, true, timestamp);\n\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n      }\n\n      final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(deliverableMessages);\n\n      for (int i = 0; i < deliverableMessages; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final long timestamp = now.plusMillis(i).toEpochMilli();\n\n        final MessageProtos.Envelope message =\n            generateRandomMessage(messageGuid, destinationServiceIdentifier, true, false, timestamp);\n\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n        expectedMessages.add(message);\n      }\n\n      assertEquals(expectedMessages, get(DESTINATION_UUID, DESTINATION_DEVICE_ID, deliverableMessages + discardableMessages));\n    }\n\n    @Test\n    void testGetMessagesLockedForPersistence() {\n      final int messageCount = 100;\n\n      final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);\n\n      for (int i = 0; i < messageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true);\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n        expectedMessages.add(message);\n      }\n\n      messagesCache.lockQueueForPersistence(DESTINATION_UUID, DESTINATION_DEVICE_ID).block();\n\n      assertTrue(get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount).isEmpty());\n\n      messagesCache.unlockQueueForPersistence(DESTINATION_UUID, DESTINATION_DEVICE_ID).block();\n\n      assertEquals(expectedMessages, get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));\n    }\n\n    @Test\n    void testGetMessagesToPersistLockedForPersistence() {\n      final int messageCount = 100;\n\n      final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);\n\n      for (int i = 0; i < messageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true);\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n        expectedMessages.add(message);\n      }\n\n      messagesCache.lockQueueForPersistence(DESTINATION_UUID, DESTINATION_DEVICE_ID).block();\n\n      try {\n        assertEquals(expectedMessages,\n            Flux.from(messagesCache.getMessagesToPersist(DESTINATION_UUID, DESTINATION_DEVICE_ID))\n                .collectList()\n                .blockOptional()\n                .orElseThrow());\n      } finally {\n        messagesCache.unlockQueueForPersistence(DESTINATION_UUID, DESTINATION_DEVICE_ID).block();\n      }\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testGetMessagesPublisher(final boolean expectStale) throws Exception {\n      final int messageCount = 214;\n\n      final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(messageCount);\n\n      for (int i = 0; i < messageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope message = generateRandomMessage(messageGuid, true);\n        messagesCache.insert(messageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, message).join();\n\n        expectedMessages.add(message);\n      }\n\n      final UUID ephemeralMessageGuid = UUID.randomUUID();\n      final MessageProtos.Envelope ephemeralMessage = generateRandomMessage(ephemeralMessageGuid, true)\n          .toBuilder().setEphemeral(true).build();\n      messagesCache.insert(ephemeralMessageGuid, DESTINATION_UUID, DESTINATION_DEVICE_ID, ephemeralMessage).join();\n\n      final Clock cacheClock;\n      if (expectStale) {\n        cacheClock = Clock.fixed(Instant.ofEpochMilli(serialTimestamp + 1),\n            ZoneId.of(\"Etc/UTC\"));\n      } else {\n        cacheClock = Clock.fixed(\n            Instant.ofEpochMilli(serialTimestamp + 1).plus(MessagesCache.MAX_EPHEMERAL_MESSAGE_DELAY),\n            ZoneId.of(\"Etc/UTC\"));\n      }\n\n      final MessagesCache messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n          messageDeliveryScheduler, sharedExecutorService, mock(ScheduledExecutorService.class), cacheClock, mock(ExperimentEnrollmentManager.class));\n\n      final List<MessageProtos.Envelope> actualMessages = Flux.from(\n              messagesCache.get(DESTINATION_UUID, DESTINATION_DEVICE_ID))\n          .collectList()\n          .block(Duration.ofSeconds(5));\n\n      if (expectStale) {\n        final List<MessageProtos.Envelope> expectedAllMessages = new ArrayList<>() {{\n          addAll(expectedMessages);\n          add(ephemeralMessage);\n        }};\n\n        assertEquals(expectedAllMessages, actualMessages);\n\n      } else {\n        assertEquals(expectedMessages, actualMessages);\n\n        // delete all of these messages and call `getAll()`, to confirm that ephemeral messages have been discarded\n        CompletableFuture.allOf(actualMessages.stream()\n                .map(message -> messagesCache.remove(DESTINATION_UUID, DESTINATION_DEVICE_ID,\n                    UUID.fromString(message.getServerGuid())))\n                .toArray(CompletableFuture<?>[]::new))\n            .get(5, TimeUnit.SECONDS);\n\n        final List<MessageProtos.Envelope> messages = messagesCache.getAllMessages(DESTINATION_UUID,\n                DESTINATION_DEVICE_ID, 0, 10, false)\n            .collectList()\n            .toFuture().get(5, TimeUnit.SECONDS);\n\n        assertTrue(messages.isEmpty());\n      }\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testClearQueueForDevice(final boolean sealedSender) {\n      final int messageCount = 1000;\n\n      for (final byte deviceId : new byte[]{DESTINATION_DEVICE_ID, DESTINATION_DEVICE_ID + 1}) {\n        for (int i = 0; i < messageCount; i++) {\n          final UUID messageGuid = UUID.randomUUID();\n          final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender);\n\n          messagesCache.insert(messageGuid, DESTINATION_UUID, deviceId, message).join();\n        }\n      }\n\n      messagesCache.clear(DESTINATION_UUID, DESTINATION_DEVICE_ID).join();\n\n      assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));\n      assertEquals(messageCount, get(DESTINATION_UUID, (byte) (DESTINATION_DEVICE_ID + 1), messageCount).size());\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testClearQueueForAccount(final boolean sealedSender) {\n      final int messageCount = 1000;\n\n      for (final byte deviceId : new byte[]{DESTINATION_DEVICE_ID, DESTINATION_DEVICE_ID + 1}) {\n        for (int i = 0; i < messageCount; i++) {\n          final UUID messageGuid = UUID.randomUUID();\n          final MessageProtos.Envelope message = generateRandomMessage(messageGuid, sealedSender);\n\n          messagesCache.insert(messageGuid, DESTINATION_UUID, deviceId, message).join();\n        }\n      }\n\n      messagesCache.clear(DESTINATION_UUID).join();\n\n      assertEquals(Collections.emptyList(), get(DESTINATION_UUID, DESTINATION_DEVICE_ID, messageCount));\n      assertEquals(Collections.emptyList(), get(DESTINATION_UUID, (byte) (DESTINATION_DEVICE_ID + 1), messageCount));\n    }\n\n    @Test\n    void testGetAccountFromQueueName() {\n      assertEquals(DESTINATION_UUID,\n          MessagesCache.getAccountUuidFromQueueName(\n              new String(MessagesCache.getMessageQueueKey(DESTINATION_UUID, DESTINATION_DEVICE_ID),\n                  StandardCharsets.UTF_8)));\n    }\n\n    @Test\n    void testGetDeviceIdFromQueueName() {\n      assertEquals(DESTINATION_DEVICE_ID,\n          MessagesCache.getDeviceIdFromQueueName(\n              new String(MessagesCache.getMessageQueueKey(DESTINATION_UUID, DESTINATION_DEVICE_ID),\n                  StandardCharsets.UTF_8)));\n    }\n\n    @Test\n    void claimNextNodeToPersist() {\n      final int partitionCount =\n          REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> connection.getPartitions().size());\n\n      final String persisterId = UUID.randomUUID().toString();\n\n      for (int i = 0; i < partitionCount; i++) {\n        assertTrue(messagesCache.claimNextNodeToPersist(persisterId, NODE_CLAIM_TTL).isPresent());\n      }\n\n      assertTrue(messagesCache.claimNextNodeToPersist(persisterId, NODE_CLAIM_TTL).isEmpty(),\n          \"Should not be able to claim a node when all nodes are already claimed\");\n    }\n\n    @Test\n    void claimNextNodeToPersistRotation() {\n      final int partitionCount =\n          REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection -> connection.getPartitions().size());\n\n      final String persisterId = UUID.randomUUID().toString();\n\n      final Set<RedisClusterNode> claimedNodes = new HashSet<>();\n\n      for (int i = 0; i < partitionCount; i++) {\n        final Optional<RedisClusterNode> maybeNode = messagesCache.claimNextNodeToPersist(persisterId, NODE_CLAIM_TTL);\n        assertTrue(maybeNode.isPresent());\n\n        claimedNodes.add(maybeNode.get());\n        messagesCache.releaseNodeClaim(maybeNode.get(), persisterId);\n      }\n\n      assertEquals(partitionCount, claimedNodes.size(),\n          \"Persisters should cycle through all available partitions when claims are uncontested\");\n    }\n\n    @Test\n    void claimNode() {\n      final RedisClusterNode node =\n          REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection ->\n              connection.getPartitions().stream().findFirst().orElseThrow());\n\n      final String persisterId = UUID.randomUUID().toString();\n\n      assertTrue(messagesCache.claimNode(node, persisterId, NODE_CLAIM_TTL));\n      assertFalse(messagesCache.claimNode(node, persisterId, NODE_CLAIM_TTL), \"Should not be able to claim a node twice\");\n\n      messagesCache.releaseNodeClaim(node, persisterId);\n      assertTrue(messagesCache.claimNode(node, persisterId, NODE_CLAIM_TTL), \"Should be able to claim node after releasing claim\");\n    }\n\n    @Test\n    void releaseNodeClaim() {\n      final RedisClusterNode node =\n          REDIS_CLUSTER_EXTENSION.getRedisCluster().withCluster(connection ->\n              connection.getPartitions().stream().findFirst().orElseThrow());\n\n      final String persisterId = UUID.randomUUID().toString();\n      final String competingPersisterId = UUID.randomUUID().toString();\n\n      messagesCache.claimNode(node, competingPersisterId, NODE_CLAIM_TTL);\n\n      assertFalse(messagesCache.claimNode(node, persisterId, NODE_CLAIM_TTL),\n          \"Should not be able to claim a node claimed by another persister\");\n\n      messagesCache.releaseNodeClaim(node, persisterId);\n      assertFalse(messagesCache.claimNode(node, persisterId, NODE_CLAIM_TTL),\n          \"Should not be able to release/claim a node claimed by another persister\");\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testMultiRecipientMessage(final boolean sharedMrmKeyPresent) {\n\n      final ServiceIdentifier destinationServiceId = new AciServiceIdentifier(UUID.randomUUID());\n      final byte deviceId = 1;\n\n      final SealedSenderMultiRecipientMessage mrm = generateRandomMrmMessage(destinationServiceId, deviceId);\n\n      final byte[] sharedMrmDataKey;\n      if (sharedMrmKeyPresent) {\n        sharedMrmDataKey = messagesCache.insertSharedMultiRecipientMessagePayload(mrm).join();\n      } else {\n        sharedMrmDataKey = \"{1}\".getBytes(StandardCharsets.UTF_8);\n      }\n\n      final UUID guid = UUID.randomUUID();\n      final MessageProtos.Envelope message = generateRandomMessage(guid, destinationServiceId, true)\n          .toBuilder()\n          // clear some things added by the helper\n          .clearServerGuid()\n          .setSharedMrmKey(ByteString.copyFrom(sharedMrmDataKey))\n          .clearContent()\n          .build();\n      messagesCache.insert(guid, destinationServiceId.uuid(), deviceId, message).join();\n\n      assertEquals(sharedMrmKeyPresent ? 1 : 0, (long) REDIS_CLUSTER_EXTENSION.getRedisCluster()\n          .withBinaryCluster(conn -> conn.sync().exists(sharedMrmDataKey)));\n\n      final List<MessageProtos.Envelope> messages = get(destinationServiceId.uuid(), deviceId, 1);\n\n      if (!sharedMrmKeyPresent) {\n        assertTrue(messages.isEmpty());\n\n        // the discard is purely async, so we just wait for it\n        assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {\n          boolean exists;\n          do {\n            exists = 1 == REDIS_CLUSTER_EXTENSION.getRedisCluster()\n                .withBinaryCluster(conn ->\n                    conn.sync().exists(MessagesCache.getMessageQueueKey(destinationServiceId.uuid(), deviceId)));\n          } while (exists);\n        }, \"Stale MRM message should be deleted asynchronously\");\n\n      } else {\n        assertEquals(1, messages.size());\n\n        assertEquals(guid, UUID.fromString(messages.getFirst().getServerGuid()));\n        assertFalse(messages.getFirst().hasSharedMrmKey());\n        final SealedSenderMultiRecipientMessage.Recipient recipient = mrm.getRecipients()\n            .get(destinationServiceId.toLibsignal());\n        assertArrayEquals(mrm.messageForRecipient(recipient), messages.getFirst().getContent().toByteArray());\n\n        final Optional<RemovedMessage> removedMessage = messagesCache.remove(destinationServiceId.uuid(), deviceId, guid)\n            .join();\n\n        assertTrue(removedMessage.isPresent());\n        assertEquals(guid, UUID.fromString(removedMessage.get().serverGuid().toString()));\n      }\n\n      assertTrue(get(destinationServiceId.uuid(), deviceId, 1).isEmpty());\n\n      // updating the shared MRM data is purely async, so we just wait for it\n      assertTimeoutPreemptively(Duration.ofSeconds(1), () -> {\n        boolean exists;\n        do {\n          exists = 1 == REDIS_CLUSTER_EXTENSION.getRedisCluster()\n              .withBinaryCluster(conn -> conn.sync().exists(sharedMrmDataKey));\n        } while (exists);\n      }, \"Shared MRM data should be deleted asynchronously\");\n    }\n\n    @Test\n    void testEstimatePersistedQueueSize() {\n      final UUID destinationUuid = UUID.randomUUID();\n      final ServiceIdentifier serviceId = new AciServiceIdentifier(destinationUuid);\n      final byte deviceId = 1;\n\n      // Should count all non-ephemeral, non-stale message bytes\n      long expectedQueueSize = 0L;\n      for (int i = 0; i < 400; i++) {\n        final MessageProtos.Envelope messageToInsert = switch (i % 4) {\n          // An MRM message\n          case 0 -> {\n\n            // First generate a random MRM message\n            final SealedSenderMultiRecipientMessage mrm = generateRandomMrmMessage(serviceId, deviceId);\n            final SealedSenderMultiRecipientMessage.Recipient recepient = mrm.getRecipients()\n                .get(serviceId.toLibsignal());\n\n            // Calculate the size of a message that has the shared content in it\n            final MessageProtos.Envelope message = generateRandomMessage(UUID.randomUUID(), serviceId, true)\n                .toBuilder()\n                .setContent(ByteString.copyFrom(mrm.messageForRecipient(recepient)))\n                .build();\n            expectedQueueSize += message.getSerializedSize();\n            byte[] sharedMrmDataKey = messagesCache.insertSharedMultiRecipientMessagePayload(mrm).join();\n\n            // Insert the MRM message without the content\n            yield message\n                .toBuilder()\n                .clearContent()\n                .setSharedMrmKey(ByteString.copyFrom(sharedMrmDataKey))\n                .build();\n          }\n\n          // A stale MRM message\n          case 1 ->\n            generateRandomMessage(UUID.randomUUID(), serviceId, true)\n                .toBuilder()\n                // clear some things added by the helper\n                .clearContent()\n                .setSharedMrmKey(MessagesCache.STALE_MRM_KEY)\n                .build();\n\n          // An ephemeral message\n          case 2 -> generateRandomMessage(UUID.randomUUID(), serviceId, true).toBuilder().setEphemeral(true).build();\n\n          // A standardard message\n          case 3 -> {\n            final MessageProtos.Envelope message = generateRandomMessage(UUID.randomUUID(), serviceId, true);\n            expectedQueueSize += message.getSerializedSize();\n            yield message;\n          }\n\n          default -> throw new IllegalStateException();\n        };\n        messagesCache.insert(UUID.fromString(messageToInsert.getServerGuid()), destinationUuid, deviceId, messageToInsert).join();\n      }\n      long actualQueueSize = messagesCache.estimatePersistedQueueSizeBytes(destinationUuid, deviceId).join();\n      assertEquals(expectedQueueSize, actualQueueSize);\n    }\n\n    @ParameterizedTest\n    @ValueSource(booleans = {true, false})\n    void testGetMessagesToPersist(final boolean sharedMrmKeyPresent) {\n\n      final UUID destinationUuid = UUID.randomUUID();\n      final ServiceIdentifier destinationServiceId = new AciServiceIdentifier(destinationUuid);\n      final byte deviceId = 1;\n\n      final UUID messageGuid = UUID.randomUUID();\n      final MessageProtos.Envelope message = generateRandomMessage(messageGuid,\n          new AciServiceIdentifier(destinationUuid), true);\n\n      messagesCache.insert(messageGuid, destinationUuid, deviceId, message).join();\n\n      final SealedSenderMultiRecipientMessage mrm = generateRandomMrmMessage(destinationServiceId, deviceId);\n\n      final byte[] sharedMrmDataKey;\n      if (sharedMrmKeyPresent) {\n        sharedMrmDataKey = messagesCache.insertSharedMultiRecipientMessagePayload(mrm).join();\n      } else {\n        sharedMrmDataKey = new byte[]{1};\n      }\n\n      final UUID mrmMessageGuid = UUID.randomUUID();\n      final MessageProtos.Envelope mrmMessage = generateRandomMessage(mrmMessageGuid, destinationServiceId, true)\n          .toBuilder()\n          // clear some things added by the helper\n          .clearContent()\n          .setSharedMrmKey(ByteString.copyFrom(sharedMrmDataKey))\n          .build();\n      messagesCache.insert(mrmMessageGuid, destinationUuid, deviceId, mrmMessage).join();\n\n      final List<MessageProtos.Envelope> messages =\n          Flux.from(messagesCache.getMessagesToPersist(destinationUuid, deviceId))\n              .collectList()\n              .blockOptional()\n              .orElseThrow();\n\n      if (!sharedMrmKeyPresent) {\n        assertEquals(1, messages.size());\n      } else {\n        assertEquals(2, messages.size());\n\n        assertEquals(mrmMessage.toBuilder()\n                .clearSharedMrmKey()\n                .setContent(ByteString.copyFrom(\n                    mrm.messageForRecipient(mrm.getRecipients().get(destinationServiceId.toLibsignal()))))\n                .build(),\n            messages.getLast());\n      }\n\n      assertEquals(message.toBuilder()\n              .setServerGuid(messageGuid.toString())\n              .build(),\n          messages.getFirst());\n    }\n\n    private List<MessageProtos.Envelope> get(final UUID destinationUuid, final byte destinationDeviceId,\n        final int messageCount) {\n      return Flux.from(messagesCache.get(destinationUuid, destinationDeviceId))\n          .take(messageCount, true)\n          .collectList()\n          .block();\n    }\n  }\n\n  @Nested\n  class WithMockCluster {\n\n    private MessagesCache messagesCache;\n    private RedisAdvancedClusterReactiveCommands<byte[], byte[]> reactiveCommands;\n    private RedisAdvancedClusterAsyncCommands<byte[], byte[]> asyncCommands;\n    private Scheduler messageDeliveryScheduler;\n\n    @SuppressWarnings(\"unchecked\")\n    @BeforeEach\n    void setup() throws Exception {\n      reactiveCommands = mock(RedisAdvancedClusterReactiveCommands.class);\n      asyncCommands = mock(RedisAdvancedClusterAsyncCommands.class);\n      final FaultTolerantRedisClusterClient mockCluster = RedisClusterHelper.builder()\n          .binaryReactiveCommands(reactiveCommands)\n          .binaryAsyncCommands(asyncCommands)\n          .build();\n\n      messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n\n      messagesCache = new MessagesCache(mockCluster, messageDeliveryScheduler,\n          Executors.newSingleThreadExecutor(), mock(ScheduledExecutorService.class), Clock.systemUTC(), mock(ExperimentEnrollmentManager.class));\n    }\n\n    @AfterEach\n    void teardown() {\n      StepVerifier.resetDefaultTimeout();\n      messageDeliveryScheduler.dispose();\n    }\n\n    @Test\n    @Disabled(\"flaky test\")\n    void testGetAllMessagesLimitsAndBackpressure() {\n      // this test makes sure that we don’t fetch and buffer all messages from the cache when the publisher\n      // is subscribed. Rather, we should be fetching in pages to satisfy downstream requests, so that memory usage\n      // is limited to few pages of messages\n\n      // we use a combination of Flux.just() and TestPublishers to control when data is “fetched” and emitted from the\n      // cache. The initial Flux.just()s are pages that are readily available, on demand. By design, there are more of\n      // these pages than the initial prefetch. The publishers allow us to create extra demand but defer producing\n      // values to satisfy the demand until later on.\n\n      final TestPublisher<Object> page4Publisher = TestPublisher.create();\n      final TestPublisher<Object> page56Publisher = TestPublisher.create();\n      final TestPublisher<Object> emptyFinalPagePublisher = TestPublisher.create();\n\n      final Deque<List<byte[]>> pages = new ArrayDeque<>();\n      pages.add(generatePage());\n      pages.add(generatePage());\n      pages.add(generatePage());\n      pages.add(generatePage());\n      // make sure that stale ephemeral messages are also produced by calls to getAllMessages()\n      pages.add(generateStaleEphemeralPage());\n      pages.add(generatePage());\n\n      when(reactiveCommands.evalsha(any(), any(), any(), any()))\n          .thenReturn(Flux.just(pages.pop()))\n          .thenReturn(Flux.just(pages.pop()))\n          .thenReturn(Flux.just(pages.pop()))\n          .thenReturn(Flux.from(page4Publisher))\n          .thenReturn(Flux.from(page56Publisher))\n          .thenReturn(Flux.from(emptyFinalPagePublisher))\n          .thenReturn(Flux.empty());\n\n      final Flux<?> allMessages = messagesCache.getAllMessages(UUID.randomUUID(), Device.PRIMARY_ID, 0, 10, false);\n\n      // Why initialValue = 3?\n      // 1. messagesCache.getAllMessages() above produces the first call\n      // 2. when we subscribe, the prefetch of 1 results in `expand()`, which produces a second call\n      // 3. there is an implicit “low tide mark” of 1, meaning there will be an extra call to replenish when there is\n      //    1 value remaining\n      final AtomicInteger expectedReactiveCommandInvocations = new AtomicInteger(3);\n\n      StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));\n\n      final int page = 100;\n      final int halfPage = page / 2;\n\n      // in order to fully control demand and separate the prefetch mechanics, initially subscribe with a request of 0\n      StepVerifier.create(allMessages, 0)\n          .expectSubscription()\n          .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.get())).evalsha(any(), any(),\n              any(), any()))\n          .thenRequest(halfPage) // page 0.5 requested\n          .expectNextCount(halfPage) // page 0.5 produced\n          // page 0.5 produced, 1.5 remain, so no additional interactions with the cache cluster\n          .then(() -> verify(reactiveCommands, atMost(expectedReactiveCommandInvocations.get())).evalsha(any(),\n              any(), any(), any()))\n          .then(page4Publisher::assertWasNotRequested)\n          .thenRequest(page) // page 1.5 requested\n          .expectNextCount(page) // page 1.5 produced\n\n          // we now have produced 1.5 pages, have 0.5 buffered, and two more have been prefetched.\n          // after producing more than a full page, we’ll need to replenish from the cache.\n          // future requests will depend on sink emitters.\n          // also NB: times() checks cumulative calls, hence addAndGet\n          .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(1))).evalsha(any(),\n              any(), any(), any()))\n          .then(page4Publisher::assertWasSubscribed)\n          .thenRequest(page + halfPage) // page 3 requested\n          .expectNextCount(page + halfPage) // page 1.5–3 produced\n\n          .thenRequest(halfPage) // page 3.5 requested\n          .then(page56Publisher::assertWasNotRequested)\n          .then(() -> page4Publisher.emit(pages.pop()))\n          .expectNextCount(halfPage) // page 3.5 produced\n          .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(1))).evalsha(any(),\n              any(), any(), any()))\n          .then(page56Publisher::assertWasSubscribed)\n\n          .thenRequest(page) // page 4.5 requested\n          .expectNextCount(halfPage) // page 4 produced\n\n          .thenRequest(page * 4) // request more demand than we will ultimately satisfy\n\n          .then(() -> page56Publisher.next(pages.pop()).next(pages.pop()).complete())\n          .expectNextCount(page + page) // page 5 and 6 produced\n          .then(emptyFinalPagePublisher::complete)\n          // confirm that cache calls increased by 2: one for page 5-and-6 (we got a two-fer in next(pop()).next(pop()),\n          //   and one for the final, empty page\n          .then(() -> verify(reactiveCommands, times(expectedReactiveCommandInvocations.addAndGet(2))).evalsha(any(),\n              any(), any(),\n              any()))\n          .expectComplete()\n          .log()\n          .verify();\n\n      // make sure that we consumed all the pages, especially in case of refactoring\n      assertTrue(pages.isEmpty());\n    }\n\n    @Test\n    void testGetDiscardsEphemeralMessages() {\n      final Deque<List<byte[]>> pages = new ArrayDeque<>();\n      pages.add(generatePage());\n      pages.add(generatePage());\n      pages.add(generateStaleEphemeralPage());\n\n      when(reactiveCommands.evalsha(any(), any(), any(byte[][].class), any(byte[][].class)))\n          .thenReturn(Flux.just(pages.pop()))\n          .thenReturn(Flux.just(pages.pop()))\n          .thenReturn(Flux.just(pages.pop()))\n          .thenReturn(Flux.empty());\n\n      final AsyncCommand<?, ?, ?> removeSuccess = new AsyncCommand<>(mock(RedisCommand.class));\n      removeSuccess.complete();\n\n      when(asyncCommands.evalsha(any(), any(), any(byte[][].class), any(byte[][].class)))\n          .thenReturn((RedisFuture) removeSuccess);\n\n      final Publisher<?> allMessages = messagesCache.get(UUID.randomUUID(), Device.PRIMARY_ID);\n\n      StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));\n\n      // async commands are used for remove(), and nothing should happen until we are subscribed\n      verify(asyncCommands, never()).evalsha(any(), any(), any(byte[][].class), any(byte[][].class));\n      // the reactive commands will be called once, to prep the first page fetch (but no remote request would actually be sent)\n      verify(reactiveCommands, times(1)).evalsha(any(), any(), any(byte[][].class), any(byte[][].class));\n\n      StepVerifier.create(allMessages)\n          .expectSubscription()\n          .expectNextCount(200)\n          .expectComplete()\n          .log()\n          .verify();\n\n      assertTrue(pages.isEmpty());\n      verify(asyncCommands, atLeast(1)).evalsha(any(), any(), any(byte[][].class), any(byte[][].class));\n    }\n\n    @Test\n    void testGetRetries() {\n      final List<byte[]> page = generatePage();\n\n      final AtomicBoolean emittedError = new AtomicBoolean(false);\n      final AtomicBoolean emittedPage = new AtomicBoolean(false);\n\n      when(reactiveCommands.evalsha(any(), any(), any(byte[][].class), any(byte[][].class)))\n          .thenReturn(Flux.defer(() -> {\n            if (emittedError.compareAndSet(false, true)) {\n              return Flux.error(new RedisCommandTimeoutException(\"Timeout\"));\n            } else if (emittedPage.compareAndSet(false, true)) {\n              return Flux.just(page);\n            }\n\n            return Flux.empty();\n          }));\n\n      final AsyncCommand<?, ?, ?> removeSuccess = new AsyncCommand<>(mock(RedisCommand.class));\n      removeSuccess.complete();\n\n      when(asyncCommands.evalsha(any(), any(), any(byte[][].class), any(byte[][].class)))\n          .thenReturn((RedisFuture) removeSuccess);\n\n      final Publisher<?> allMessages = messagesCache.get(UUID.randomUUID(), Device.PRIMARY_ID);\n\n      StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));\n\n      // async commands are used for remove(), and nothing should happen until we are subscribed\n      verify(asyncCommands, never()).evalsha(any(), any(), any(byte[][].class), any(byte[][].class));\n      // the reactive commands will be called once, to prep the first page fetch (but no remote request would actually be sent)\n      verify(reactiveCommands, times(1)).evalsha(any(), any(), any(byte[][].class), any(byte[][].class));\n\n      StepVerifier.create(allMessages)\n          .expectSubscription()\n          .expectNextCount(page.size() / 2)\n          .expectComplete()\n          .log()\n          .verify();\n    }\n\n    private List<byte[]> generatePage() {\n      final List<byte[]> messagesAndIds = new ArrayList<>();\n\n      for (int i = 0; i < 100; i++) {\n        final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID(), true);\n        messagesAndIds.add(envelope.toByteArray());\n        messagesAndIds.add(String.valueOf(serialTimestamp).getBytes());\n      }\n\n      return messagesAndIds;\n    }\n\n    private List<byte[]> generateStaleEphemeralPage() {\n      final List<byte[]> messagesAndIds = new ArrayList<>();\n\n      for (int i = 0; i < 100; i++) {\n        final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID(), true)\n            .toBuilder().setEphemeral(true).build();\n        messagesAndIds.add(envelope.toByteArray());\n        messagesAndIds.add(String.valueOf(serialTimestamp).getBytes());\n      }\n\n      return messagesAndIds;\n    }\n  }\n\n  private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid, final boolean sealedSender) {\n    return generateRandomMessage(messageGuid, new AciServiceIdentifier(UUID.randomUUID()), sealedSender, false,\n        serialTimestamp++);\n  }\n\n  private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid,\n      final ServiceIdentifier destinationServiceId,\n      final boolean sealedSender) {\n\n    return generateRandomMessage(messageGuid, destinationServiceId, sealedSender, false, serialTimestamp++);\n  }\n\n  private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid,\n      final ServiceIdentifier destinationServiceId,\n      final boolean sealedSender,\n      final boolean ephemeral,\n      final long timestamp) {\n    final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(timestamp)\n        .setContent(ByteString.copyFromUtf8(RandomStringUtils.secure().nextAlphanumeric(256)))\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .setServerGuid(messageGuid.toString())\n        .setDestinationServiceId(destinationServiceId.toServiceIdentifierString())\n        .setEphemeral(ephemeral);\n\n    if (!sealedSender) {\n      envelopeBuilder.setSourceDevice(random.nextInt(Device.MAXIMUM_DEVICE_ID) + 1)\n          .setSourceServiceId(UUID.randomUUID().toString());\n    }\n\n    return envelopeBuilder.build();\n  }\n\n  static SealedSenderMultiRecipientMessage generateRandomMrmMessage(\n      Map<ServiceIdentifier, List<Byte>> destinations) {\n\n    try {\n      final ByteBuffer prefix = ByteBuffer.allocate(7);\n      prefix.put((byte) 0x23); // version\n      writeVarint(prefix, destinations.size()); // recipient count\n      prefix.flip();\n\n      List<ByteBuffer> recipients = new ArrayList<>(destinations.size());\n\n      for (Map.Entry<ServiceIdentifier, List<Byte>> serviceIdentifierAndDeviceIds : destinations.entrySet()) {\n\n        final ServiceIdentifier destination = serviceIdentifierAndDeviceIds.getKey();\n        final List<Byte> deviceIds = serviceIdentifierAndDeviceIds.getValue();\n\n        assert deviceIds.size() < 255;\n\n        final ByteBuffer recipient = ByteBuffer.allocate(17 + 3 * deviceIds.size() + 48);\n\n        recipient.put(destination.toFixedWidthByteArray());\n        for (int i = 0; i < deviceIds.size(); i++) {\n          final int hasMore = i == deviceIds.size() - 1 ? 0x0000 : 0x8000;\n          recipient.put(new byte[]{deviceIds.get(i)}); // device ID\n          recipient.putShort((short) ((100 + deviceIds.get(i)) | hasMore)); // registration ID\n        }\n\n        final byte[] keyMaterial = new byte[48];\n        ThreadLocalRandom.current().nextBytes(keyMaterial);\n        recipient.put(keyMaterial);\n\n        recipients.add(recipient);\n      }\n\n      final byte[] commonPayload = new byte[64];\n      ThreadLocalRandom.current().nextBytes(commonPayload);\n\n      final ByteArrayOutputStream baos = new ByteArrayOutputStream();\n      baos.write(prefix.array(), 0, prefix.limit());\n      for (ByteBuffer recipient : recipients) {\n        baos.write(recipient.array());\n      }\n      baos.write(commonPayload);\n\n      return SealedSenderMultiRecipientMessage.parse(baos.toByteArray());\n    } catch (Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  static SealedSenderMultiRecipientMessage generateRandomMrmMessage(ServiceIdentifier destination,\n      byte... deviceIds) {\n\n    final Map<ServiceIdentifier, List<Byte>> destinations = new HashMap<>();\n    destinations.put(destination, Arrays.asList(ArrayUtils.toObject(deviceIds)));\n    return generateRandomMrmMessage(destinations);\n  }\n\n  private static void writeVarint(ByteBuffer bb, long n) {\n    while (n >= 0x80) {\n      bb.put((byte) (n & 0x7F | 0x80));\n      n = n >> 7;\n    }\n    bb.put((byte) (n & 0x7F));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesDynamoDbTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\n\nimport com.google.protobuf.ByteString;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Random;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.reactivestreams.Publisher;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.DevicesHelper;\nimport org.whispersystems.textsecuregcm.tests.util.MessageHelper;\nimport reactor.core.publisher.Flux;\nimport reactor.test.StepVerifier;\n\nclass MessagesDynamoDbTest {\n\n\n  private static final Random random = new Random();\n  private static final MessageProtos.Envelope MESSAGE1;\n  private static final MessageProtos.Envelope MESSAGE2;\n  private static final MessageProtos.Envelope MESSAGE3;\n\n  static {\n    final long serverTimestamp = System.currentTimeMillis();\n    MessageProtos.Envelope.Builder builder = MessageProtos.Envelope.newBuilder();\n    builder.setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER);\n    builder.setClientTimestamp(123456789L);\n    builder.setContent(ByteString.copyFrom(new byte[]{(byte) 0xDE, (byte) 0xAD, (byte) 0xBE, (byte) 0xEF}));\n    builder.setServerGuid(UUID.randomUUID().toString());\n    builder.setServerTimestamp(serverTimestamp);\n    builder.setDestinationServiceId(UUID.randomUUID().toString());\n\n    MESSAGE1 = builder.build();\n\n    builder.setType(MessageProtos.Envelope.Type.CIPHERTEXT);\n    builder.setSourceServiceId(UUID.randomUUID().toString());\n    builder.setSourceDevice(1);\n    builder.setContent(ByteString.copyFromUtf8(\"MOO\"));\n    builder.setServerGuid(UUID.randomUUID().toString());\n    builder.setServerTimestamp(serverTimestamp + 1);\n    builder.setDestinationServiceId(UUID.randomUUID().toString());\n\n    MESSAGE2 = builder.build();\n\n    builder.setType(MessageProtos.Envelope.Type.UNIDENTIFIED_SENDER);\n    builder.clearSourceDevice();\n    builder.clearSourceDevice();\n    builder.setContent(ByteString.copyFromUtf8(\"COW\"));\n    builder.setServerGuid(UUID.randomUUID().toString());\n    builder.setServerTimestamp(serverTimestamp);  // Test same millisecond arrival for two different messages\n    builder.setDestinationServiceId(UUID.randomUUID().toString());\n\n    MESSAGE3 = builder.build();\n  }\n\n  private ExecutorService messageDeletionExecutorService;\n  private MessagesDynamoDb messagesDynamoDb;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.MESSAGES);\n\n  @BeforeEach\n  void setup() {\n    messageDeletionExecutorService = Executors.newSingleThreadExecutor();\n    messagesDynamoDb = new MessagesDynamoDb(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), Tables.MESSAGES.tableName(), Duration.ofDays(14),\n        messageDeletionExecutorService, mock(ExperimentEnrollmentManager.class));\n  }\n\n  @AfterEach\n  void teardown() throws Exception {\n    messageDeletionExecutorService.shutdown();\n    messageDeletionExecutorService.awaitTermination(5, TimeUnit.SECONDS);\n\n    StepVerifier.resetDefaultTimeout();\n  }\n\n  @Test\n  void testSimpleFetchAfterInsert() {\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte destinationDeviceId = (byte) (random.nextInt(Device.MAXIMUM_DEVICE_ID) + 1);\n    final Device destinationDevice = DevicesHelper.createDevice(destinationDeviceId);\n\n    messagesDynamoDb.store(List.of(MESSAGE1, MESSAGE2, MESSAGE3), destinationUuid, destinationDevice);\n\n    final List<MessageProtos.Envelope> messagesStored = load(destinationUuid, destinationDevice,\n        MessagesDynamoDb.RESULT_SET_CHUNK_SIZE);\n    assertThat(messagesStored).isNotNull().hasSize(3);\n    final MessageProtos.Envelope firstMessage =\n        MESSAGE1.getServerGuid().compareTo(MESSAGE3.getServerGuid()) < 0 ? MESSAGE1 : MESSAGE3;\n    final MessageProtos.Envelope secondMessage = firstMessage == MESSAGE1 ? MESSAGE3 : MESSAGE1;\n    assertThat(messagesStored).element(0).isEqualTo(firstMessage);\n    assertThat(messagesStored).element(1).isEqualTo(secondMessage);\n    assertThat(messagesStored).element(2).isEqualTo(MESSAGE2);\n  }\n\n  @ParameterizedTest\n  @ValueSource(ints = {10, 100, 100, 1_000, 3_000})\n  void testLoadManyAfterInsert(final int messageCount) {\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte destinationDeviceId = (byte) (random.nextInt(Device.MAXIMUM_DEVICE_ID) + 1);\n    final Device destinationDevice = DevicesHelper.createDevice(destinationDeviceId);\n\n    final List<MessageProtos.Envelope> messages = new ArrayList<>(messageCount);\n    for (int i = 0; i < messageCount; i++) {\n      messages.add(MessageHelper.createMessage(UUID.randomUUID(), Device.PRIMARY_ID, destinationUuid, (i + 1L) * 1000,\n          \"message \" + i));\n    }\n\n    messagesDynamoDb.store(messages, destinationUuid, destinationDevice);\n\n    final Publisher<?> fetchedMessages = messagesDynamoDb.load(destinationUuid, destinationDevice, null);\n\n    final long firstRequest = Math.min(10, messageCount);\n    StepVerifier.setDefaultTimeout(Duration.ofSeconds(15));\n\n    StepVerifier.Step<?> step = StepVerifier.create(fetchedMessages, 0)\n        .expectSubscription()\n        .thenRequest(firstRequest)\n        .expectNextCount(firstRequest);\n\n    if (messageCount > firstRequest) {\n      step = step.thenRequest(messageCount)\n          .expectNextCount(messageCount - firstRequest);\n    }\n\n    step.thenCancel()\n        .verify();\n  }\n\n  @Test\n  void testLimitedLoad() {\n    final int messageCount = 200;\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte destinationDeviceId = (byte) (random.nextInt(Device.MAXIMUM_DEVICE_ID) + 1);\n    final Device destinationDevice = DevicesHelper.createDevice(destinationDeviceId);\n\n    final List<MessageProtos.Envelope> messages = new ArrayList<>(messageCount);\n    for (int i = 0; i < messageCount; i++) {\n      messages.add(MessageHelper.createMessage(UUID.randomUUID(), Device.PRIMARY_ID, destinationUuid, (i + 1L) * 1000,\n          \"message \" + i));\n    }\n\n    messagesDynamoDb.store(messages, destinationUuid, destinationDevice);\n\n    final int messageLoadLimit = 100;\n    final int halfOfMessageLoadLimit = messageLoadLimit / 2;\n    final Publisher<?> fetchedMessages = messagesDynamoDb.load(destinationUuid, destinationDevice,\n        messageLoadLimit);\n\n    StepVerifier.setDefaultTimeout(Duration.ofSeconds(10));\n\n    final AtomicInteger messagesRemaining = new AtomicInteger(messageLoadLimit);\n\n    StepVerifier.create(fetchedMessages, 0)\n        .expectSubscription()\n        .thenRequest(halfOfMessageLoadLimit)\n        .expectNextCount(halfOfMessageLoadLimit)\n        // the first 100 should be fetched and buffered, but further requests should fail\n        .then(DYNAMO_DB_EXTENSION::resetServer)\n        .thenRequest(halfOfMessageLoadLimit)\n        .expectNextCount(halfOfMessageLoadLimit)\n        // we’ve consumed all the buffered messages, so a single request will fail\n        .thenRequest(1)\n        .expectError()\n        .verify();\n  }\n\n  @Test\n  void testDeleteSingleMessage() throws Exception {\n    final UUID destinationUuid = UUID.randomUUID();\n    final UUID secondDestinationUuid = UUID.randomUUID();\n    final Device primary = DevicesHelper.createDevice((byte) 1);\n    final Device device2 = DevicesHelper.createDevice((byte) 2);\n\n    messagesDynamoDb.store(List.of(MESSAGE1), destinationUuid, primary);\n    messagesDynamoDb.store(List.of(MESSAGE2), secondDestinationUuid, primary);\n    messagesDynamoDb.store(List.of(MESSAGE3), destinationUuid, device2);\n\n    assertThat(load(destinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)\n        .element(0).isEqualTo(MESSAGE1);\n    assertThat(load(destinationUuid, device2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()\n        .hasSize(1)\n        .element(0).isEqualTo(MESSAGE3);\n    assertThat(load(secondDestinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()\n        .hasSize(1).element(0).isEqualTo(MESSAGE2);\n\n    messagesDynamoDb.deleteMessage(secondDestinationUuid, primary,\n        UUID.fromString(MESSAGE2.getServerGuid()), MESSAGE2.getServerTimestamp()).get(1, TimeUnit.SECONDS);\n\n    assertThat(load(destinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull().hasSize(1)\n        .element(0).isEqualTo(MESSAGE1);\n    assertThat(load(destinationUuid, device2, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()\n        .hasSize(1)\n        .element(0).isEqualTo(MESSAGE3);\n    assertThat(load(secondDestinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE)).isNotNull()\n        .isEmpty();\n  }\n\n  private List<MessageProtos.Envelope> load(final UUID destinationUuid, final Device destinationDevice,\n      final int count) {\n    return Flux.from(messagesDynamoDb.load(destinationUuid, destinationDevice, count))\n        .take(count, true)\n        .collectList()\n        .block();\n  }\n\n  @Test\n  void testLazyMessageDeletion() throws Exception {\n    final UUID destinationUuid = UUID.randomUUID();\n    final Device primary = DevicesHelper.createDevice((byte) 1);\n    primary.setCreated(System.currentTimeMillis());\n\n    messagesDynamoDb.store(List.of(MESSAGE1, MESSAGE2), destinationUuid, primary);\n    assertThat(load(destinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE))\n        .as(\"load should return all messages stored\").containsOnly(MESSAGE1, MESSAGE2);\n\n    messagesDynamoDb.deleteMessage(destinationUuid, primary, UUID.fromString(MESSAGE1.getServerGuid()), MESSAGE1.getServerTimestamp())\n        .get(1, TimeUnit.SECONDS);\n    assertThat(load(destinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE))\n        .as(\"deleting message by guid and timestamp should work\").containsExactly(MESSAGE2);\n\n    primary.setCreated(primary.getCreated() + 1000);\n    assertThat(load(destinationUuid, primary, MessagesDynamoDb.RESULT_SET_CHUNK_SIZE))\n        .as(\"devices with the same id but different create timestamps should see no messages\")\n        .isEmpty();\n  }\n\n  @Test\n  void mayHaveMessages() {\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte destinationDeviceId = (byte) (random.nextInt(Device.MAXIMUM_DEVICE_ID) + 1);\n    final Device destinationDevice = DevicesHelper.createDevice(destinationDeviceId);\n\n    assertThat(messagesDynamoDb.mayHaveMessages(destinationUuid, destinationDevice).join()).isFalse();\n\n    messagesDynamoDb.store(List.of(MESSAGE1, MESSAGE2, MESSAGE3), destinationUuid, destinationDevice);\n\n    assertThat(messagesDynamoDb.mayHaveMessages(destinationUuid, destinationDevice).join()).isTrue();\n  }\n\n  @Test\n  void mayHaveUrgentMessages() {\n    final UUID destinationUuid = UUID.randomUUID();\n    final byte destinationDeviceId = (byte) (random.nextInt(Device.MAXIMUM_DEVICE_ID) + 1);\n    final Device destinationDevice = DevicesHelper.createDevice(destinationDeviceId);\n\n    assertThat(messagesDynamoDb.mayHaveUrgentMessages(destinationUuid, destinationDevice).join()).isFalse();\n\n    // used as the stable sort key, and the urgent message should be sorted last\n    int serverTimestamp = 1;\n    {\n      final MessageProtos.Envelope nonUrgentMessage = MessageProtos.Envelope.newBuilder()\n          .setUrgent(false)\n          .setServerGuid(UUID.randomUUID().toString())\n          .setDestinationServiceId(destinationUuid.toString())\n          .setServerTimestamp(serverTimestamp++)\n          .build();\n\n      messagesDynamoDb.store(List.of(nonUrgentMessage), destinationUuid, destinationDevice);\n    }\n\n    assertThat(messagesDynamoDb.mayHaveUrgentMessages(destinationUuid, destinationDevice).join()).isFalse();\n\n    {\n      final List<MessageProtos.Envelope> messages = new ArrayList<>();\n      // store more non-urgent messages\n      for (int i = 0; i < MessagesDynamoDb.MAY_HAVE_URGENT_MESSAGES_QUERY_LIMIT * 5; i++) {\n        messages.add(MessageProtos.Envelope.newBuilder()\n            .setUrgent(false)\n            .setServerGuid(UUID.randomUUID().toString())\n            .setDestinationServiceId(destinationUuid.toString())\n                .setServerTimestamp(serverTimestamp++)\n            .build());\n      }\n\n      // and one urgent message\n      messages.add(MessageProtos.Envelope.newBuilder()\n          .setUrgent(true)\n          .setServerGuid(UUID.randomUUID().toString())\n          .setDestinationServiceId(destinationUuid.toString())\n          .setServerTimestamp(serverTimestamp++)\n          .build());\n\n      messagesDynamoDb.store(messages, destinationUuid, destinationDevice);\n    }\n\n    assertThat(messagesDynamoDb.mayHaveUrgentMessages(destinationUuid, destinationDevice).join()).isTrue();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/MessagesManagerTest.java",
    "content": "/*\n * Copyright 2021-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ThreadLocalRandom;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.signal.libsignal.protocol.InvalidMessageException;\nimport org.signal.libsignal.protocol.InvalidVersionException;\nimport org.signal.libsignal.protocol.SealedSenderMultiRecipientMessage;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.tests.util.MultiRecipientMessageHelper;\nimport org.whispersystems.textsecuregcm.tests.util.TestRecipient;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport reactor.core.publisher.Mono;\n\nclass MessagesManagerTest {\n\n  private final MessagesDynamoDb messagesDynamoDb = mock(MessagesDynamoDb.class);\n  private final MessagesCache messagesCache = mock(MessagesCache.class);\n  private final ReportMessageManager reportMessageManager = mock(ReportMessageManager.class);\n\n  private static final TestClock CLOCK = TestClock.pinned(Instant.now());\n\n  private final MessagesManager messagesManager = new MessagesManager(messagesDynamoDb, messagesCache,\n      mock(RedisMessageAvailabilityManager.class), reportMessageManager, Executors.newSingleThreadExecutor(), CLOCK);\n\n  @BeforeEach\n  void setUp() {\n    when(messagesCache.insert(any(), any(), anyByte(), any())).thenReturn(CompletableFuture.completedFuture(true));\n  }\n\n  @Test\n  void insert() {\n    final UUID sourceAci = UUID.randomUUID();\n    final Envelope message = Envelope.newBuilder()\n        .setSourceServiceId(sourceAci.toString())\n        .build();\n\n    final UUID destinationUuid = UUID.randomUUID();\n\n    messagesManager.insert(destinationUuid, Map.of(Device.PRIMARY_ID, message));\n\n    verify(reportMessageManager).store(eq(sourceAci.toString()), any(UUID.class));\n\n    final Envelope syncMessage = Envelope.newBuilder(message)\n        .setSourceServiceId(destinationUuid.toString())\n        .build();\n\n    messagesManager.insert(destinationUuid, Map.of(Device.PRIMARY_ID, syncMessage));\n\n    verifyNoMoreInteractions(reportMessageManager);\n  }\n\n  @Test\n  void insertMultiRecipientMessage() throws InvalidMessageException, InvalidVersionException {\n    final ServiceIdentifier singleDeviceAccountAciServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final ServiceIdentifier singleDeviceAccountPniServiceIdentifier = new PniServiceIdentifier(UUID.randomUUID());\n    final ServiceIdentifier multiDeviceAccountAciServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n    final ServiceIdentifier unresolvedAccountAciServiceIdentifier = new AciServiceIdentifier(UUID.randomUUID());\n\n    final Account singleDeviceAccount = mock(Account.class);\n    final Account multiDeviceAccount = mock(Account.class);\n\n    when(singleDeviceAccount.getIdentifier(IdentityType.ACI))\n        .thenReturn(singleDeviceAccountAciServiceIdentifier.uuid());\n\n    when(multiDeviceAccount.getIdentifier(IdentityType.ACI))\n        .thenReturn(multiDeviceAccountAciServiceIdentifier.uuid());\n\n    final byte[] multiRecipientMessageBytes = MultiRecipientMessageHelper.generateMultiRecipientMessage(List.of(\n        new TestRecipient(singleDeviceAccountAciServiceIdentifier, Device.PRIMARY_ID, 1, new byte[48]),\n        new TestRecipient(multiDeviceAccountAciServiceIdentifier, Device.PRIMARY_ID, 2, new byte[48]),\n        new TestRecipient(multiDeviceAccountAciServiceIdentifier, (byte) (Device.PRIMARY_ID + 1), 3, new byte[48]),\n        new TestRecipient(unresolvedAccountAciServiceIdentifier, Device.PRIMARY_ID, 4, new byte[48]),\n        new TestRecipient(singleDeviceAccountPniServiceIdentifier, Device.PRIMARY_ID, 5, new byte[48])\n    ));\n\n    final SealedSenderMultiRecipientMessage multiRecipientMessage =\n        SealedSenderMultiRecipientMessage.parse(multiRecipientMessageBytes);\n\n    final Map<SealedSenderMultiRecipientMessage.Recipient, Account> resolvedRecipients = new HashMap<>();\n\n    multiRecipientMessage.getRecipients().forEach(((serviceId, recipient) -> {\n      if (serviceId.getRawUUID().equals(singleDeviceAccountAciServiceIdentifier.uuid()) ||\n          serviceId.getRawUUID().equals(singleDeviceAccountPniServiceIdentifier.uuid())) {\n        resolvedRecipients.put(recipient, singleDeviceAccount);\n      } else if (serviceId.getRawUUID().equals(multiDeviceAccountAciServiceIdentifier.uuid())) {\n        resolvedRecipients.put(recipient, multiDeviceAccount);\n      }\n    }));\n\n    final Map<Account, Map<Byte, Boolean>> expectedPresenceByAccountAndDeviceId = Map.of(\n        singleDeviceAccount, Map.of(Device.PRIMARY_ID, true),\n        multiDeviceAccount, Map.of(Device.PRIMARY_ID, false, (byte) (Device.PRIMARY_ID + 1), true)\n    );\n\n    final Map<UUID, Map<Byte, Boolean>> presenceByAccountIdentifierAndDeviceId = Map.of(\n        singleDeviceAccountAciServiceIdentifier.uuid(), Map.of(Device.PRIMARY_ID, true),\n        multiDeviceAccountAciServiceIdentifier.uuid(), Map.of(Device.PRIMARY_ID, false, (byte) (Device.PRIMARY_ID + 1), true)\n    );\n\n    final byte[] sharedMrmKey = \"shared-mrm-key\".getBytes(StandardCharsets.UTF_8);\n\n    when(messagesCache.insertSharedMultiRecipientMessagePayload(multiRecipientMessage))\n        .thenReturn(CompletableFuture.completedFuture(sharedMrmKey));\n\n    when(messagesCache.insert(any(), any(), anyByte(), any()))\n        .thenAnswer(invocation -> {\n          final UUID accountIdentifier = invocation.getArgument(1);\n          final byte deviceId = invocation.getArgument(2);\n\n          return CompletableFuture.completedFuture(\n              presenceByAccountIdentifierAndDeviceId.getOrDefault(accountIdentifier, Collections.emptyMap())\n                  .getOrDefault(deviceId, false));\n        });\n\n    final long clientTimestamp = System.currentTimeMillis();\n    final boolean isStory = ThreadLocalRandom.current().nextBoolean();\n    final boolean isEphemeral = ThreadLocalRandom.current().nextBoolean();\n    final boolean isUrgent = ThreadLocalRandom.current().nextBoolean();\n\n    final Envelope.Builder expectedEnvelopeBuilder = Envelope.newBuilder()\n        .setType(Envelope.Type.UNIDENTIFIED_SENDER)\n        .setClientTimestamp(clientTimestamp)\n        .setServerTimestamp(CLOCK.millis())\n        .setEphemeral(isEphemeral)\n        .setUrgent(isUrgent)\n        .setSharedMrmKey(ByteString.copyFrom(sharedMrmKey));\n    if (isStory) {\n        expectedEnvelopeBuilder.setStory(true);\n    }\n    final Envelope prototypeExpectedMessage = expectedEnvelopeBuilder.build();\n\n    assertEquals(expectedPresenceByAccountAndDeviceId,\n        messagesManager.insertMultiRecipientMessage(multiRecipientMessage, resolvedRecipients, clientTimestamp, isStory, isEphemeral, isUrgent).join());\n\n    verify(messagesCache).insert(any(),\n        eq(singleDeviceAccountAciServiceIdentifier.uuid()),\n        eq(Device.PRIMARY_ID),\n        eq(prototypeExpectedMessage.toBuilder().setDestinationServiceId(singleDeviceAccountAciServiceIdentifier.toServiceIdentifierString()).build()));\n\n    verify(messagesCache).insert(any(),\n        eq(singleDeviceAccountAciServiceIdentifier.uuid()),\n        eq(Device.PRIMARY_ID),\n        eq(prototypeExpectedMessage.toBuilder().setDestinationServiceId(singleDeviceAccountPniServiceIdentifier.toServiceIdentifierString()).build()));\n\n    verify(messagesCache).insert(any(),\n        eq(multiDeviceAccountAciServiceIdentifier.uuid()),\n        eq((byte) (Device.PRIMARY_ID + 1)),\n        eq(prototypeExpectedMessage.toBuilder().setDestinationServiceId(multiDeviceAccountAciServiceIdentifier.toServiceIdentifierString()).build()));\n\n    verify(messagesCache, never()).insert(any(),\n        eq(unresolvedAccountAciServiceIdentifier.uuid()),\n        anyByte(),\n        any());\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"false, false, false\",\n      \"false, true, true\",\n      \"true, false, true\",\n      \"true, true, true\"\n  })\n  void mayHaveMessages(final boolean hasCachedMessages, final boolean hasPersistedMessages, final boolean expectMayHaveMessages) {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    when(messagesCache.hasMessagesAsync(accountIdentifier, Device.PRIMARY_ID))\n        .thenReturn(CompletableFuture.completedFuture(hasCachedMessages));\n\n    when(messagesDynamoDb.mayHaveMessages(accountIdentifier, device))\n        .thenReturn(CompletableFuture.completedFuture(hasPersistedMessages));\n\n    if (hasCachedMessages) {\n      verifyNoInteractions(messagesDynamoDb);\n    }\n\n    assertEquals(expectMayHaveMessages, messagesManager.mayHaveMessages(accountIdentifier, device).join());\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \",,\",\n      \"1,,1\",\n      \",1,1\",\n      \"2,1,1\",\n      \"1,2,2\"\n  })\n  public void oldestMessageTimestamp(Long oldestCached, Long oldestPersisted, Long expected) {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    when(messagesCache.getEarliestUndeliveredTimestamp(accountIdentifier, Device.PRIMARY_ID))\n        .thenReturn(oldestCached == null ? Mono.empty() : Mono.just(oldestCached));\n    when(messagesDynamoDb.load(accountIdentifier, device, 1))\n        .thenReturn(oldestPersisted == null\n            ? Mono.empty()\n            : Mono.just(Envelope.newBuilder().setServerTimestamp(oldestPersisted).build()));\n    final Optional<Instant> earliest =\n        messagesManager.getEarliestUndeliveredTimestampForDevice(accountIdentifier, device).join();\n    assertEquals(Optional.ofNullable(expected).map(Instant::ofEpochMilli), earliest);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/OnetimeDonationsManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.time.Duration;\nimport java.time.Instant;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\n\npublic class OnetimeDonationsManagerTest {\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.ONETIME_DONATIONS);\n  private OneTimeDonationsManager oneTimeDonationsManager;\n\n  @BeforeEach\n  void beforeEach() {\n    oneTimeDonationsManager = new OneTimeDonationsManager(\n        DynamoDbExtensionSchema.Tables.ONETIME_DONATIONS.tableName(),\n        Duration.ofDays(90),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient());\n  }\n\n  @Test\n  void testSetGetPaidAtTimestamp() {\n    final String validPaymentIntentId = \"abc\";\n    final Instant paidAt = Instant.ofEpochSecond(1_000_000);\n    final Instant fallBackTimestamp = Instant.ofEpochSecond(2_000_000);\n    oneTimeDonationsManager.putPaidAt(validPaymentIntentId, paidAt).join();\n\n    assertThat(oneTimeDonationsManager.getPaidAt(validPaymentIntentId, fallBackTimestamp).join()).isEqualTo(paidAt);\n    assertThat(oneTimeDonationsManager.getPaidAt(\"invalidPaymentId\", fallBackTimestamp).join()).isEqualTo(fallBackTimestamp);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/PagedSingleUseKEMPreKeyStoreTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.util.Comparator;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.publisher.Flux;\nimport software.amazon.awssdk.core.async.AsyncRequestBody;\nimport software.amazon.awssdk.services.s3.model.ListObjectsV2Request;\nimport software.amazon.awssdk.services.s3.model.PutObjectRequest;\nimport software.amazon.awssdk.services.s3.model.S3Object;\n\nclass PagedSingleUseKEMPreKeyStoreTest {\n\n  private static final int KEY_COUNT = 100;\n  private static final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n  private static final String BUCKET_NAME = \"testbucket\";\n\n  private PagedSingleUseKEMPreKeyStore keyStore;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS);\n\n  @RegisterExtension\n  static final S3LocalStackExtension S3_EXTENSION = new S3LocalStackExtension(BUCKET_NAME);\n\n  @BeforeEach\n  void setUp() {\n    keyStore = new PagedSingleUseKEMPreKeyStore(\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        S3_EXTENSION.getS3Client(),\n        DynamoDbExtensionSchema.Tables.PAGED_PQ_KEYS.tableName(),\n        BUCKET_NAME);\n  }\n\n  @Test\n  void storeTake() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(Optional.empty(), keyStore.take(accountIdentifier, deviceId).join());\n\n    final List<KEMSignedPreKey> preKeys = generateRandomPreKeys();\n    assertDoesNotThrow(() -> keyStore.store(accountIdentifier, deviceId, preKeys).join());\n\n    final List<KEMSignedPreKey> sortedPreKeys = preKeys.stream()\n        .sorted(Comparator.comparing(KEMSignedPreKey::keyId))\n        .toList();\n\n    assertEquals(Optional.of(sortedPreKeys.get(0)), keyStore.take(accountIdentifier, deviceId).join());\n    assertEquals(Optional.of(sortedPreKeys.get(1)), keyStore.take(accountIdentifier, deviceId).join());\n  }\n\n  @Test\n  void storeTwice() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    final List<KEMSignedPreKey> preKeys1 = generateRandomPreKeys();\n    keyStore.store(accountIdentifier, deviceId, preKeys1).join();\n    List<String> oldPages = listPages(accountIdentifier).stream().map(S3Object::key).toList();\n    assertEquals(1, oldPages.size());\n\n    final List<KEMSignedPreKey> preKeys2 = generateRandomPreKeys();\n    keyStore.store(accountIdentifier, deviceId, preKeys2).join();\n    List<String> newPages = listPages(accountIdentifier).stream().map(S3Object::key).toList();\n    assertEquals(1, newPages.size());\n\n    assertNotEquals(oldPages.getFirst(), newPages.getFirst());\n\n    assertEquals(\n        preKeys2.stream().sorted(Comparator.comparing(KEMSignedPreKey::keyId)).toList(),\n\n        IntStream.range(0, preKeys2.size())\n            .mapToObj(i -> keyStore.take(accountIdentifier, deviceId).join())\n            .map(Optional::orElseThrow)\n            .toList());\n\n    assertTrue(keyStore.take(accountIdentifier, deviceId).join().isEmpty());\n\n  }\n\n  @Test\n  void takeAll() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    final List<KEMSignedPreKey> preKeys = generateRandomPreKeys();\n    assertDoesNotThrow(() -> keyStore.store(accountIdentifier, deviceId, preKeys).join());\n\n    final List<KEMSignedPreKey> sortedPreKeys = preKeys.stream()\n        .sorted(Comparator.comparing(KEMSignedPreKey::keyId))\n        .toList();\n\n    for (int i = 0; i < KEY_COUNT; i++) {\n      assertEquals(Optional.of(sortedPreKeys.get(i)), keyStore.take(accountIdentifier, deviceId).join());\n    }\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n    assertTrue(keyStore.take(accountIdentifier, deviceId).join().isEmpty());\n  }\n\n  @Test\n  void getCount() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n\n    final List<KEMSignedPreKey> preKeys = generateRandomPreKeys();\n\n    keyStore.store(accountIdentifier, deviceId, preKeys).join();\n\n    assertEquals(KEY_COUNT, keyStore.getCount(accountIdentifier, deviceId).join());\n\n    for (int i = 0; i < KEY_COUNT; i++) {\n      keyStore.take(accountIdentifier, deviceId).join();\n      assertEquals(KEY_COUNT - (i + 1), keyStore.getCount(accountIdentifier, deviceId).join());\n    }\n  }\n\n  @Test\n  void deleteSingleDevice() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n    assertDoesNotThrow(() -> keyStore.delete(accountIdentifier, deviceId).join());\n\n    final List<KEMSignedPreKey> preKeys = generateRandomPreKeys();\n\n    keyStore.store(accountIdentifier, deviceId, preKeys).join();\n    keyStore.store(accountIdentifier, (byte) (deviceId + 1), preKeys).join();\n\n    assertDoesNotThrow(() -> keyStore.delete(accountIdentifier, deviceId).join());\n\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n    assertEquals(KEY_COUNT, keyStore.getCount(accountIdentifier, (byte) (deviceId + 1)).join());\n\n    final List<S3Object> pages = listPages(accountIdentifier);\n    assertEquals(1, pages.size());\n    assertTrue(pages.getFirst().key().startsWith(\"%s/%s\".formatted(accountIdentifier, deviceId + 1)));\n  }\n\n  @Test\n  void deleteAllDevices() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n    assertDoesNotThrow(() -> keyStore.delete(accountIdentifier).join());\n\n    final List<KEMSignedPreKey> preKeys = generateRandomPreKeys();\n\n    keyStore.store(accountIdentifier, deviceId, preKeys).join();\n    keyStore.store(accountIdentifier, (byte) (deviceId + 1), preKeys).join();\n\n    assertDoesNotThrow(() -> keyStore.delete(accountIdentifier).join());\n\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n    assertEquals(0, keyStore.getCount(accountIdentifier, (byte) (deviceId + 1)).join());\n    assertEquals(0, listPages(accountIdentifier).size());\n  }\n\n  @Test\n  void listPages() {\n    final UUID aci1 = UUID.randomUUID();\n    final UUID aci2 = new UUID(aci1.getMostSignificantBits(), aci1.getLeastSignificantBits() + 1);\n    final byte deviceId = 1;\n\n    keyStore.store(aci1, deviceId, generateRandomPreKeys()).join();\n    keyStore.store(aci1, (byte) (deviceId + 1), generateRandomPreKeys()).join();\n    keyStore.store(aci2, deviceId, generateRandomPreKeys()).join();\n\n    List<DeviceKEMPreKeyPages> stored = keyStore.listStoredPages(1).collectList().block();\n    assertEquals(3, stored.size());\n    for (DeviceKEMPreKeyPages pages : stored) {\n      assertEquals(1, pages.pageIdToLastModified().size());\n    }\n\n    assertEquals(List.of(aci1, aci1, aci2), stored.stream().map(DeviceKEMPreKeyPages::identifier).toList());\n    assertEquals(\n        List.of(deviceId, (byte) (deviceId + 1), deviceId),\n        stored.stream().map(DeviceKEMPreKeyPages::deviceId).toList());\n  }\n\n  @Test\n  void listPagesWithOrphans() {\n    final UUID aci1 = UUID.randomUUID();\n    final UUID aci2 = new UUID(aci1.getMostSignificantBits(), aci1.getLeastSignificantBits() + 1);\n    final byte deviceId = 1;\n\n    // Two orphans\n    keyStore.store(aci1, deviceId, generateRandomPreKeys()).join();\n    writeOrphanedS3Object(aci1, deviceId);\n    writeOrphanedS3Object(aci1, deviceId);\n\n    // No orphans\n    keyStore.store(aci1, (byte) (deviceId + 1), generateRandomPreKeys()).join();\n\n    // One orphan\n    keyStore.store(aci2, deviceId, generateRandomPreKeys()).join();\n    writeOrphanedS3Object(aci2, deviceId);\n\n    // Orphan with no database record\n    writeOrphanedS3Object(aci2, (byte) (deviceId + 2));\n\n    List<DeviceKEMPreKeyPages> stored = keyStore.listStoredPages(1).collectList().block();\n    assertEquals(4, stored.size());\n    \n    assertEquals(\n        List.of(3, 1, 2, 1),\n        stored.stream().map(s -> s.pageIdToLastModified().size()).toList());\n  }\n\n  private void writeOrphanedS3Object(final UUID identifier, final byte deviceId) {\n    S3_EXTENSION.getS3Client()\n        .putObject(PutObjectRequest.builder()\n                .bucket(BUCKET_NAME)\n                .key(\"%s/%s/%s\".formatted(identifier, deviceId, UUID.randomUUID())).build(),\n            AsyncRequestBody.fromBytes(TestRandomUtil.nextBytes(10)))\n        .join();\n  }\n\n  private List<S3Object> listPages(final UUID identifier) {\n    return Flux.from(S3_EXTENSION.getS3Client().listObjectsV2Paginator(ListObjectsV2Request.builder()\n            .bucket(BUCKET_NAME)\n            .prefix(identifier.toString())\n            .build()))\n        .concatMap(response -> Flux.fromIterable(response.contents()))\n        .collectList()\n        .block();\n  }\n\n  @Test\n  void takeSkipsOutOfRangeKeys() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    final KEMSignedPreKey validKey = KeysHelper.signedKEMPreKey(1, IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey outOfRange1 = KeysHelper.signedKEMPreKey(KeyIdUtil.MAX_KEY_ID + 1, IDENTITY_KEY_PAIR);\n    final KEMSignedPreKey outOfRange2 = KeysHelper.signedKEMPreKey(KeyIdUtil.MAX_KEY_ID + 2, IDENTITY_KEY_PAIR);\n\n    keyStore.store(accountIdentifier, deviceId, List.of(outOfRange1, outOfRange2, validKey)).join();\n    assertEquals(Optional.of(validKey), keyStore.take(accountIdentifier, deviceId).join());\n    assertEquals(Optional.empty(), keyStore.take(accountIdentifier, deviceId).join());\n    assertEquals(0, keyStore.getCount(accountIdentifier, deviceId).join());\n\n  }\n\n  private List<KEMSignedPreKey> generateRandomPreKeys() {\n    final Set<Integer> keyIds = new HashSet<>(KEY_COUNT);\n\n    while (keyIds.size() < KEY_COUNT) {\n      keyIds.add(Math.abs(ThreadLocalRandom.current().nextInt()));\n    }\n\n    return keyIds.stream()\n        .map(keyId -> KeysHelper.signedKEMPreKey(keyId, IDENTITY_KEY_PAIR))\n        .toList();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/PersistentTimerTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\n\nimport io.micrometer.core.instrument.Timer;\nimport java.time.Duration;\nimport java.time.Instant;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.util.TestClock;\n\nclass PersistentTimerTest {\n\n  private static final String NAMESPACE = \"namespace\";\n  private static final String KEY = \"key\";\n\n  @RegisterExtension\n  private static final RedisClusterExtension CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n  private TestClock clock;\n  private PersistentTimer timer;\n\n  @BeforeEach\n  public void setup() {\n    clock = TestClock.pinned(Instant.ofEpochSecond(10));\n    timer = new PersistentTimer(CLUSTER_EXTENSION.getRedisCluster(), clock);\n  }\n\n  @Test\n  public void testStop() {\n    PersistentTimer.Sample sample = timer.start(NAMESPACE, KEY).join();\n    final String redisKey = timer.redisKey(NAMESPACE, KEY);\n\n    final String actualStartString = CLUSTER_EXTENSION.getRedisCluster()\n        .withCluster(conn -> conn.sync().get(redisKey));\n    final Instant actualStart = Instant.ofEpochSecond(Long.parseLong(actualStartString));\n    assertThat(actualStart).isEqualTo(clock.instant());\n\n    final long ttl = CLUSTER_EXTENSION.getRedisCluster()\n        .withCluster(conn -> conn.sync().ttl(redisKey));\n\n    assertThat(ttl).isBetween(0L, PersistentTimer.TIMER_TTL.getSeconds());\n\n    Timer mockTimer = mock(Timer.class);\n    clock.pin(clock.instant().plus(Duration.ofSeconds(5)));\n    sample.stop(mockTimer).join();\n    verify(mockTimer).record(Duration.ofSeconds(5));\n\n    final String afterDeletion = CLUSTER_EXTENSION.getRedisCluster()\n        .withCluster(conn -> conn.sync().get(redisKey));\n\n    assertThat(afterDeletion).isNull();\n  }\n\n  @Test\n  public void testNamespace() {\n    Timer mockTimer = mock(Timer.class);\n\n    clock.pin(Instant.ofEpochSecond(10));\n    PersistentTimer.Sample timer1 = timer.start(\"n1\", KEY).join();\n    clock.pin(Instant.ofEpochSecond(20));\n    PersistentTimer.Sample timer2 = timer.start(\"n2\", KEY).join();\n    clock.pin(Instant.ofEpochSecond(30));\n\n    timer2.stop(mockTimer).join();\n    verify(mockTimer).record(Duration.ofSeconds(10));\n\n    timer1.stop(mockTimer).join();\n    verify(mockTimer).record(Duration.ofSeconds(20));\n  }\n\n  @Test\n  public void testMultipleStart() {\n    Timer mockTimer = mock(Timer.class);\n\n    clock.pin(Instant.ofEpochSecond(10));\n    PersistentTimer.Sample timer1 = timer.start(NAMESPACE, KEY).join();\n    clock.pin(Instant.ofEpochSecond(11));\n    PersistentTimer.Sample timer2 = timer.start(NAMESPACE, KEY).join();\n    clock.pin(Instant.ofEpochSecond(12));\n    PersistentTimer.Sample timer3 = timer.start(NAMESPACE, KEY).join();\n\n    clock.pin(Instant.ofEpochSecond(20));\n    timer2.stop(mockTimer).join();\n    verify(mockTimer).record(Duration.ofSeconds(10));\n\n    assertThatNoException().isThrownBy(() -> timer1.stop(mockTimer).join());\n    assertThatNoException().isThrownBy(() -> timer3.stop(mockTimer).join());\n  }\n\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/PhoneNumberIdentifiersTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.io.IOException;\nimport java.util.Collections;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.function.Supplier;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\nimport software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException;\n\nclass PhoneNumberIdentifiersTest {\n\n  @RegisterExtension\n  static DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.PNI);\n\n  private PhoneNumberIdentifiers phoneNumberIdentifiers;\n\n  @BeforeEach\n  void setUp() {\n    phoneNumberIdentifiers = new PhoneNumberIdentifiers(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Tables.PNI.tableName());\n  }\n\n  @Test\n  void getPhoneNumberIdentifier() {\n    final String number = \"+18005551234\";\n    final String differentNumber = \"+18005556789\";\n\n    final UUID firstPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join();\n    final UUID secondPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join();\n\n    assertEquals(firstPni, secondPni);\n    assertNotEquals(firstPni, phoneNumberIdentifiers.getPhoneNumberIdentifier(differentNumber).join());\n  }\n\n  @Test\n  void generatePhoneNumberIdentifier() {\n    final List<String> numbers = List.of(\"+18005551234\", \"+18005556789\");\n    // Should set both PNIs to a new random PNI\n    final UUID pni = phoneNumberIdentifiers.setPniIfRequired(numbers.getFirst(), numbers, Collections.emptyMap()).join();\n\n    assertEquals(pni, phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.getFirst()).join());\n    assertEquals(pni, phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.getLast()).join());\n  }\n\n  @Test\n  void generatePhoneNumberIdentifierOneFormExists() {\n    final String firstNumber = \"+18005551234\";\n    final String secondNumber = \"+18005556789\";\n    final String thirdNumber = \"+1800555456\";\n    final List<String> allNumbers = List.of(firstNumber, secondNumber, thirdNumber);\n\n    // Set one member of the \"same\" numbers to a new PNI\n    final UUID pni = phoneNumberIdentifiers.getPhoneNumberIdentifier(secondNumber).join();\n\n    final Map<String, UUID> existingAssociations = phoneNumberIdentifiers.fetchPhoneNumbers(allNumbers).join();\n    assertEquals(Map.of(secondNumber, pni), existingAssociations);\n\n    assertEquals(pni, phoneNumberIdentifiers.setPniIfRequired(firstNumber, allNumbers, existingAssociations).join());\n\n    for (String number : allNumbers) {\n      assertEquals(pni, phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join());\n    }\n  }\n\n  @Test\n  void getPhoneNumberIdentifierExistingMapping() {\n    final String newFormatBeninE164 = PhoneNumberUtil.getInstance()\n        .format(PhoneNumberUtil.getInstance().getExampleNumber(\"BJ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst(\"01\", \"\");\n    final UUID oldFormatPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(oldFormatBeninE164).join();\n    final UUID newFormatPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(newFormatBeninE164).join();\n    assertEquals(oldFormatPni, newFormatPni);\n  }\n\n  @Test\n  void conflictingExistingPnis() {\n    final String firstNumber = \"+18005551234\";\n    final String secondNumber = \"+18005556789\";\n\n    final UUID firstPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(firstNumber).join();\n    final UUID secondPni = phoneNumberIdentifiers.getPhoneNumberIdentifier(secondNumber).join();\n    assertNotEquals(firstPni, secondPni);\n\n    assertEquals(\n        firstPni,\n        phoneNumberIdentifiers.setPniIfRequired(\n            firstNumber, List.of(firstNumber, secondNumber),\n            phoneNumberIdentifiers.fetchPhoneNumbers(List.of(firstNumber, secondNumber)).join()).join());\n    assertEquals(\n        secondPni,\n        phoneNumberIdentifiers.setPniIfRequired(\n            secondNumber, List.of(secondNumber, firstNumber),\n            phoneNumberIdentifiers.fetchPhoneNumbers(List.of(firstNumber, secondNumber)).join()).join());\n  }\n\n  @Test\n  void conflictOnOriginalNumber() {\n    final List<String> numbers = List.of(\"+18005551234\", \"+18005556789\");\n    // Stale view of database where both numbers have no PNI\n    final Map<String, UUID> existingAssociations = Collections.emptyMap();\n\n    // Both numbers have different PNIs\n    final UUID pni1 = phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.getFirst()).join();\n    final UUID pni2 = phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.getLast()).join();\n    assertNotEquals(pni1, pni2);\n\n    // Should conflict and find that we now have a PNI\n    assertEquals(pni1, phoneNumberIdentifiers.setPniIfRequired(numbers.getFirst(), numbers, existingAssociations).join());\n  }\n\n  @Test\n  void conflictOnAlternateNumber() {\n    final List<String> numbers = List.of(\"+18005551234\", \"+18005556789\");\n    // Stale view of database where both numbers have no PNI\n    final Map<String, UUID> existingAssociations = Collections.emptyMap();\n\n    // the alternate number has a PNI added\n    phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.getLast()).join();\n\n    // Should conflict and fail\n    CompletableFutureTestUtil.assertFailsWithCause(\n        TransactionCanceledException.class,\n        phoneNumberIdentifiers.setPniIfRequired(numbers.getFirst(), numbers, existingAssociations));\n  }\n\n  @Test\n  void multipleAssociations() {\n    final List<String> numbers = List.of(\"+18005550000\", \"+18005551111\", \"+18005552222\", \"+18005553333\", \"+1800555444\");\n\n    // Set pni1={number1, number2}, pni2={number3}, number0 and number 4 unset\n    final UUID pni1 = phoneNumberIdentifiers.setPniIfRequired(numbers.get(1), numbers.subList(1, 3),\n        Collections.emptyMap()).join();\n    final UUID pni2 = phoneNumberIdentifiers.setPniIfRequired(numbers.get(3), List.of(numbers.get(3)),\n        Collections.emptyMap()).join();\n\n    final Map<String, UUID> existingAssociations = phoneNumberIdentifiers.fetchPhoneNumbers(numbers).join();\n    assertEquals(existingAssociations, Map.of(numbers.get(1), pni1, numbers.get(2), pni1, numbers.get(3), pni2));\n\n    // The unmapped phone numbers should map to the arbitrarily selected PNI (which is selected based on the order\n    // of the numbers)\n    assertEquals(pni1, phoneNumberIdentifiers.setPniIfRequired(numbers.get(0), numbers, existingAssociations).join());\n    assertEquals(pni1, phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.get(0)).join());\n    assertEquals(pni1, phoneNumberIdentifiers.getPhoneNumberIdentifier(numbers.get(4)).join());\n  }\n\n  private static class FailN implements Supplier<CompletableFuture<Integer>> {\n    final AtomicInteger numFails;\n\n    FailN(final int numFails) {\n      this.numFails = new AtomicInteger(numFails);\n    }\n\n    @Override\n    public CompletableFuture<Integer> get() {\n      if (numFails.getAndDecrement() == 0) {\n        return CompletableFuture.completedFuture(7);\n      }\n      return CompletableFuture.failedFuture(new IOException(\"test\"));\n    }\n  }\n\n  @Test\n  void testRetry() {\n    assertEquals(7, PhoneNumberIdentifiers.retry(10, IOException.class, new FailN(9)).join());\n\n    CompletableFutureTestUtil.assertFailsWithCause(\n        IOException.class,\n        PhoneNumberIdentifiers.retry(10, IOException.class, new FailN(10)));\n\n    CompletableFutureTestUtil.assertFailsWithCause(\n        IOException.class,\n        PhoneNumberIdentifiers.retry(10, RuntimeException.class, new FailN(1)));\n  }\n\n  @Test\n  void getPhoneNumber() {\n    final String number = \"+18005551234\";\n\n    assertTrue(phoneNumberIdentifiers.getPhoneNumber(UUID.randomUUID()).join().isEmpty());\n\n    final UUID pni = phoneNumberIdentifiers.getPhoneNumberIdentifier(number).join();\n    assertEquals(List.of(number), phoneNumberIdentifiers.getPhoneNumber(pni).join());\n  }\n\n  @Test\n  void regeneratePhoneNumberIdentifierMappings() {\n    // libphonenumber 8.13.50 and on generate new-format numbers for Benin\n    final String newFormatBeninE164 = PhoneNumberUtil.getInstance()\n        .format(PhoneNumberUtil.getInstance().getExampleNumber(\"BJ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n    final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst(\"01\", \"\");\n\n    final UUID phoneNumberIdentifier = UUID.randomUUID();\n\n    final Account account = mock(Account.class);\n    when(account.getNumber()).thenReturn(newFormatBeninE164);\n    when(account.getIdentifier(IdentityType.PNI)).thenReturn(phoneNumberIdentifier);\n\n    phoneNumberIdentifiers.regeneratePhoneNumberIdentifierMappings(account).join();\n\n    assertEquals(phoneNumberIdentifier, phoneNumberIdentifiers.getPhoneNumberIdentifier(newFormatBeninE164).join());\n    assertEquals(phoneNumberIdentifier, phoneNumberIdentifiers.getPhoneNumberIdentifier(oldFormatBeninE164).join());\n    assertEquals(Set.of(newFormatBeninE164, oldFormatBeninE164),\n        new HashSet<>(phoneNumberIdentifiers.getPhoneNumber(phoneNumberIdentifier).join()));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesManagerTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertSame;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.RedisException;\nimport io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ScheduledExecutorService;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKey;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\nimport org.whispersystems.textsecuregcm.tests.util.MockRedisFuture;\nimport org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;\nimport org.whispersystems.textsecuregcm.tests.util.RedisClusterHelper;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport software.amazon.awssdk.services.s3.S3AsyncClient;\nimport software.amazon.awssdk.services.s3.model.DeleteObjectRequest;\n\n@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\npublic class ProfilesManagerTest {\n\n  private Profiles profiles;\n  private RedisAdvancedClusterCommands<String, String> commands;\n  private RedisAdvancedClusterAsyncCommands<String, String> asyncCommands;\n  private S3AsyncClient s3Client;\n\n  private ProfilesManager profilesManager;\n\n  private static final String BUCKET = \"bucket\";\n\n  @BeforeEach\n  void setUp() {\n    //noinspection unchecked\n    commands = mock(RedisAdvancedClusterCommands.class);\n    asyncCommands = mock(RedisAdvancedClusterAsyncCommands.class);\n    final FaultTolerantRedisClusterClient cacheCluster = RedisClusterHelper.builder()\n        .stringCommands(commands)\n        .stringAsyncCommands(asyncCommands)\n        .build();\n\n    profiles = mock(Profiles.class);\n    s3Client = mock(S3AsyncClient.class);\n\n    profilesManager = new ProfilesManager(profiles, cacheCluster, mock(ScheduledExecutorService.class), s3Client, BUCKET);\n  }\n\n  @Test\n  public void testGetProfileInCache() throws InvalidInputException {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(uuid)).serialize();\n    when(commands.hget(eq(ProfilesManager.getCacheKey( uuid)), eq(\"someversion\"))).thenReturn(String.format(\n        \"{\\\"version\\\": \\\"someversion\\\", \\\"name\\\": \\\"%s\\\", \\\"avatar\\\": \\\"someavatar\\\", \\\"commitment\\\":\\\"%s\\\"}\",\n        ProfileTestHelper.encodeToBase64(name),\n        ProfileTestHelper.encodeToBase64(commitment)));\n\n    Optional<VersionedProfile> profile = profilesManager.get(uuid, \"someversion\");\n\n    assertTrue(profile.isPresent());\n    assertArrayEquals(profile.get().name(), name);\n    assertEquals(\"someavatar\", profile.get().avatar());\n    assertArrayEquals(profile.get().commitment(), commitment);\n\n    verify(commands, times(1)).hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"));\n    verifyNoMoreInteractions(commands);\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @Test\n  public void testGetProfileAsyncInCache() throws InvalidInputException {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(uuid)).serialize();\n\n    when(asyncCommands.hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"))).thenReturn(\n        MockRedisFuture.completedFuture(String.format(\"{\\\"version\\\": \\\"someversion\\\", \\\"name\\\": \\\"%s\\\", \\\"avatar\\\": \\\"someavatar\\\", \\\"commitment\\\":\\\"%s\\\"}\",\n            ProfileTestHelper.encodeToBase64(name),\n            ProfileTestHelper.encodeToBase64(commitment))));\n\n    Optional<VersionedProfile> profile = profilesManager.getAsync(uuid, \"someversion\").join();\n\n    assertTrue(profile.isPresent());\n    assertArrayEquals(profile.get().name(), name);\n    assertEquals(\"someavatar\", profile.get().avatar());\n    assertArrayEquals(profile.get().commitment(), commitment);\n\n    verify(asyncCommands, times(1)).hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"));\n    verifyNoMoreInteractions(asyncCommands);\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @Test\n  public void testGetProfileNotInCache() {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final VersionedProfile profile = new VersionedProfile(\"someversion\", name, \"someavatar\", null, null,\n        null, null, \"somecommitment\".getBytes());\n\n    when(commands.hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"))).thenReturn(null);\n    when(profiles.get(eq(uuid), eq(\"someversion\"))).thenReturn(Optional.of(profile));\n\n    Optional<VersionedProfile> retrieved = profilesManager.get(uuid, \"someversion\");\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), profile);\n\n    verify(commands, times(1)).hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"));\n    verify(commands, times(1)).hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString());\n    verifyNoMoreInteractions(commands);\n\n    verify(profiles, times(1)).get(eq(uuid), eq(\"someversion\"));\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @Test\n  public void testGetProfileAsyncNotInCache() {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final VersionedProfile profile = new VersionedProfile(\"someversion\", name, \"someavatar\", null, null,\n        null, null, \"somecommitment\".getBytes());\n\n    when(asyncCommands.hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"))).thenReturn(MockRedisFuture.completedFuture(null));\n    when(asyncCommands.hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString())).thenReturn(MockRedisFuture.completedFuture(null));\n    when(profiles.getAsync(eq(uuid), eq(\"someversion\"))).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));\n\n    Optional<VersionedProfile> retrieved = profilesManager.getAsync(uuid, \"someversion\").join();\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), profile);\n\n    verify(asyncCommands, times(1)).hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"));\n    verify(asyncCommands, times(1)).hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString());\n    verifyNoMoreInteractions(asyncCommands);\n\n    verify(profiles, times(1)).getAsync(eq(uuid), eq(\"someversion\"));\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  public void testGetProfileBrokenCache(final boolean failUpdateCache) {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final VersionedProfile profile = new VersionedProfile(\"someversion\", name, \"someavatar\", null, null,\n        null, null, \"somecommitment\".getBytes());\n\n    when(commands.hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"))).thenThrow(new RedisException(\"Connection lost\"));\n    if (failUpdateCache) {\n      when(commands.hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString()))\n        .thenThrow(new RedisException(\"Connection lost\"));\n    }\n    when(profiles.get(eq(uuid), eq(\"someversion\"))).thenReturn(Optional.of(profile));\n\n    Optional<VersionedProfile> retrieved = profilesManager.get(uuid, \"someversion\");\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), profile);\n\n    verify(commands, times(1)).hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"));\n    verify(commands, times(1)).hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString());\n    verifyNoMoreInteractions(commands);\n\n    verify(profiles, times(1)).get(eq(uuid), eq(\"someversion\"));\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  public void testGetProfileAsyncBrokenCache(final boolean failUpdateCache) {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final VersionedProfile profile = new VersionedProfile(\"someversion\", name, \"someavatar\", null, null,\n        null, null, \"somecommitment\".getBytes());\n\n    when(asyncCommands.hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"))).thenReturn(MockRedisFuture.failedFuture(new RedisException(\"Connection lost\")));\n    when(asyncCommands.hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString()))\n        .thenReturn(failUpdateCache\n            ? MockRedisFuture.failedFuture(new RedisException(\"Connection lost\"))\n            : MockRedisFuture.completedFuture(null));\n    when(profiles.getAsync(eq(uuid), eq(\"someversion\"))).thenReturn(CompletableFuture.completedFuture(Optional.of(profile)));\n\n    Optional<VersionedProfile> retrieved = profilesManager.getAsync(uuid, \"someversion\").join();\n\n    assertTrue(retrieved.isPresent());\n    assertSame(retrieved.get(), profile);\n\n    verify(asyncCommands, times(1)).hget(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"));\n    verify(asyncCommands, times(1)).hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString());\n    verifyNoMoreInteractions(asyncCommands);\n\n    verify(profiles, times(1)).getAsync(eq(uuid), eq(\"someversion\"));\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @Test\n  public void testSet() {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final VersionedProfile profile = new VersionedProfile(\"someversion\", name, \"someavatar\", null, null,\n        null, null, \"somecommitment\".getBytes());\n\n    profilesManager.set(uuid, profile);\n\n    verify(commands, times(1)).hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), any());\n    verifyNoMoreInteractions(commands);\n\n    verify(profiles, times(1)).set(eq(uuid), eq(profile));\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @Test\n  public void testSetAsync() {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final VersionedProfile profile = new VersionedProfile(\"someversion\", name, \"someavatar\", null, null,\n        null, null, \"somecommitment\".getBytes());\n\n    when(asyncCommands.hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), anyString())).thenReturn(MockRedisFuture.completedFuture(null));\n    when(profiles.setAsync(eq(uuid), eq(profile))).thenReturn(CompletableFuture.completedFuture(null));\n\n    profilesManager.setAsync(uuid, profile).join();\n\n    verify(asyncCommands, times(1)).hset(eq(ProfilesManager.getCacheKey(uuid)), eq(\"someversion\"), any());\n    verifyNoMoreInteractions(asyncCommands);\n\n    verify(profiles, times(1)).setAsync(eq(uuid), eq(profile));\n    verifyNoMoreInteractions(profiles);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  public void testDeleteAll(final boolean includeAvatar) {\n    final UUID uuid = UUID.randomUUID();\n\n    final String avatarOne = \"avatar1\";\n    final String avatarTwo = \"avatar2\";\n    when(profiles.deleteAll(uuid)).thenReturn(CompletableFuture.completedFuture(List.of(avatarOne, avatarTwo)));\n    when(asyncCommands.del(ProfilesManager.getCacheKey(uuid))).thenReturn(MockRedisFuture.completedFuture(null));\n    when(s3Client.deleteObject(any(DeleteObjectRequest.class)))\n        .thenReturn(CompletableFuture.completedFuture(null))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException(\"some error\")));\n\n    profilesManager.deleteAll(uuid, includeAvatar).join();\n\n    verify(profiles).deleteAll(uuid);\n    verify(asyncCommands).del(ProfilesManager.getCacheKey(uuid));\n    if (includeAvatar) {\n      verify(s3Client).deleteObject(DeleteObjectRequest.builder()\n          .bucket(BUCKET)\n          .key(avatarOne)\n          .build());\n      verify(s3Client).deleteObject(DeleteObjectRequest.builder()\n          .bucket(BUCKET)\n          .key(avatarTwo)\n          .build());\n    } else {\n      verifyNoInteractions(s3Client);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ProfilesTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.profiles.ProfileKey;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.ProfileTestHelper;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\n\n@Timeout(value = 10, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\npublic class ProfilesTest {\n  private static final UUID ACI = UUID.randomUUID();\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.PROFILES);\n\n  private Profiles profiles;\n  private VersionedProfile validProfile;\n\n  @BeforeEach\n  void setUp() throws InvalidInputException {\n    profiles = new Profiles(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Tables.PROFILES.tableName());\n    final byte[] commitment = new ProfileKey(new byte[32]).getCommitment(new ServiceId.Aci(ACI)).serialize();\n    final String version = \"someVersion\";\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] validAboutEmoji = TestRandomUtil.nextBytes(60);\n    final byte[] validAbout = TestRandomUtil.nextBytes(156);\n    final String avatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n\n    validProfile = new VersionedProfile(version, name, avatar, validAboutEmoji, validAbout, null, phoneNumberSharing, commitment);\n  }\n\n  @Test\n  void testSetGet() {\n    profiles.set(ACI, validProfile);\n\n    Optional<VersionedProfile> retrieved = profiles.get(ACI, validProfile.version());\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(validProfile.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(validProfile.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(validProfile.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(validProfile.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(validProfile.aboutEmoji());\n  }\n\n  @Test\n  void testSetGetAsync() {\n    profiles.setAsync(ACI, validProfile).join();\n\n    Optional<VersionedProfile> retrieved = profiles.getAsync(ACI, validProfile.version()).join();\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(validProfile.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(validProfile.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(validProfile.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(validProfile.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(validProfile.aboutEmoji());\n  }\n\n  @Test\n  void testDeleteReset() throws InvalidInputException {\n    profiles.set(ACI, validProfile);\n\n    profiles.deleteAll(ACI).join();\n\n    final String version = \"someVersion\";\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final String differentAvatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final byte[] differentEmoji = TestRandomUtil.nextBytes(60);\n    final byte[] differentAbout = TestRandomUtil.nextBytes(156);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n    final byte[] commitment = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    VersionedProfile updatedProfile = new VersionedProfile(version, name, differentAvatar,\n        differentEmoji, differentAbout, paymentAddress, phoneNumberSharing, commitment);\n\n    profiles.set(ACI, updatedProfile);\n\n    Optional<VersionedProfile> retrieved = profiles.get(ACI, version);\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(updatedProfile.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(updatedProfile.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(updatedProfile.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(updatedProfile.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(updatedProfile.aboutEmoji());\n  }\n\n  @Test\n  void testSetGetNullOptionalFields() throws InvalidInputException {\n    final String version = \"someVersion\";\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final byte[] commitment = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    VersionedProfile profile = new VersionedProfile(version, name, null, null, null, null, null,\n        commitment);\n    profiles.set(ACI, profile);\n\n    Optional<VersionedProfile> retrieved = profiles.get(ACI, version);\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(profile.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(profile.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(profile.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(profile.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(profile.aboutEmoji());\n  }\n\n  @Test\n  void testSetReplace() throws InvalidInputException {\n    profiles.set(ACI, validProfile);\n\n    Optional<VersionedProfile> retrieved = profiles.get(ACI, validProfile.version());\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(validProfile.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(validProfile.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(validProfile.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(validProfile.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(validProfile.aboutEmoji());\n    assertThat(retrieved.get().paymentAddress()).isNull();\n\n    final byte[] differentName = TestRandomUtil.nextBytes(81);\n    final byte[] differentEmoji = TestRandomUtil.nextBytes(60);\n    final byte[] differentAbout = TestRandomUtil.nextBytes(156);\n    final String differentAvatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final byte[] differentPhoneNumberSharing = TestRandomUtil.nextBytes(29);\n    final byte[] differentCommitment = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    VersionedProfile updated = new VersionedProfile(validProfile.version(), differentName, differentAvatar, differentEmoji, differentAbout, null,\n        differentPhoneNumberSharing, differentCommitment);\n    profiles.set(ACI, updated);\n\n    retrieved = profiles.get(ACI, updated.version());\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(updated.name());\n    assertThat(retrieved.get().about()).isEqualTo(updated.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(updated.aboutEmoji());\n    assertThat(retrieved.get().avatar()).isEqualTo(updated.avatar());\n    assertThat(retrieved.get().phoneNumberSharing()).isEqualTo(updated.phoneNumberSharing());\n\n    // Commitment should be unchanged after an overwrite\n    assertThat(retrieved.get().commitment()).isEqualTo(validProfile.commitment());\n  }\n\n  @Test\n  void testMultipleVersions() throws InvalidInputException {\n    final String versionOne = \"versionOne\";\n    final String versionTwo = \"versionTwo\";\n\n    final byte[] nameOne = TestRandomUtil.nextBytes(81);\n    final byte[] nameTwo = TestRandomUtil.nextBytes(81);\n\n    final String avatarOne = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final String avatarTwo = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n\n    final byte[] aboutEmoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n\n    final byte[] commitmentOne = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n    final byte[] commitmentTwo = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    VersionedProfile profileOne = new VersionedProfile(versionOne, nameOne, avatarOne, null, null,\n        null, null, commitmentOne);\n    VersionedProfile profileTwo = new VersionedProfile(versionTwo, nameTwo, avatarTwo, aboutEmoji, about, null, null, commitmentTwo);\n\n    profiles.set(ACI, profileOne);\n    profiles.set(ACI, profileTwo);\n\n    Optional<VersionedProfile> retrieved = profiles.get(ACI, versionOne);\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(profileOne.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(profileOne.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(profileOne.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(profileOne.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(profileOne.aboutEmoji());\n\n    retrieved = profiles.get(ACI, versionTwo);\n\n    assertThat(retrieved.isPresent()).isTrue();\n    assertThat(retrieved.get().name()).isEqualTo(profileTwo.name());\n    assertThat(retrieved.get().avatar()).isEqualTo(profileTwo.avatar());\n    assertThat(retrieved.get().commitment()).isEqualTo(profileTwo.commitment());\n    assertThat(retrieved.get().about()).isEqualTo(profileTwo.about());\n    assertThat(retrieved.get().aboutEmoji()).isEqualTo(profileTwo.aboutEmoji());\n  }\n\n  @Test\n  void testMissing() {\n    profiles.set(ACI, validProfile);\n    final String missingVersion = \"missingVersion\";\n\n    Optional<VersionedProfile> retrieved = profiles.get(ACI, missingVersion);\n    assertThat(retrieved.isPresent()).isFalse();\n  }\n\n\n  @Test\n  void testDelete() throws InvalidInputException {\n    final String versionOne = \"versionOne\";\n    final String versionTwo = \"versionTwo\";\n    final String versionThree = \"versionThree\";\n\n    final byte[] nameOne = TestRandomUtil.nextBytes(81);\n    final byte[] nameTwo = TestRandomUtil.nextBytes(81);\n\n    final byte[] aboutEmoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n\n    final String avatarOne = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final String avatarTwo = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n\n    final byte[] commitmentOne = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n    final byte[] commitmentTwo = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n    final byte[] commitmentThree = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    VersionedProfile profileOne = new VersionedProfile(versionOne, nameOne, avatarOne, null, null,\n        null, null, commitmentOne);\n    VersionedProfile profileTwo = new VersionedProfile(versionTwo, nameTwo, avatarTwo, aboutEmoji, about, null, null, commitmentTwo);\n    VersionedProfile profileThree = new VersionedProfile(versionThree, nameTwo, null, aboutEmoji, about, null, null,\n        commitmentThree);\n\n    profiles.set(ACI, profileOne);\n    profiles.set(ACI, profileTwo);\n    profiles.set(ACI, profileThree);\n\n    final List<String> avatars = profiles.deleteAll(ACI).join();\n\n    for (String version : List.of(versionOne, versionTwo, versionThree)) {\n      final Optional<VersionedProfile> retrieved = profiles.get(ACI, version);\n      assertThat(retrieved.isPresent()).isFalse();\n    }\n\n    assertThat(avatars.size()).isEqualTo(2);\n    assertThat(avatars.containsAll(List.of(avatarOne, avatarTwo))).isTrue();\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void buildUpdateExpression(final VersionedProfile profile, final String expectedUpdateExpression) {\n    assertEquals(expectedUpdateExpression, Profiles.buildUpdateExpression(profile));\n  }\n\n  private static Stream<Arguments> buildUpdateExpression() throws InvalidInputException {\n    final String version = \"someVersion\";\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final String avatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n    final byte[] commitment = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    return Stream.of(\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, about, paymentAddress, phoneNumberSharing, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji, #paymentAddress = :paymentAddress, #phoneNumberSharing = :phoneNumberSharing\"),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, about, paymentAddress, null, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji, #paymentAddress = :paymentAddress REMOVE #phoneNumberSharing\"),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, about, null, null, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #about = :about, #aboutEmoji = :aboutEmoji REMOVE #paymentAddress, #phoneNumberSharing\"),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, null, null, null, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar, #aboutEmoji = :aboutEmoji REMOVE #about, #paymentAddress, #phoneNumberSharing\"),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, null, null, null, null, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name, #avatar = :avatar REMOVE #about, #aboutEmoji, #paymentAddress, #phoneNumberSharing\"),\n\n        Arguments.of(\n            new VersionedProfile(version, name, null, null, null, null, null, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment), #name = :name REMOVE #avatar, #about, #aboutEmoji, #paymentAddress, #phoneNumberSharing\"),\n\n        Arguments.of(\n            new VersionedProfile(version, null, null, null, null, null, null, commitment),\n            \"SET #commitment = if_not_exists(#commitment, :commitment) REMOVE #name, #avatar, #about, #aboutEmoji, #paymentAddress, #phoneNumberSharing\")\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void buildUpdateExpressionAttributeValues(final VersionedProfile profile, final Map<String, AttributeValue> expectedAttributeValues) {\n    assertEquals(expectedAttributeValues, Profiles.buildUpdateExpressionAttributeValues(profile));\n  }\n\n  private static Stream<Arguments> buildUpdateExpressionAttributeValues() throws InvalidInputException {\n    final String version = \"someVersion\";\n    final byte[] name = TestRandomUtil.nextBytes(81);\n    final String avatar = \"profiles/\" + ProfileTestHelper.generateRandomBase64FromByteArray(16);\n    final byte[] emoji = TestRandomUtil.nextBytes(60);\n    final byte[] about = TestRandomUtil.nextBytes(156);\n    final byte[] paymentAddress = TestRandomUtil.nextBytes(582);\n    final byte[] phoneNumberSharing = TestRandomUtil.nextBytes(29);\n    final byte[] commitment = new ProfileKey(TestRandomUtil.nextBytes(32)).getCommitment(new ServiceId.Aci(ACI)).serialize();\n\n    return Stream.of(\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, about, paymentAddress, phoneNumberSharing, commitment),\n            Map.of(\n                \":commitment\", AttributeValues.fromByteArray(commitment),\n                \":name\", AttributeValues.fromByteArray(name),\n                \":avatar\", AttributeValues.fromString(avatar),\n                \":aboutEmoji\", AttributeValues.fromByteArray(emoji),\n                \":about\", AttributeValues.fromByteArray(about),\n                \":paymentAddress\", AttributeValues.fromByteArray(paymentAddress),\n                \":phoneNumberSharing\", AttributeValues.fromByteArray(phoneNumberSharing))),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, about, paymentAddress, null, commitment),\n            Map.of(\n                \":commitment\", AttributeValues.fromByteArray(commitment),\n                \":name\", AttributeValues.fromByteArray(name),\n                \":avatar\", AttributeValues.fromString(avatar),\n                \":aboutEmoji\", AttributeValues.fromByteArray(emoji),\n                \":about\", AttributeValues.fromByteArray(about),\n                \":paymentAddress\", AttributeValues.fromByteArray(paymentAddress))),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, about, null, null, commitment),\n            Map.of(\n                \":commitment\", AttributeValues.fromByteArray(commitment),\n                \":name\", AttributeValues.fromByteArray(name),\n                \":avatar\", AttributeValues.fromString(avatar),\n                \":aboutEmoji\", AttributeValues.fromByteArray(emoji),\n                \":about\", AttributeValues.fromByteArray(about))),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, emoji, null, null, null, commitment),\n            Map.of(\n                \":commitment\", AttributeValues.fromByteArray(commitment),\n                \":name\",AttributeValues.fromByteArray(name),\n                \":avatar\", AttributeValues.fromString(avatar),\n                \":aboutEmoji\", AttributeValues.fromByteArray(emoji))),\n\n        Arguments.of(\n            new VersionedProfile(version, name, avatar, null, null, null, null, commitment),\n            Map.of(\n                \":commitment\", AttributeValues.fromByteArray(commitment),\n                \":name\", AttributeValues.fromByteArray(name),\n                \":avatar\", AttributeValues.fromString(avatar))),\n\n        Arguments.of(\n            new VersionedProfile(version, name, null, null, null, null, null, commitment),\n            Map.of(\n                \":commitment\", AttributeValues.fromByteArray(commitment),\n                \":name\", AttributeValues.fromByteArray(name))),\n\n        Arguments.of(\n            new VersionedProfile(version, null, null, null, null, null, null, commitment),\n            Map.of(\":commitment\", AttributeValues.fromByteArray(commitment)))\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/PushChallengeDynamoDbTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.Random;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass PushChallengeDynamoDbTest {\n\n  private PushChallengeDynamoDb pushChallengeDynamoDb;\n\n  private static final long CURRENT_TIME_MILLIS = 1_000_000_000;\n\n  private static final Random RANDOM = new Random();\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.PUSH_CHALLENGES);\n\n  @BeforeEach\n  void setUp() {\n    this.pushChallengeDynamoDb = new PushChallengeDynamoDb(\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        Tables.PUSH_CHALLENGES.tableName(),\n        Clock.fixed(Instant.ofEpochMilli(CURRENT_TIME_MILLIS), ZoneId.systemDefault()));\n  }\n\n  @Test\n  void add() {\n    final UUID uuid = UUID.randomUUID();\n\n    assertTrue(pushChallengeDynamoDb.add(uuid, generateRandomToken(), Duration.ofMinutes(1)));\n    assertFalse(pushChallengeDynamoDb.add(uuid, generateRandomToken(), Duration.ofMinutes(1)));\n  }\n\n  @Test\n  void remove() {\n    final UUID uuid = UUID.randomUUID();\n    final byte[] token = generateRandomToken();\n\n    assertFalse(pushChallengeDynamoDb.remove(uuid, token));\n    assertTrue(pushChallengeDynamoDb.add(uuid, token, Duration.ofMinutes(1)));\n    assertTrue(pushChallengeDynamoDb.remove(uuid, token));\n    assertTrue(pushChallengeDynamoDb.add(uuid, token, Duration.ofMinutes(-1)));\n    assertFalse(pushChallengeDynamoDb.remove(uuid, token));\n  }\n\n  @Test\n  void getExpirationTimestamp() {\n    assertEquals((CURRENT_TIME_MILLIS / 1000) + 3600,\n        pushChallengeDynamoDb.getExpirationTimestamp(Duration.ofHours(1)));\n  }\n\n  private static byte[] generateRandomToken() {\n    return TestRandomUtil.nextBytes(16);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RedeemedReceiptsManagerTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.signal.libsignal.zkgroup.InvalidInputException;\nimport org.signal.libsignal.zkgroup.receipts.ReceiptSerial;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass RedeemedReceiptsManagerTest {\n\n  private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.REDEEMED_RECEIPTS);\n\n  Clock clock = TestClock.pinned(Instant.ofEpochSecond(NOW_EPOCH_SECONDS));\n  ReceiptSerial receiptSerial;\n  RedeemedReceiptsManager redeemedReceiptsManager;\n\n  @BeforeEach\n  void beforeEach() throws InvalidInputException {\n    receiptSerial = new ReceiptSerial(TestRandomUtil.nextBytes(ReceiptSerial.SIZE));\n    redeemedReceiptsManager = new RedeemedReceiptsManager(\n        clock,\n        Tables.REDEEMED_RECEIPTS.tableName(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Duration.ofDays(90));\n  }\n\n  @Test\n  void testPut() throws ExecutionException, InterruptedException {\n    final long receiptExpiration = 42;\n    final long receiptLevel = 3;\n    CompletableFuture<Boolean> put;\n\n    // initial insert should return true\n    put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);\n    assertThat(put.get()).isTrue();\n\n    // subsequent attempted inserts with modified parameters should return false\n    put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration + 1, receiptLevel, AuthHelper.VALID_UUID);\n    assertThat(put.get()).isFalse();\n    put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel + 1, AuthHelper.VALID_UUID);\n    assertThat(put.get()).isFalse();\n    put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID_TWO);\n    assertThat(put.get()).isFalse();\n\n    // repeated insert attempt of the original parameters should return true\n    put = redeemedReceiptsManager.put(receiptSerial, receiptExpiration, receiptLevel, AuthHelper.VALID_UUID);\n    assertThat(put.get()).isTrue();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RedisDynamoDbMessagePublisherTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport java.io.IOException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.atomic.AtomicLong;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterAll;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport reactor.adapter.JdkFlowAdapter;\nimport reactor.core.Disposable;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.test.StepVerifier;\n\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass RedisDynamoDbMessagePublisherTest {\n\n  private MessagesDynamoDb messagesDynamoDb;\n  private MessagesCache messagesCache;\n  private RedisMessageAvailabilityManager redisMessageAvailabilityManager;\n\n  private static ExecutorService sharedExecutorService;\n  private static Scheduler messageDeliveryScheduler;\n\n  private Device destinationDevice;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.MESSAGES);\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private static final AtomicLong SERIAL_TIMESTAMP = new AtomicLong(0);\n\n  private static final ServiceIdentifier DESTINATION_SERVICE_IDENTIFIER = new AciServiceIdentifier(UUID.randomUUID());\n\n  @BeforeAll\n  static void setUpBeforeAll() {\n    sharedExecutorService = Executors.newVirtualThreadPerTaskExecutor();\n    messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n  }\n\n  @BeforeEach\n  void setUp() throws IOException {\n    messagesDynamoDb = new MessagesDynamoDb(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.MESSAGES.tableName(),\n        Duration.ofDays(14),\n        sharedExecutorService,\n        mock(ExperimentEnrollmentManager.class));\n\n    messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        messageDeliveryScheduler, sharedExecutorService, mock(ScheduledExecutorService.class), Clock.systemUTC(), mock(ExperimentEnrollmentManager.class));\n\n    redisMessageAvailabilityManager = mock(RedisMessageAvailabilityManager.class);\n\n    destinationDevice = mock(Device.class);\n    when(destinationDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(destinationDevice.getCreated()).thenReturn(System.currentTimeMillis());\n  }\n\n  @AfterAll\n  static void tearDownAfterAll() {\n    sharedExecutorService.shutdown();\n    messageDeliveryScheduler.dispose();\n  }\n\n  @Test\n  void subscribeDispose() {\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.getCreated()).thenReturn(System.currentTimeMillis());\n\n    {\n      final UUID accountIdentifier = UUID.randomUUID();\n\n      final RedisDynamoDbMessagePublisher _ =\n          new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, accountIdentifier, device);\n\n      verify(redisMessageAvailabilityManager, never()).handleClientConnected(eq(accountIdentifier), eq(deviceId), any());\n      verify(redisMessageAvailabilityManager, never()).handleClientDisconnected(eq(accountIdentifier), eq(deviceId));\n    }\n\n    {\n      final UUID accountIdentifier = UUID.randomUUID();\n\n      final RedisDynamoDbMessagePublisher messagePublisher =\n          new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, accountIdentifier, device);\n\n      JdkFlowAdapter.flowPublisherToFlux(messagePublisher).subscribe();\n\n      verify(redisMessageAvailabilityManager).handleClientConnected(eq(accountIdentifier), eq(deviceId), any());\n      verify(redisMessageAvailabilityManager, never()).handleClientDisconnected(eq(accountIdentifier), eq(deviceId));\n    }\n\n    {\n      final UUID accountIdentifier = UUID.randomUUID();\n\n      final RedisDynamoDbMessagePublisher messagePublisher =\n          new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, accountIdentifier, device);\n\n      final Disposable disposable = JdkFlowAdapter.flowPublisherToFlux(messagePublisher).subscribe();\n      disposable.dispose();\n\n      verify(redisMessageAvailabilityManager).handleClientConnected(eq(accountIdentifier), eq(deviceId), any());\n      verify(redisMessageAvailabilityManager).handleClientDisconnected(eq(accountIdentifier), eq(deviceId));\n    }\n  }\n\n  @Test\n  void publishMessages() {\n    final MessageProtos.Envelope dynamoDbMessage = insertDynamoDbMessage(generateRandomMessage());\n    final MessageProtos.Envelope redisMessage = insertRedisMessage(generateRandomMessage());\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher))\n        .expectNext(new MessageStreamEntry.Envelope(dynamoDbMessage))\n        .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n        .expectNext(new MessageStreamEntry.QueueEmpty())\n        .verifyTimeout(Duration.ofMillis(500));\n  }\n\n  @Test\n  void publishMessagesDynamoDbOnly() {\n    final MessageProtos.Envelope dynamoDbMessage = insertDynamoDbMessage(generateRandomMessage());\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher))\n        .expectNext(new MessageStreamEntry.Envelope(dynamoDbMessage))\n        .expectNext(new MessageStreamEntry.QueueEmpty())\n        .verifyTimeout(Duration.ofMillis(500));\n  }\n\n  @Test\n  void publishMessagesRedisOnly() {\n    final MessageProtos.Envelope redisMessage = insertRedisMessage(generateRandomMessage());\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher))\n        .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n        .expectNext(new MessageStreamEntry.QueueEmpty())\n        .verifyTimeout(Duration.ofMillis(500));\n  }\n\n  @Test\n  void publishMessagesTailNewRedisMessages() {\n    final MessageProtos.Envelope dynamoDbMessage = insertDynamoDbMessage(generateRandomMessage());\n    final MessageProtos.Envelope redisMessage = insertRedisMessage(generateRandomMessage());\n\n    final MessageProtos.Envelope newArrivalRedisMessage = generateRandomMessage();\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    final CountDownLatch queueEmptyCountDownLatch = new CountDownLatch(1);\n\n    Thread.ofVirtual().start(() -> {\n      try {\n        queueEmptyCountDownLatch.await();\n      } catch (final InterruptedException e) {\n        throw new RuntimeException(e);\n      }\n\n      deleteRedisMessage(redisMessage);\n      messagePublisher.handleMessageAcknowledged();\n\n      deleteDynamoDbMessage(dynamoDbMessage);\n      messagePublisher.handleMessageAcknowledged();\n\n      insertRedisMessage(newArrivalRedisMessage);\n      messagePublisher.handleNewMessageAvailable();\n    });\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)\n            .doOnNext(entry -> {\n              if (entry instanceof MessageStreamEntry.QueueEmpty) {\n                queueEmptyCountDownLatch.countDown();\n              }\n            }))\n        .expectNext(new MessageStreamEntry.Envelope(dynamoDbMessage))\n        .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n        .expectNext(new MessageStreamEntry.QueueEmpty())\n        .expectNext(new MessageStreamEntry.Envelope(newArrivalRedisMessage))\n        .verifyTimeout(Duration.ofMillis(500));\n  }\n\n  @Test\n  void publishMessagesTailNewPersistedMessages() {\n    final MessageProtos.Envelope dynamoDbMessage = insertDynamoDbMessage(generateRandomMessage());\n    final MessageProtos.Envelope redisMessage = insertRedisMessage(generateRandomMessage());\n\n    final MessageProtos.Envelope persistedMessage = generateRandomMessage();\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    final CountDownLatch queueEmptyCountDownLatch = new CountDownLatch(1);\n\n    Thread.ofVirtual().start(() -> {\n      try {\n        queueEmptyCountDownLatch.await();\n      } catch (final InterruptedException e) {\n        throw new RuntimeException(e);\n      }\n\n      deleteRedisMessage(redisMessage);\n      messagePublisher.handleMessageAcknowledged();\n\n      deleteDynamoDbMessage(dynamoDbMessage);\n      messagePublisher.handleMessageAcknowledged();\n\n      insertDynamoDbMessage(persistedMessage);\n      messagePublisher.handleMessagesPersisted();\n    });\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)\n            .doOnNext(entry -> {\n              if (entry instanceof MessageStreamEntry.QueueEmpty) {\n                queueEmptyCountDownLatch.countDown();\n              }\n            }))\n        .expectNext(new MessageStreamEntry.Envelope(dynamoDbMessage))\n        .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n        .expectNext(new MessageStreamEntry.QueueEmpty())\n        .expectNext(new MessageStreamEntry.Envelope(persistedMessage))\n        .verifyTimeout(Duration.ofMillis(500));\n  }\n\n  @Test\n  void publishMessagesWaitForAcknowledgement() {\n    final MessageProtos.Envelope dynamoDbMessage = insertDynamoDbMessage(generateRandomMessage());\n    final MessageProtos.Envelope redisMessage = insertRedisMessage(generateRandomMessage());\n\n    final MessageProtos.Envelope persistedMessage = generateRandomMessage();\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    final CountDownLatch queueEmptyCountDownLatch = new CountDownLatch(1);\n\n    Thread.ofVirtual().start(() -> {\n      try {\n        queueEmptyCountDownLatch.await();\n      } catch (final InterruptedException e) {\n        throw new RuntimeException(e);\n      }\n\n      insertDynamoDbMessage(persistedMessage);\n      messagePublisher.handleMessagesPersisted();\n    });\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)\n            .doOnNext(entry -> {\n              if (entry instanceof MessageStreamEntry.QueueEmpty) {\n                queueEmptyCountDownLatch.countDown();\n              }\n            }))\n        .expectNext(new MessageStreamEntry.Envelope(dynamoDbMessage))\n        .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n        .expectNext(new MessageStreamEntry.QueueEmpty())\n        .verifyTimeout(Duration.ofMillis(500));\n  }\n\n  @Test\n  void publishMessagesConsumerConflict() {\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    final CountDownLatch countDownLatch = new CountDownLatch(1);\n\n    Thread.ofVirtual().start(() -> {\n      try {\n        countDownLatch.await();\n      } catch (final InterruptedException e) {\n        throw new RuntimeException(e);\n      }\n\n      messagePublisher.handleConflictingMessageConsumer();\n    });\n\n    StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher)\n        .doOnSubscribe(_ -> countDownLatch.countDown()))\n            .expectError(ConflictingMessageConsumerException.class)\n        .verify();\n\n    verify(redisMessageAvailabilityManager, timeout(1_000)).handleClientConnected(DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice.getId(), messagePublisher);\n    verify(redisMessageAvailabilityManager, timeout(1_000)).handleClientDisconnected(DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice.getId());\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n          \"207, 173\",\n          \"323, 0\",\n          \"0, 221\",\n  })\n  void publishMessagesMultipleRequests(final int persistedMessageCount, final int cachedMessageCount) {\n    final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount);\n\n    for (int i = 0; i < persistedMessageCount; i++) {\n      expectedMessages.add(insertDynamoDbMessage(generateRandomMessage()));\n    }\n\n    for (int i = 0; i < cachedMessageCount; i++) {\n      expectedMessages.add(insertRedisMessage(generateRandomMessage()));\n    }\n\n    final RedisDynamoDbMessagePublisher messagePublisher =\n        new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    final List<MessageProtos.Envelope> publishedMessages = new ArrayList<>(expectedMessages.size());\n\n    final CompletableFuture<Void> queueEmptyFuture = new CompletableFuture<>();\n\n    final Disposable disposable = JdkFlowAdapter.flowPublisherToFlux(messagePublisher)\n        .limitRate(20)\n        .doOnNext(entry -> {\n          if (entry instanceof MessageStreamEntry.Envelope(final MessageProtos.Envelope message)) {\n            publishedMessages.add(message);\n          } else if (entry instanceof MessageStreamEntry.QueueEmpty) {\n            queueEmptyFuture.complete(null);\n          }\n        })\n        .subscribe();\n\n    queueEmptyFuture.thenRun(disposable::dispose).join();\n\n    assertEquals(expectedMessages, publishedMessages);\n  }\n\n  @Test\n  void publishQueueEmptySignalDeferred() {\n    final MessageProtos.Envelope redisMessage = insertRedisMessage(generateRandomMessage());\n\n    {\n      final RedisDynamoDbMessagePublisher messagePublisher =\n          new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,\n              DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n      StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher), 1)\n          .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n          .verifyTimeout(Duration.ofMillis(500));\n    }\n\n    {\n      final RedisDynamoDbMessagePublisher messagePublisher =\n          new RedisDynamoDbMessagePublisher(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager,\n              DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n      StepVerifier.create(JdkFlowAdapter.flowPublisherToFlux(messagePublisher), 2)\n          .expectNext(new MessageStreamEntry.Envelope(redisMessage))\n          .expectNext(new MessageStreamEntry.QueueEmpty())\n          .verifyTimeout(Duration.ofMillis(500));\n    }\n  }\n\n  private MessageProtos.Envelope insertRedisMessage(final MessageProtos.Envelope message) {\n    messagesCache.insert(UUID.fromString(message.getServerGuid()),\n        DESTINATION_SERVICE_IDENTIFIER.uuid(),\n        destinationDevice.getId(),\n        message)\n        .join();\n\n    return message;\n  }\n\n  private void deleteRedisMessage(final MessageProtos.Envelope message) {\n    messagesCache.remove(DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice.getId(), UUID.fromString(message.getServerGuid())).join();\n  }\n\n  private MessageProtos.Envelope insertDynamoDbMessage(final MessageProtos.Envelope message) {\n    messagesDynamoDb.store(List.of(message), DESTINATION_SERVICE_IDENTIFIER.uuid(), destinationDevice);\n\n    return message;\n  }\n\n  private void deleteDynamoDbMessage(final MessageProtos.Envelope message) {\n    messagesDynamoDb.deleteMessage(DESTINATION_SERVICE_IDENTIFIER.uuid(),\n        destinationDevice,\n        UUID.fromString(message.getServerGuid()),\n        message.getServerTimestamp())\n        .join();\n  }\n\n  private static MessageProtos.Envelope generateRandomMessage() {\n\n    final long timestamp = SERIAL_TIMESTAMP.incrementAndGet();\n\n    final MessageProtos.Envelope.Builder envelopeBuilder = MessageProtos.Envelope.newBuilder()\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(timestamp)\n        .setContent(ByteString.copyFromUtf8(RandomStringUtils.secure().nextAlphanumeric(256)))\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .setServerGuid(UUID.randomUUID().toString())\n        .setDestinationServiceId(DESTINATION_SERVICE_IDENTIFIER.toServiceIdentifierString());\n\n    return envelopeBuilder.build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RedisDynamoDbMessageStreamTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\n\nclass RedisDynamoDbMessageStreamTest {\n\n  private MessagesDynamoDb messagesDynamoDb;\n  private MessagesCache messagesCache;\n\n  private RedisDynamoDbMessageStream redisDynamoDbMessageStream;\n\n  private Device device;\n\n  private static final UUID ACCOUNT_IDENTIFIER = UUID.randomUUID();\n  private static final byte DEVICE_ID = Device.PRIMARY_ID;\n\n  @BeforeEach\n  void setUp() {\n    messagesDynamoDb = mock(MessagesDynamoDb.class);\n    messagesCache = mock(MessagesCache.class);\n\n    device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n\n    redisDynamoDbMessageStream = new RedisDynamoDbMessageStream(messagesDynamoDb,\n        messagesCache,\n        ACCOUNT_IDENTIFIER,\n        device,\n        mock(RedisDynamoDbMessagePublisher.class));\n\n    when(messagesDynamoDb.deleteMessage(any(), any(), any(), anyLong()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(messagesCache.remove(any(), anyByte(), any(UUID.class)))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n  }\n\n  @Test\n  void acknowledgeMessageDynamoDb() {\n    final MessageProtos.Envelope message = generateMessage();\n    final UUID messageGuid = UUID.fromString(message.getServerGuid());\n    final long serverTimestamp = message.getServerTimestamp();\n\n    when(messagesDynamoDb.deleteMessage(ACCOUNT_IDENTIFIER, device, messageGuid, serverTimestamp))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(message)));\n\n    redisDynamoDbMessageStream.acknowledgeMessage(message).join();\n\n    verify(messagesCache).remove(ACCOUNT_IDENTIFIER, DEVICE_ID, messageGuid);\n    verify(messagesDynamoDb).deleteMessage(ACCOUNT_IDENTIFIER, device, messageGuid, serverTimestamp);\n  }\n\n  @Test\n  void acknowledgeMessageRedis() {\n    final MessageProtos.Envelope message = generateMessage();\n    final UUID messageGuid = UUID.fromString(message.getServerGuid());\n\n    when(messagesCache.remove(ACCOUNT_IDENTIFIER, DEVICE_ID, messageGuid))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(RemovedMessage.fromEnvelope(message))));\n\n    redisDynamoDbMessageStream.acknowledgeMessage(message).join();\n\n    verify(messagesCache).remove(ACCOUNT_IDENTIFIER, DEVICE_ID, messageGuid);\n    verify(messagesDynamoDb, never()).deleteMessage(any(), any(), any(), anyLong());\n  }\n\n  private static MessageProtos.Envelope generateMessage() {\n    return MessageProtos.Envelope.newBuilder()\n        .setServerGuid(UUID.randomUUID().toString())\n        .setDestinationServiceId(new AciServiceIdentifier(ACCOUNT_IDENTIFIER).toServiceIdentifierString())\n        .setServerTimestamp(System.currentTimeMillis())\n        .setClientTimestamp(System.currentTimeMillis())\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RegistrationRecoveryTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.nio.charset.StandardCharsets;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.util.AttributeValues;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.GetItemRequest;\n\npublic class RegistrationRecoveryTest {\n\n  private static final MutableClock CLOCK = MockUtils.mutableClock(0);\n  private static final Duration EXPIRATION = Duration.ofSeconds(1000);\n  private static final UUID PNI = UUID.randomUUID();\n\n  private static final SaltedTokenHash ORIGINAL_HASH = SaltedTokenHash.generateFor(\"pass1\");\n  private static final SaltedTokenHash ANOTHER_HASH = SaltedTokenHash.generateFor(\"pass2\");\n\n  @RegisterExtension\n  private static final DynamoDbExtension DYNAMO_DB_EXTENSION =\n      new DynamoDbExtension(Tables.REGISTRATION_RECOVERY_PASSWORDS);\n\n  private RegistrationRecoveryPasswords registrationRecoveryPasswords;\n\n  private RegistrationRecoveryPasswordsManager manager;\n\n  @BeforeEach\n  public void before() throws Exception {\n    CLOCK.setTimeMillis(Clock.systemUTC().millis());\n    registrationRecoveryPasswords = new RegistrationRecoveryPasswords(\n        Tables.REGISTRATION_RECOVERY_PASSWORDS.tableName(),\n        EXPIRATION,\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        CLOCK\n    );\n\n    manager = new RegistrationRecoveryPasswordsManager(registrationRecoveryPasswords);\n  }\n\n  @Test\n  public void testLookupAfterWrite() throws Exception {\n    assertTrue(registrationRecoveryPasswords.addOrReplace(PNI, ORIGINAL_HASH).get());\n    final long initialExp = fetchTimestamp(PNI);\n    final long expectedExpiration = CLOCK.instant().getEpochSecond() + EXPIRATION.getSeconds();\n    assertEquals(expectedExpiration, initialExp);\n\n    final Optional<SaltedTokenHash> saltedTokenHashByPni = registrationRecoveryPasswords.lookup(PNI).get();\n    assertTrue(saltedTokenHashByPni.isPresent());\n    assertEquals(ORIGINAL_HASH.salt(), saltedTokenHashByPni.get().salt());\n    assertEquals(ORIGINAL_HASH.hash(), saltedTokenHashByPni.get().hash());\n  }\n\n  @Test\n  public void testLookupAfterRefresh() throws Exception {\n    registrationRecoveryPasswords.addOrReplace(PNI, ORIGINAL_HASH).get();\n\n    CLOCK.increment(50, TimeUnit.SECONDS);\n    registrationRecoveryPasswords.addOrReplace(PNI, ORIGINAL_HASH).get();\n    final long updatedExp = fetchTimestamp(PNI);\n    final long expectedExp = CLOCK.instant().getEpochSecond() + EXPIRATION.getSeconds();\n    assertEquals(expectedExp, updatedExp);\n\n    final Optional<SaltedTokenHash> saltedTokenHashByPni = registrationRecoveryPasswords.lookup(PNI).get();\n    assertTrue(saltedTokenHashByPni.isPresent());\n    assertEquals(ORIGINAL_HASH.salt(), saltedTokenHashByPni.get().salt());\n    assertEquals(ORIGINAL_HASH.hash(), saltedTokenHashByPni.get().hash());\n  }\n\n  @Test\n  public void testReplace() throws Exception {\n    assertTrue(registrationRecoveryPasswords.addOrReplace(PNI, ORIGINAL_HASH).get());\n    assertFalse(registrationRecoveryPasswords.addOrReplace(PNI, ANOTHER_HASH).get());\n\n    final Optional<SaltedTokenHash> saltedTokenHashByPni = registrationRecoveryPasswords.lookup(PNI).get();\n    assertTrue(saltedTokenHashByPni.isPresent());\n    assertEquals(ANOTHER_HASH.salt(), saltedTokenHashByPni.get().salt());\n    assertEquals(ANOTHER_HASH.hash(), saltedTokenHashByPni.get().hash());\n  }\n\n  @Test\n  public void testRemove() throws Exception {\n    assertFalse(registrationRecoveryPasswords.removeEntry(PNI).join());\n\n    registrationRecoveryPasswords.addOrReplace(PNI, ORIGINAL_HASH).get();\n    assertTrue(registrationRecoveryPasswords.lookup(PNI).get().isPresent());\n\n    assertTrue(registrationRecoveryPasswords.removeEntry(PNI).get());\n    assertTrue(registrationRecoveryPasswords.lookup(PNI).get().isEmpty());\n  }\n\n  @Test\n  public void testManagerFlow() throws Exception {\n    final byte[] password = \"password\".getBytes(StandardCharsets.UTF_8);\n    final byte[] updatedPassword = \"udpate\".getBytes(StandardCharsets.UTF_8);\n    final byte[] wrongPassword = \"qwerty123\".getBytes(StandardCharsets.UTF_8);\n\n    // initial store\n    manager.store(PNI, password).get();\n    assertTrue(manager.verify(PNI, password).get());\n    assertFalse(manager.verify(PNI, wrongPassword).get());\n\n    // update\n    manager.store(PNI, password).get();\n    assertTrue(manager.verify(PNI, password).get());\n    assertFalse(manager.verify(PNI, wrongPassword).get());\n\n    // replace\n    manager.store(PNI, updatedPassword).get();\n    assertTrue(manager.verify(PNI, updatedPassword).get());\n    assertFalse(manager.verify(PNI, password).get());\n    assertFalse(manager.verify(PNI, wrongPassword).get());\n\n    manager.remove(PNI).get();\n    assertFalse(manager.verify(PNI, updatedPassword).get());\n    assertFalse(manager.verify(PNI, password).get());\n    assertFalse(manager.verify(PNI, wrongPassword).get());\n  }\n\n  private static long fetchTimestamp(final UUID phoneNumberIdentifier) throws ExecutionException, InterruptedException {\n    return DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient().getItem(GetItemRequest.builder()\n            .tableName(Tables.REGISTRATION_RECOVERY_PASSWORDS.tableName())\n            .key(Map.of(RegistrationRecoveryPasswords.KEY_PNI, AttributeValues.fromString(phoneNumberIdentifier.toString())))\n            .build())\n        .thenApply(getItemResponse -> {\n          final Map<String, AttributeValue> item = getItemResponse.item();\n          if (item == null || !item.containsKey(RegistrationRecoveryPasswords.ATTR_EXP)) {\n            throw new RuntimeException(\"Data not found\");\n          }\n          final String exp = item.get(RegistrationRecoveryPasswords.ATTR_EXP).n();\n          return Long.parseLong(exp);\n        })\n        .get();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsManagerTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.BeforeEach;\n\nclass RemoteConfigsManagerTest {\n\n  private RemoteConfigs remoteConfigs;\n  private RemoteConfigsManager remoteConfigsManager;\n\n  @BeforeEach\n  void setup() {\n    this.remoteConfigs = mock(RemoteConfigs.class);\n    this.remoteConfigsManager = new RemoteConfigsManager(remoteConfigs);\n  }\n\n  @Test\n  void testGetAll() {\n    remoteConfigsManager.getAll();\n    remoteConfigsManager.getAll();\n\n    // A memoized supplier should prevent multiple calls to the underlying data source\n    verify(remoteConfigs, times(1)).getAll();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RemoteConfigsTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.util.List;\nimport java.util.Set;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.tests.util.AuthHelper;\n\nclass RemoteConfigsTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.REMOTE_CONFIGS);\n\n  private RemoteConfigs remoteConfigs;\n\n  @BeforeEach\n  void setUp() {\n    remoteConfigs = new RemoteConfigs(DYNAMO_DB_EXTENSION.getDynamoDbClient(), Tables.REMOTE_CONFIGS.tableName());\n  }\n\n  @Test\n  void testStore() {\n    remoteConfigs.set(new RemoteConfig(\"android.stickers\", 50, Set.of(AuthHelper.VALID_UUID, AuthHelper.VALID_UUID_TWO), \"FALSE\", \"TRUE\", null));\n    remoteConfigs.set(new RemoteConfig(\"value.sometimes\", 25, Set.of(AuthHelper.VALID_UUID_TWO), \"default\", \"custom\", null));\n\n    List<RemoteConfig> configs = remoteConfigs.getAll();\n\n    assertThat(configs).hasSize(2);\n\n    assertThat(configs.get(0).getName()).isEqualTo(\"android.stickers\");\n    assertThat(configs.get(0).getValue()).isEqualTo(\"TRUE\");\n    assertThat(configs.get(0).getDefaultValue()).isEqualTo(\"FALSE\");\n    assertThat(configs.get(0).getPercentage()).isEqualTo(50);\n    assertThat(configs.get(0).getUuids()).hasSize(2);\n    assertThat(configs.get(0).getUuids()).contains(AuthHelper.VALID_UUID);\n    assertThat(configs.get(0).getUuids()).contains(AuthHelper.VALID_UUID_TWO);\n    assertThat(configs.get(0).getUuids()).doesNotContain(AuthHelper.INVALID_UUID);\n\n    assertThat(configs.get(1).getName()).isEqualTo(\"value.sometimes\");\n    assertThat(configs.get(1).getValue()).isEqualTo(\"custom\");\n    assertThat(configs.get(1).getDefaultValue()).isEqualTo(\"default\");\n    assertThat(configs.get(1).getPercentage()).isEqualTo(25);\n    assertThat(configs.get(1).getUuids()).hasSize(1);\n    assertThat(configs.get(1).getUuids()).contains(AuthHelper.VALID_UUID_TWO);\n    assertThat(configs.get(1).getUuids()).doesNotContain(AuthHelper.VALID_UUID);\n    assertThat(configs.get(1).getUuids()).doesNotContain(AuthHelper.INVALID_UUID);\n  }\n\n  @Test\n  void testUpdate() {\n    remoteConfigs.set(new RemoteConfig(\"android.stickers\", 50, Set.of(), \"FALSE\", \"TRUE\", null));\n    remoteConfigs.set(new RemoteConfig(\"value.sometimes\", 22, Set.of(), \"def\", \"!\", null));\n    remoteConfigs.set(new RemoteConfig(\"ios.stickers\", 75, Set.of(), \"FALSE\", \"TRUE\", null));\n    remoteConfigs.set(new RemoteConfig(\"value.sometimes\", 77, Set.of(), \"hey\", \"wut\", null));\n\n    List<RemoteConfig> configs = remoteConfigs.getAll();\n\n    assertThat(configs).hasSize(3);\n\n    assertThat(configs.get(0).getName()).isEqualTo(\"android.stickers\");\n    assertThat(configs.get(0).getPercentage()).isEqualTo(50);\n    assertThat(configs.get(0).getUuids()).isEmpty();\n    assertThat(configs.get(0).getDefaultValue()).isEqualTo(\"FALSE\");\n    assertThat(configs.get(0).getValue()).isEqualTo(\"TRUE\");\n\n    assertThat(configs.get(1).getName()).isEqualTo(\"ios.stickers\");\n    assertThat(configs.get(1).getPercentage()).isEqualTo(75);\n    assertThat(configs.get(1).getUuids()).isEmpty();\n    assertThat(configs.get(1).getDefaultValue()).isEqualTo(\"FALSE\");\n    assertThat(configs.get(1).getValue()).isEqualTo(\"TRUE\");\n\n    assertThat(configs.get(2).getName()).isEqualTo(\"value.sometimes\");\n    assertThat(configs.get(2).getPercentage()).isEqualTo(77);\n    assertThat(configs.get(2).getUuids()).isEmpty();\n    assertThat(configs.get(2).getDefaultValue()).isEqualTo(\"hey\");\n    assertThat(configs.get(2).getValue()).isEqualTo(\"wut\");\n  }\n\n  @Test\n  void testDelete() {\n    remoteConfigs.set(new RemoteConfig(\"android.stickers\", 50, Set.of(AuthHelper.VALID_UUID), \"FALSE\", \"TRUE\", null));\n    remoteConfigs.set(new RemoteConfig(\"ios.stickers\", 50, Set.of(), \"FALSE\", \"TRUE\", null));\n    remoteConfigs.set(new RemoteConfig(\"ios.stickers\", 75, Set.of(), \"FALSE\", \"TRUE\", null));\n    remoteConfigs.set(new RemoteConfig(\"value.always\", 100, Set.of(), \"never\", \"always\", null));\n    remoteConfigs.delete(\"android.stickers\");\n\n    List<RemoteConfig> configs = remoteConfigs.getAll();\n\n    assertThat(configs).hasSize(2);\n\n    assertThat(configs.get(0).getName()).isEqualTo(\"ios.stickers\");\n    assertThat(configs.get(0).getPercentage()).isEqualTo(75);\n    assertThat(configs.get(0).getDefaultValue()).isEqualTo(\"FALSE\");\n    assertThat(configs.get(0).getValue()).isEqualTo(\"TRUE\");\n\n    assertThat(configs.get(1).getName()).isEqualTo(\"value.always\");\n    assertThat(configs.get(1).getPercentage()).isEqualTo(100);\n    assertThat(configs.get(1).getValue()).isEqualTo(\"always\");\n    assertThat(configs.get(1).getDefaultValue()).isEqualTo(\"never\");\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RepeatedUseECSignedPreKeyStoreTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\n\nclass RepeatedUseECSignedPreKeyStoreTest extends RepeatedUseSignedPreKeyStoreTest<ECSignedPreKey> {\n\n  private RepeatedUseECSignedPreKeyStore keyStore;\n\n  private int currentKeyId = 1;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION =\n      new DynamoDbExtension(DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS);\n\n  private static final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n\n  @BeforeEach\n  void setUp() {\n    keyStore = new RepeatedUseECSignedPreKeyStore(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.REPEATED_USE_EC_SIGNED_PRE_KEYS.tableName());\n  }\n\n  @Override\n  protected RepeatedUseSignedPreKeyStore<ECSignedPreKey> getKeyStore() {\n    return keyStore;\n  }\n\n  @Override\n  protected ECSignedPreKey generateSignedPreKey() {\n    return generateSignedPreKey(currentKeyId++);\n  }\n\n  @Override\n  protected ECSignedPreKey generateSignedPreKey(long keyId) {\n    return KeysHelper.signedECPreKey(keyId, IDENTITY_KEY_PAIR);\n  }\n\n  @Override\n  protected DynamoDbClient getDynamoDbClient() {\n    return DYNAMO_DB_EXTENSION.getDynamoDbClient();\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RepeatedUseKEMSignedPreKeyStoreTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\nimport org.whispersystems.textsecuregcm.tests.util.KeysHelper;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\n\nclass RepeatedUseKEMSignedPreKeyStoreTest extends RepeatedUseSignedPreKeyStoreTest<KEMSignedPreKey> {\n\n  private RepeatedUseKEMSignedPreKeyStore keyStore;\n\n  private int currentKeyId = 1;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION =\n      new DynamoDbExtension(DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS);\n\n  private static final ECKeyPair IDENTITY_KEY_PAIR = ECKeyPair.generate();\n\n  @BeforeEach\n  void setUp() {\n    keyStore = new RepeatedUseKEMSignedPreKeyStore(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.REPEATED_USE_KEM_SIGNED_PRE_KEYS.tableName());\n  }\n\n  @Override\n  protected RepeatedUseSignedPreKeyStore<KEMSignedPreKey> getKeyStore() {\n    return keyStore;\n  }\n\n  @Override\n  protected DynamoDbClient getDynamoDbClient() {\n    return DYNAMO_DB_EXTENSION.getDynamoDbClient();\n  }\n\n  @Override\n  protected KEMSignedPreKey generateSignedPreKey() {\n    return generateSignedPreKey(currentKeyId++);\n  }\n\n  @Override\n  protected KEMSignedPreKey generateSignedPreKey(long keyId) {\n    return KeysHelper.signedKEMPreKey(keyId, IDENTITY_KEY_PAIR);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/RepeatedUseSignedPreKeyStoreTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.entities.SignedPreKey;\nimport org.whispersystems.textsecuregcm.util.CompletableFutureTestUtil;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbClient;\nimport software.amazon.awssdk.services.dynamodb.model.TransactWriteItemsRequest;\n\nabstract class RepeatedUseSignedPreKeyStoreTest<K extends SignedPreKey<?>> {\n\n  protected abstract RepeatedUseSignedPreKeyStore<K> getKeyStore();\n\n  protected abstract K generateSignedPreKey();\n\n  protected abstract K generateSignedPreKey(long keyId);\n\n  protected abstract DynamoDbClient getDynamoDbClient();\n\n  @Test\n  void storeFind() {\n    final RepeatedUseSignedPreKeyStore<K> keys = getKeyStore();\n\n    assertEquals(Optional.empty(), keys.find(UUID.randomUUID(), Device.PRIMARY_ID).join());\n\n    final UUID identifier = UUID.randomUUID();\n    final byte deviceId = 1;\n    final K signedPreKey = generateSignedPreKey();\n\n    assertDoesNotThrow(() -> keys.store(identifier, deviceId, signedPreKey).join());\n    assertEquals(Optional.of(signedPreKey), keys.find(identifier, deviceId).join());\n  }\n\n  @Test\n  void buildTransactWriteItemForInsertion() {\n    final RepeatedUseSignedPreKeyStore<K> keys = getKeyStore();\n\n    assertEquals(Optional.empty(), keys.find(UUID.randomUUID(), Device.PRIMARY_ID).join());\n\n    final UUID identifier = UUID.randomUUID();\n    final K signedPreKey = generateSignedPreKey();\n\n    getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()\n        .transactItems(keys.buildTransactWriteItemForInsertion(identifier, Device.PRIMARY_ID, signedPreKey))\n        .build());\n\n    assertEquals(Optional.of(signedPreKey), keys.find(identifier, Device.PRIMARY_ID).join());\n  }\n\n  @Test\n  void buildTransactWriteItemForDeletion() {\n    final RepeatedUseSignedPreKeyStore<K> keys = getKeyStore();\n\n    final UUID identifier = UUID.randomUUID();\n    final byte deviceId2 = 2;\n    final K retainedPreKey = generateSignedPreKey();\n\n    keys.store(identifier, Device.PRIMARY_ID, generateSignedPreKey()).join();\n    keys.store(identifier, deviceId2, retainedPreKey).join();\n\n    getDynamoDbClient().transactWriteItems(TransactWriteItemsRequest.builder()\n            .transactItems(keys.buildTransactWriteItemForDeletion(identifier, Device.PRIMARY_ID))\n        .build());\n\n    assertEquals(Optional.empty(), keys.find(identifier, Device.PRIMARY_ID).join());\n    assertEquals(Optional.of(retainedPreKey), keys.find(identifier, deviceId2).join());\n  }\n\n  @Test\n  void findThrowsOnOutOfRangeKeyId() {\n    final RepeatedUseSignedPreKeyStore<K> keys = getKeyStore();\n\n    final UUID identifier = UUID.randomUUID();\n    final byte deviceId = 1;\n    final K outOfRangeKey = generateSignedPreKey(KeyIdUtil.MAX_KEY_ID + 1);\n\n    keys.store(identifier, deviceId, outOfRangeKey).join();\n    CompletableFutureTestUtil.assertFailsWithCause(IllegalStateException.class, keys.find(identifier, deviceId));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageDynamoDbTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertAll;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.time.Duration;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.util.UUIDUtil;\n\nclass ReportMessageDynamoDbTest {\n\n  private ReportMessageDynamoDb reportMessageDynamoDb;\n\n  @RegisterExtension\n  static DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.REPORT_MESSAGES);\n\n\n  @BeforeEach\n  void setUp() {\n    this.reportMessageDynamoDb = new ReportMessageDynamoDb(\n        DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        Tables.REPORT_MESSAGES.tableName(),\n        Duration.ofDays(1));\n  }\n\n  @Test\n  void testStore() {\n\n    final byte[] hash1 = UUIDUtil.toBytes(UUID.randomUUID());\n    final byte[] hash2 = UUIDUtil.toBytes(UUID.randomUUID());\n\n    assertAll(\"database should be empty\",\n        () -> assertFalse(reportMessageDynamoDb.remove(hash1)),\n        () -> assertFalse(reportMessageDynamoDb.remove(hash2))\n    );\n\n    reportMessageDynamoDb.store(hash1).join();\n    reportMessageDynamoDb.store(hash2).join();\n\n    assertAll(\"both hashes should be found\",\n        () -> assertTrue(reportMessageDynamoDb.remove(hash1)),\n        () -> assertTrue(reportMessageDynamoDb.remove(hash2))\n    );\n\n    assertAll( \"database should be empty\",\n        () -> assertFalse(reportMessageDynamoDb.remove(hash1)),\n        () -> assertFalse(reportMessageDynamoDb.remove(hash2))\n    );\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ReportMessageManagerTest.java",
    "content": "/*\n * Copyright 2021-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\n\nclass ReportMessageManagerTest {\n\n  private ReportMessageDynamoDb reportMessageDynamoDb;\n\n  private ReportMessageManager reportMessageManager;\n\n  private String sourceNumber;\n  private UUID sourceAci;\n  private UUID sourcePni;\n  private Account sourceAccount;\n  private UUID messageGuid;\n  private UUID reporterUuid;\n\n  @RegisterExtension\n  static RedisClusterExtension RATE_LIMIT_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @BeforeEach\n  void setUp() {\n    reportMessageDynamoDb = mock(ReportMessageDynamoDb.class);\n\n    reportMessageManager = new ReportMessageManager(reportMessageDynamoDb,\n        RATE_LIMIT_CLUSTER_EXTENSION.getRedisCluster(), Duration.ofDays(1));\n\n    sourceNumber = \"+15105551111\";\n    sourceAci = UUID.randomUUID();\n    sourcePni = UUID.randomUUID();\n    messageGuid = UUID.randomUUID();\n    reporterUuid = UUID.randomUUID();\n\n    sourceAccount = mock(Account.class);\n    when(sourceAccount.getUuid()).thenReturn(sourceAci);\n    when(sourceAccount.getNumber()).thenReturn(sourceNumber);\n    when(sourceAccount.getPhoneNumberIdentifier()).thenReturn(sourcePni);\n  }\n\n  @Test\n  void testStore() {\n    assertDoesNotThrow(() -> reportMessageManager.store(null, messageGuid));\n\n    verifyNoInteractions(reportMessageDynamoDb);\n\n    reportMessageManager.store(sourceAci.toString(), messageGuid);\n\n    verify(reportMessageDynamoDb).store(any());\n\n    when(reportMessageDynamoDb.store(any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));\n\n    assertDoesNotThrow(() -> reportMessageManager.store(sourceAci.toString(), messageGuid));\n  }\n\n  @Test\n  void testReport() {\n    final ReportedMessageListener listener = mock(ReportedMessageListener.class);\n    reportMessageManager.addListener(listener);\n\n    when(reportMessageDynamoDb.remove(any())).thenReturn(false);\n    reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), messageGuid,\n        reporterUuid, Optional.empty(), \"user-agent\");\n\n    assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount));\n\n    when(reportMessageDynamoDb.remove(any())).thenReturn(true);\n    reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni), messageGuid,\n        reporterUuid, Optional.empty(), \"user-agent\");\n\n    assertEquals(1, reportMessageManager.getRecentReportCount(sourceAccount));\n    verify(listener).handleMessageReported(sourceNumber, messageGuid, reporterUuid, Optional.empty());\n  }\n\n  @Test\n  void testReportMultipleReporters() {\n    when(reportMessageDynamoDb.remove(any())).thenReturn(true);\n    assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount));\n\n    for (int i = 0; i < 100; i++) {\n      reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni),\n          messageGuid, UUID.randomUUID(), Optional.empty(), \"user-agent\");\n    }\n\n    assertTrue(reportMessageManager.getRecentReportCount(sourceAccount) > 10);\n  }\n\n  @Test\n  void testReportSingleReporter() {\n    when(reportMessageDynamoDb.remove(any())).thenReturn(true);\n    assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount));\n\n    for (int i = 0; i < 100; i++) {\n      reportMessageManager.report(Optional.of(sourceNumber), Optional.of(sourceAci), Optional.of(sourcePni),\n          messageGuid,\n          reporterUuid, Optional.empty(), \"user-agent\");\n    }\n\n    assertEquals(1, reportMessageManager.getRecentReportCount(sourceAccount));\n  }\n\n  @Test\n  void testReportMultipleReportersByPni() {\n    when(reportMessageDynamoDb.remove(any())).thenReturn(true);\n    assertEquals(0, reportMessageManager.getRecentReportCount(sourceAccount));\n\n    for (int i = 0; i < 100; i++) {\n      reportMessageManager.report(Optional.empty(), Optional.of(sourceAci), Optional.of(sourcePni),\n          messageGuid, UUID.randomUUID(), Optional.empty(), \"user-agent\");\n    }\n\n    reportMessageManager.report(Optional.empty(), Optional.of(sourceAci), Optional.empty(),\n        messageGuid, UUID.randomUUID(), Optional.empty(), \"user-agent\");\n\n    final int recentReportCount = reportMessageManager.getRecentReportCount(sourceAccount);\n    assertTrue(recentReportCount > 10);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/S3LocalStackExtension.java",
    "content": "/*\n * Copyright 2021-2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.testcontainers.containers.localstack.LocalStackContainer.Service.S3;\n\nimport org.apache.commons.lang3.StringUtils;\nimport org.junit.jupiter.api.extension.AfterAllCallback;\nimport org.junit.jupiter.api.extension.AfterEachCallback;\nimport org.junit.jupiter.api.extension.BeforeAllCallback;\nimport org.junit.jupiter.api.extension.BeforeEachCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.testcontainers.containers.localstack.LocalStackContainer;\nimport org.testcontainers.junit.jupiter.Testcontainers;\nimport org.testcontainers.utility.DockerImageName;\nimport org.whispersystems.textsecuregcm.util.TestcontainersImages;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Mono;\nimport software.amazon.awssdk.auth.credentials.AwsBasicCredentials;\nimport software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;\nimport software.amazon.awssdk.regions.Region;\nimport software.amazon.awssdk.services.s3.S3AsyncClient;\nimport software.amazon.awssdk.services.s3.model.CreateBucketRequest;\nimport software.amazon.awssdk.services.s3.model.DeleteBucketRequest;\nimport software.amazon.awssdk.services.s3.model.DeleteObjectRequest;\nimport software.amazon.awssdk.services.s3.model.ListObjectsV2Request;\n\n@Testcontainers\npublic class S3LocalStackExtension implements BeforeEachCallback, AfterEachCallback, BeforeAllCallback,\n    AfterAllCallback {\n\n  private final static DockerImageName LOCAL_STACK_IMAGE = DockerImageName.parse(TestcontainersImages.getLocalStack())\n      .asCompatibleSubstituteFor(\n          // Workaround: DockerImageName#parse does not correctly handle registry/image:tag@sha256:hash,\n          // and so it doesn't consider the image to be \"localstack/localstack\"\n          StringUtils.substringBefore(\n              StringUtils.substringBefore(TestcontainersImages.getLocalStack(), \"@\"),\n              \":\"));\n\n  private static LocalStackContainer LOCAL_STACK = new LocalStackContainer(LOCAL_STACK_IMAGE).withServices(S3)\n      .withExposedPorts(4566);\n\n  private final String bucketName;\n  private S3AsyncClient s3Client;\n\n  public S3LocalStackExtension(final String bucketName) {\n    this.bucketName = bucketName;\n  }\n\n  @Override\n  public void afterEach(ExtensionContext context) {\n    Flux.from(s3Client.listObjectsV2Paginator(ListObjectsV2Request.builder()\n                .bucket(bucketName)\n                .build())\n            .contents())\n        .flatMap(obj -> Mono.fromFuture(() -> s3Client.deleteObject(DeleteObjectRequest.builder()\n            .bucket(bucketName)\n            .key(obj.key())\n            .build())), 100)\n        .then()\n        .block();\n    s3Client.deleteBucket(DeleteBucketRequest.builder().bucket(bucketName).build()).join();\n  }\n\n\n  @Override\n  public void beforeEach(ExtensionContext context) throws Exception {\n    s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()).join();\n  }\n\n  public S3AsyncClient getS3Client() {\n    return s3Client;\n  }\n\n  @Override\n  public void afterAll(final ExtensionContext context) throws Exception {\n    s3Client.close();\n    LOCAL_STACK.close();\n  }\n\n  @Override\n  public void beforeAll(final ExtensionContext context) throws Exception {\n    LOCAL_STACK.start();\n    s3Client = S3AsyncClient.builder()\n        .endpointOverride(LOCAL_STACK.getEndpoint())\n        .credentialsProvider(StaticCredentialsProvider\n            .create(AwsBasicCredentials.create(LOCAL_STACK.getAccessKey(), LOCAL_STACK.getSecretKey())))\n        .region(Region.of(LOCAL_STACK.getRegion()))\n        .build();\n  }\n\n  public String getBucketName() {\n    return bucketName;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/SerializedExpireableJsonDynamoStoreTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Nested;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport software.amazon.awssdk.services.dynamodb.DynamoDbAsyncClient;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;\nimport software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;\n\nclass SerializedExpireableJsonDynamoStoreTest {\n\n  static abstract class Tests<T> {\n\n    private static final String TABLE_NAME = \"test\";\n    private static final String KEY = \"foo\";\n\n    static final Clock clock = Clock.systemUTC();\n\n    interface Value {\n\n      String v();\n    }\n\n    @RegisterExtension\n    static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n        new DynamoDbExtension.RawSchema(\n            TABLE_NAME,\n            SerializedExpireableJsonDynamoStore.KEY_KEY,\n            null,\n            List.of(AttributeDefinition.builder()\n                .attributeName(SerializedExpireableJsonDynamoStore.KEY_KEY)\n                .attributeType(ScalarAttributeType.S)\n                .build()),\n            List.of(),\n            List.of()));\n\n    private SerializedExpireableJsonDynamoStore<T> store;\n\n    abstract SerializedExpireableJsonDynamoStore<T> getStore(final DynamoDbAsyncClient dynamoDbClient,\n        final String tableName);\n\n    abstract T testValue(final String v);\n\n    abstract T maybeExpiredTestValue(final String v);\n\n    @BeforeEach\n    void setUp() {\n      store = getStore(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), TABLE_NAME);\n    }\n\n    @Test\n    void testStoreAndFind() throws Exception {\n      assertEquals(Optional.empty(), store.findForKey(KEY).get(1, TimeUnit.SECONDS));\n\n      final T original = testValue(\"1234\");\n      final T second = testValue(\"5678\");\n\n      store.insert(KEY, original).get(1, TimeUnit.SECONDS);\n      {\n        final Optional<T> maybeValue = store.findForKey(KEY).get(1, TimeUnit.SECONDS);\n\n        assertTrue(maybeValue.isPresent());\n        assertEquals(original, maybeValue.get());\n      }\n\n      assertThrows(Exception.class, () -> store.insert(KEY, second).get(1, TimeUnit.SECONDS));\n\n      assertDoesNotThrow(() -> store.update(KEY, second).get(1, TimeUnit.SECONDS));\n      {\n        final Optional<T> maybeValue = store.findForKey(KEY).get(1, TimeUnit.SECONDS);\n\n        assertTrue(maybeValue.isPresent());\n        assertEquals(second, maybeValue.get());\n      }\n    }\n\n    @Test\n    void testRemove() throws Exception {\n      assertEquals(Optional.empty(), store.findForKey(KEY).get(1, TimeUnit.SECONDS));\n\n      store.insert(KEY, testValue(\"1234\")).get(1, TimeUnit.SECONDS);\n      assertTrue(store.findForKey(KEY).get(1, TimeUnit.SECONDS).isPresent());\n\n      store.remove(KEY).get(1, TimeUnit.SECONDS);\n      assertFalse(store.findForKey(KEY).get(1, TimeUnit.SECONDS).isPresent());\n\n      final T v = maybeExpiredTestValue(\"1234\");\n      store.insert(KEY, v).get(1, TimeUnit.SECONDS);\n\n      assertEquals(v instanceof SerializedExpireableJsonDynamoStore.Expireable,\n          store.findForKey(KEY).get(1, TimeUnit.SECONDS).isEmpty());\n    }\n\n  }\n\n  record Expires(String v, long timestamp) implements SerializedExpireableJsonDynamoStore.Expireable, Tests.Value {\n\n    static final Duration EXPIRATION = Duration.ofSeconds(30);\n\n    @Override\n    public long getExpirationEpochSeconds() {\n      return Instant.ofEpochMilli(timestamp()).plus(EXPIRATION).getEpochSecond();\n    }\n  }\n\n  @Nested\n  class Expireable extends Tests<Expires> {\n\n    class ExpiresStore extends SerializedExpireableJsonDynamoStore<Expires> {\n\n      public ExpiresStore(final DynamoDbAsyncClient dynamoDbClient, final String tableName) {\n        super(dynamoDbClient, tableName, clock);\n      }\n    }\n\n    private static final long VALID_TIMESTAMP = Instant.now().toEpochMilli();\n    private static final long EXPIRED_TIMESTAMP = Instant.now().minus(Expires.EXPIRATION).minus(\n        Duration.ofHours(1)).toEpochMilli();\n\n    @Override\n    SerializedExpireableJsonDynamoStore<Expires> getStore(final DynamoDbAsyncClient dynamoDbClient,\n        final String tableName) {\n      return new ExpiresStore(dynamoDbClient, tableName);\n    }\n\n    @Override\n    Expires testValue(final String v) {\n      return new Expires(v, VALID_TIMESTAMP);\n    }\n\n    @Override\n    Expires maybeExpiredTestValue(final String v) {\n      return new Expires(v, EXPIRED_TIMESTAMP);\n    }\n  }\n\n  record DoesNotExpire(String v) implements Tests.Value {\n\n  }\n\n\n  @Nested\n  class NotExpireable extends Tests<DoesNotExpire> {\n\n    class DoesNotExpireStore extends SerializedExpireableJsonDynamoStore<DoesNotExpire> {\n\n      public DoesNotExpireStore(final DynamoDbAsyncClient dynamoDbClient, final String tableName) {\n        super(dynamoDbClient, tableName, clock);\n      }\n    }\n\n    @Override\n    SerializedExpireableJsonDynamoStore<DoesNotExpire> getStore(final DynamoDbAsyncClient dynamoDbClient,\n        final String tableName) {\n      return new DoesNotExpireStore(dynamoDbClient, tableName);\n    }\n\n    @Override\n    DoesNotExpire testValue(final String v) {\n      return new DoesNotExpire(v);\n    }\n\n    @Override\n    DoesNotExpire maybeExpiredTestValue(final String v) {\n      return new DoesNotExpire(v);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/ServiceContainerFoundationDbDatabaseLifecycleManager.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.apple.foundationdb.Database;\nimport com.apple.foundationdb.FDB;\nimport java.io.File;\nimport java.io.FileWriter;\nimport java.io.IOException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/**\n * Manages the lifecycle of a database connected to a FoundationDB instance running as an external service container.\n */\nclass ServiceContainerFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {\n\n  private final String foundationDbServiceContainerName;\n\n  private Database database;\n\n  private static final Logger log = LoggerFactory.getLogger(ServiceContainerFoundationDbDatabaseLifecycleManager.class);\n\n  ServiceContainerFoundationDbDatabaseLifecycleManager(final String foundationDbServiceContainerName) {\n    log.info(\"Using FoundationDB service container: {}\", foundationDbServiceContainerName);\n    this.foundationDbServiceContainerName = foundationDbServiceContainerName;\n  }\n\n  @Override\n  public void initializeDatabase(final FDB fdb) throws IOException {\n    final File clusterFile = File.createTempFile(\"fdb.cluster\", \"\");\n    clusterFile.deleteOnExit();\n\n    try (final FileWriter fileWriter = new FileWriter(clusterFile)) {\n      fileWriter.write(String.format(\"docker:docker@%s:4500\", foundationDbServiceContainerName));\n    }\n\n    database = fdb.open(clusterFile.getAbsolutePath());\n  }\n\n  @Override\n  public Database getDatabase() {\n    return database;\n  }\n\n  @Override\n  public void closeDatabase() {\n    database.close();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/SingleUseECPreKeyStoreTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.util.ArrayList;\nimport java.util.Comparator;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ThreadLocalRandom;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport software.amazon.awssdk.services.dynamodb.model.ScanRequest;\nimport software.amazon.awssdk.services.dynamodb.model.ScanResponse;\nimport software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest;\nimport software.amazon.awssdk.services.dynamodb.paginators.ScanIterable;\n\nclass SingleUseECPreKeyStoreTest {\n\n  private static final int KEY_COUNT = 100;\n  private SingleUseECPreKeyStore preKeyStore;\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(DynamoDbExtensionSchema.Tables.EC_KEYS);\n\n  @BeforeEach\n  void setUp() {\n    preKeyStore = new SingleUseECPreKeyStore(DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(),\n        DynamoDbExtensionSchema.Tables.EC_KEYS.tableName());\n  }\n\n  private ECPreKey generatePreKey(final long keyId) {\n    return new ECPreKey(keyId, ECKeyPair.generate().getPublicKey());\n  }\n\n  private void clearKeyCountAttributes() {\n    final ScanIterable scanIterable = DYNAMO_DB_EXTENSION.getDynamoDbClient().scanPaginator(ScanRequest.builder()\n        .tableName(DynamoDbExtensionSchema.Tables.EC_KEYS.tableName())\n        .build());\n\n    for (final ScanResponse response : scanIterable) {\n      for (final Map<String, AttributeValue> item : response.items()) {\n\n        DYNAMO_DB_EXTENSION.getDynamoDbClient().updateItem(UpdateItemRequest.builder()\n            .tableName(DynamoDbExtensionSchema.Tables.EC_KEYS.tableName())\n            .key(Map.of(\n                SingleUseECPreKeyStore.KEY_ACCOUNT_UUID, item.get(SingleUseECPreKeyStore.KEY_ACCOUNT_UUID),\n                SingleUseECPreKeyStore.KEY_DEVICE_ID_KEY_ID, item.get(SingleUseECPreKeyStore.KEY_DEVICE_ID_KEY_ID)))\n            .updateExpression(\"REMOVE \" + SingleUseECPreKeyStore.ATTR_REMAINING_KEYS)\n            .build());\n      }\n    }\n  }\n\n  @Test\n  void storeTake() {\n    final SingleUseECPreKeyStore preKeyStore = this.preKeyStore;\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(Optional.empty(), preKeyStore.take(accountIdentifier, deviceId).join());\n\n    final List<ECPreKey> sortedPreKeys;\n    {\n      final List<ECPreKey> preKeys = generateRandomPreKeys();\n      assertDoesNotThrow(() -> preKeyStore.store(accountIdentifier, deviceId, preKeys).join());\n\n      sortedPreKeys = new ArrayList<>(preKeys);\n      sortedPreKeys.sort(Comparator.comparing(preKey -> preKey.keyId()));\n    }\n\n    assertEquals(Optional.of(sortedPreKeys.get(0)), preKeyStore.take(accountIdentifier, deviceId).join());\n    assertEquals(Optional.of(sortedPreKeys.get(1)), preKeyStore.take(accountIdentifier, deviceId).join());\n  }\n\n  @Test\n  void getCount() {\n    final SingleUseECPreKeyStore preKeyStore = this.preKeyStore;\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n\n    final List<ECPreKey> preKeys = generateRandomPreKeys();\n\n    preKeyStore.store(accountIdentifier, deviceId, preKeys).join();\n\n    assertEquals(KEY_COUNT, preKeyStore.getCount(accountIdentifier, deviceId).join());\n\n    for (int i = 0; i < KEY_COUNT; i++) {\n      preKeyStore.take(accountIdentifier, deviceId).join();\n      assertEquals(KEY_COUNT - (i + 1), preKeyStore.getCount(accountIdentifier, deviceId).join());\n    }\n\n    preKeyStore.store(accountIdentifier, deviceId, List.of(generatePreKey(KEY_COUNT + 1))).join();\n    clearKeyCountAttributes();\n\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n  }\n\n  @Test\n  void deleteSingleDevice() {\n    final SingleUseECPreKeyStore preKeyStore = this.preKeyStore;\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n    assertDoesNotThrow(() -> preKeyStore.delete(accountIdentifier, deviceId).join());\n\n    final List<ECPreKey> preKeys = generateRandomPreKeys();\n\n    preKeyStore.store(accountIdentifier, deviceId, preKeys).join();\n    preKeyStore.store(accountIdentifier, (byte) (deviceId + 1), preKeys).join();\n\n    assertDoesNotThrow(() -> preKeyStore.delete(accountIdentifier, deviceId).join());\n\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n    assertEquals(KEY_COUNT, preKeyStore.getCount(accountIdentifier, (byte) (deviceId + 1)).join());\n  }\n\n  @Test\n  void deleteAllDevices() {\n    final SingleUseECPreKeyStore preKeyStore = this.preKeyStore;\n\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n    assertDoesNotThrow(() -> preKeyStore.delete(accountIdentifier).join());\n\n    final List<ECPreKey> preKeys = generateRandomPreKeys();\n\n    preKeyStore.store(accountIdentifier, deviceId, preKeys).join();\n    preKeyStore.store(accountIdentifier, (byte) (deviceId + 1), preKeys).join();\n\n    assertDoesNotThrow(() -> preKeyStore.delete(accountIdentifier).join());\n\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, (byte) (deviceId + 1)).join());\n  }\n\n  @Test\n  void takeSkipsOutOfRangeKeys() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = 1;\n\n    final long outOfRange1 = KeyIdUtil.MAX_KEY_ID + 1;\n    final long outOfRange2 = KeyIdUtil.MAX_KEY_ID + 2;\n    final long validKeyId = 1;\n\n    final List<ECPreKey> preKeys = List.of(\n        generatePreKey(outOfRange1),\n        generatePreKey(outOfRange2),\n        generatePreKey(validKeyId));\n\n    preKeyStore.store(accountIdentifier, deviceId, preKeys).join();\n\n    final Optional<ECPreKey> taken = preKeyStore.take(accountIdentifier, deviceId).join();\n    assertEquals(Optional.of(preKeys.get(2)), taken);\n    assertEquals(Optional.empty(), preKeyStore.take(accountIdentifier, deviceId).join());\n    assertEquals(0, preKeyStore.getCount(accountIdentifier, deviceId).join());\n  }\n\n  private List<ECPreKey> generateRandomPreKeys() {\n    final Set<Integer> keyIds = new HashSet<>(KEY_COUNT);\n\n    while (keyIds.size() < KEY_COUNT) {\n      keyIds.add(Math.abs(ThreadLocalRandom.current().nextInt()));\n    }\n\n    return keyIds.stream()\n        .map(this::generatePreKey)\n        .toList();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/SubscriptionsTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Fail.fail;\nimport static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.FOUND;\nimport static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.NOT_STORED;\nimport static org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult.Type.PASSWORD_MISMATCH;\n\nimport jakarta.ws.rs.ClientErrorException;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Base64;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\nimport java.util.function.Consumer;\nimport javax.annotation.Nonnull;\nimport org.assertj.core.api.Condition;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions.GetResult;\nimport org.whispersystems.textsecuregcm.storage.Subscriptions.Record;\nimport org.whispersystems.textsecuregcm.subscriptions.PaymentProvider;\nimport org.whispersystems.textsecuregcm.subscriptions.ProcessorCustomer;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\nclass SubscriptionsTest {\n\n  private static final long NOW_EPOCH_SECONDS = 1_500_000_000L;\n  private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3);\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.SUBSCRIPTIONS);\n\n  byte[] user;\n  byte[] password;\n  String customer;\n  Instant created;\n  Subscriptions subscriptions;\n\n  @BeforeEach\n  void beforeEach() {\n    user = TestRandomUtil.nextBytes(16);\n    password = TestRandomUtil.nextBytes(16);\n    customer = Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(16));\n    created = Instant.ofEpochSecond(NOW_EPOCH_SECONDS);\n    subscriptions = new Subscriptions(\n        Tables.SUBSCRIPTIONS.tableName(), DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient());\n  }\n\n  @Test\n  void testCreateOnlyOnce() {\n    byte[] password1 = TestRandomUtil.nextBytes(16);\n    byte[] password2 = TestRandomUtil.nextBytes(16);\n    Instant created1 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS);\n    Instant created2 = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);\n\n    CompletableFuture<GetResult> getFuture = subscriptions.get(user, password1);\n    assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult.type).isEqualTo(NOT_STORED);\n      assertThat(getResult.record).isNull();\n    });\n\n    getFuture = subscriptions.get(user, password2);\n    assertThat(getFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult.type).isEqualTo(NOT_STORED);\n      assertThat(getResult.record).isNull();\n    });\n\n    CompletableFuture<Subscriptions.Record> createFuture =\n        subscriptions.create(user, password1, created1);\n    Consumer<Record> recordRequirements = checkFreshlyCreatedRecord(user, password1, created1);\n    assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements);\n\n    // password check fails so this should return null\n    createFuture = subscriptions.create(user, password2, created2);\n    assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).isNull();\n\n    // password check matches, but the record already exists so nothing should get updated\n    createFuture = subscriptions.create(user, password1, created2);\n    assertThat(createFuture).succeedsWithin(DEFAULT_TIMEOUT).satisfies(recordRequirements);\n  }\n\n  @Test\n  void testGet() {\n    byte[] wrongUser = TestRandomUtil.nextBytes(16);\n    byte[] wrongPassword = TestRandomUtil.nextBytes(16);\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n\n    assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult.type).isEqualTo(FOUND);\n      assertThat(getResult.record).isNotNull().satisfies(checkFreshlyCreatedRecord(user, password, created));\n    });\n\n    assertThat(subscriptions.get(user, wrongPassword)).succeedsWithin(DEFAULT_TIMEOUT)\n        .satisfies(getResult -> {\n          assertThat(getResult.type).isEqualTo(PASSWORD_MISMATCH);\n          assertThat(getResult.record).isNull();\n        });\n\n    assertThat(subscriptions.get(wrongUser, password)).succeedsWithin(DEFAULT_TIMEOUT)\n        .satisfies(getResult -> {\n          assertThat(getResult.type).isEqualTo(NOT_STORED);\n          assertThat(getResult.record).isNull();\n        });\n  }\n\n  @Test\n  void testSetCustomerIdAndProcessor() throws Exception {\n    Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n\n    final CompletableFuture<GetResult> getUser = subscriptions.get(user, password);\n    assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT);\n    final Record userRecord = getUser.get().record;\n\n    assertThat(subscriptions.setProcessorAndCustomerId(userRecord,\n        new ProcessorCustomer(customer, PaymentProvider.STRIPE),\n        subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT)\n        .hasFieldOrPropertyWithValue(\"processorCustomer\",\n            Optional.of(new ProcessorCustomer(customer, PaymentProvider.STRIPE)));\n\n    final Condition<Throwable> clientError409Condition = new Condition<>(e ->\n        e instanceof ClientErrorException cee && cee.getResponse().getStatus() == 409, \"Client error: 409\");\n\n    // changing the customer ID is not permitted\n    assertThat(\n        subscriptions.setProcessorAndCustomerId(userRecord,\n            new ProcessorCustomer(customer + \"1\", PaymentProvider.STRIPE),\n            subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT)\n        .withThrowableOfType(ExecutionException.class)\n        .withCauseInstanceOf(ClientErrorException.class)\n        .extracting(Throwable::getCause)\n        .satisfies(clientError409Condition);\n\n    // calling setProcessorAndCustomerId() with the same customer ID is also an error\n    assertThat(\n        subscriptions.setProcessorAndCustomerId(userRecord,\n            new ProcessorCustomer(customer, PaymentProvider.STRIPE),\n            subscriptionUpdated)).failsWithin(DEFAULT_TIMEOUT)\n        .withThrowableOfType(ExecutionException.class)\n        .withCauseInstanceOf(ClientErrorException.class)\n        .extracting(Throwable::getCause)\n        .satisfies(clientError409Condition);\n\n    assertThat(subscriptions.getSubscriberUserByProcessorCustomer(\n        new ProcessorCustomer(customer, PaymentProvider.STRIPE)))\n        .succeedsWithin(DEFAULT_TIMEOUT).\n        isEqualTo(user);\n  }\n\n  @Test\n  void testLookupByCustomerId() throws Exception {\n    Instant subscriptionUpdated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n\n    final CompletableFuture<GetResult> getUser = subscriptions.get(user, password);\n    assertThat(getUser).succeedsWithin(DEFAULT_TIMEOUT);\n    final Record userRecord = getUser.get().record;\n\n    assertThat(subscriptions.setProcessorAndCustomerId(userRecord,\n        new ProcessorCustomer(customer, PaymentProvider.STRIPE),\n        subscriptionUpdated)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.getSubscriberUserByProcessorCustomer(\n        new ProcessorCustomer(customer, PaymentProvider.STRIPE))).\n        succeedsWithin(DEFAULT_TIMEOUT).\n        isEqualTo(user);\n  }\n\n  @Test\n  void testSetCanceledAt() {\n    Instant canceled = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 42);\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.setCanceledAt(user, canceled)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult).isNotNull();\n      assertThat(getResult.type).isEqualTo(FOUND);\n      assertThat(getResult.record).isNotNull().satisfies(record -> {\n        assertThat(record.accessedAt).isEqualTo(canceled);\n        assertThat(record.canceledAt).isEqualTo(canceled);\n        assertThat(record.subscriptionId).isNull();\n      });\n    });\n  }\n\n  @Test\n  void testSubscriptionCreated() {\n    String subscriptionId = Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(16));\n    Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);\n    long level = 42;\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.subscriptionCreated(user, subscriptionId, subscriptionCreated, level)).\n        succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult).isNotNull();\n      assertThat(getResult.type).isEqualTo(FOUND);\n      assertThat(getResult.record).isNotNull().satisfies(record -> {\n        assertThat(record.accessedAt).isEqualTo(subscriptionCreated);\n        assertThat(record.subscriptionId).isEqualTo(subscriptionId);\n        assertThat(record.subscriptionCreatedAt).isEqualTo(subscriptionCreated);\n        assertThat(record.subscriptionLevel).isEqualTo(level);\n        assertThat(record.subscriptionLevelChangedAt).isEqualTo(subscriptionCreated);\n      });\n    });\n  }\n\n  @Test\n  void testSubscriptionCreatedClearCanceledAt() {\n    String subscriptionId = Base64.getEncoder().encodeToString(TestRandomUtil.nextBytes(16));\n    Instant subscriptionCreated = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 1);\n    Instant canceledAt = subscriptionCreated.plusSeconds(1);\n    long level = 42;\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.subscriptionCreated(user, subscriptionId, subscriptionCreated, level))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    assertThat(subscriptions.setCanceledAt(user, canceledAt)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.get(user, password).join().record.canceledAt).isEqualTo(canceledAt);\n\n    assertThat(subscriptions.subscriptionCreated(user, subscriptionId, subscriptionCreated, level))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult).isNotNull();\n      assertThat(getResult.type).isEqualTo(FOUND);\n      assertThat(getResult.record).isNotNull().satisfies(record -> {\n        assertThat(record.accessedAt).isEqualTo(subscriptionCreated);\n        assertThat(record.subscriptionId).isEqualTo(subscriptionId);\n        assertThat(record.subscriptionCreatedAt).isEqualTo(subscriptionCreated);\n        assertThat(record.subscriptionLevel).isEqualTo(level);\n        assertThat(record.subscriptionLevelChangedAt).isEqualTo(subscriptionCreated);\n        assertThat(record.canceledAt).isNull();\n      });\n    });\n  }\n\n  @Test\n  void testSubscriptionLevelChanged() {\n    Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);\n    long level = 1776;\n    String updatedSubscriptionId = \"new\";\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.subscriptionCreated(user, \"original\", created, level - 1)).succeedsWithin(\n        DEFAULT_TIMEOUT);\n    assertThat(subscriptions.subscriptionLevelChanged(user, at, level, updatedSubscriptionId)).succeedsWithin(\n        DEFAULT_TIMEOUT);\n    assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult).isNotNull();\n      assertThat(getResult.type).isEqualTo(FOUND);\n      assertThat(getResult.record).isNotNull().satisfies(record -> {\n        assertThat(record.accessedAt).isEqualTo(at);\n        assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);\n        assertThat(record.subscriptionLevel).isEqualTo(level);\n        assertThat(record.subscriptionId).isEqualTo(updatedSubscriptionId);\n      });\n    });\n  }\n\n  @Test\n  void testSubscriptionLevelChangedClearCanceledAt() {\n    Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);\n    Instant canceledAt = at.plusSeconds(100);\n    long level = 1776;\n    String updatedSubscriptionId = \"new\";\n    assertThat(subscriptions.create(user, password, created)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.subscriptionCreated(user, \"original\", created, level - 1))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    assertThat(subscriptions.setCanceledAt(user, canceledAt)).succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.get(user, password).join().record.canceledAt).isEqualTo(canceledAt);\n\n    assertThat(subscriptions.subscriptionLevelChanged(user, at, level, updatedSubscriptionId))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n    assertThat(subscriptions.get(user, password)).succeedsWithin(DEFAULT_TIMEOUT).satisfies(getResult -> {\n      assertThat(getResult).isNotNull();\n      assertThat(getResult.type).isEqualTo(FOUND);\n      assertThat(getResult.record).isNotNull().satisfies(record -> {\n        assertThat(record.accessedAt).isEqualTo(at);\n        assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);\n        assertThat(record.subscriptionLevel).isEqualTo(level);\n        assertThat(record.subscriptionId).isEqualTo(updatedSubscriptionId);\n        assertThat(record.canceledAt).isNull();\n      });\n    });\n  }\n\n  @Test\n  void testSetIapPurchase() {\n    Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);\n    long level = 100;\n\n    ProcessorCustomer pc = new ProcessorCustomer(\"customerId\", PaymentProvider.GOOGLE_PLAY_BILLING);\n    Record record = subscriptions.create(user, password, created).join();\n\n    // Should be able to set a fresh subscription\n    assertThat(subscriptions.setIapPurchase(record, pc, \"subscriptionId\", level, at))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    record = subscriptions.get(user, password).join().record;\n    assertThat(record.subscriptionLevel).isEqualTo(level);\n    assertThat(record.subscriptionLevelChangedAt).isEqualTo(at);\n    assertThat(record.subscriptionCreatedAt).isEqualTo(at);\n    assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);\n\n    // should be able to update the level\n    Instant nextAt = at.plus(Duration.ofSeconds(10));\n    long nextLevel = level + 1;\n    assertThat(subscriptions.setIapPurchase(record, pc, \"subscriptionId\", nextLevel, nextAt))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    record = subscriptions.get(user, password).join().record;\n    assertThat(record.subscriptionLevel).isEqualTo(nextLevel);\n    assertThat(record.subscriptionLevelChangedAt).isEqualTo(nextAt);\n    assertThat(record.subscriptionCreatedAt).isEqualTo(at);\n    assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);\n\n    nextAt = nextAt.plus(Duration.ofSeconds(10));\n    nextLevel = level + 1;\n\n    pc = new ProcessorCustomer(\"newCustomerId\", PaymentProvider.STRIPE);\n    try {\n      subscriptions.setIapPurchase(record, pc, \"subscriptionId\", nextLevel, nextAt).join();\n      fail(\"should not be able to change the processor for an existing subscription record\");\n    } catch (IllegalArgumentException e) {\n    }\n\n    // should be able to change the customerId of an existing record if the processor matches\n    pc = new ProcessorCustomer(\"newCustomerId\", PaymentProvider.GOOGLE_PLAY_BILLING);\n    assertThat(subscriptions.setIapPurchase(record, pc, \"subscriptionId\", nextLevel, nextAt))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    record = subscriptions.get(user, password).join().record;\n    assertThat(record.subscriptionLevel).isEqualTo(nextLevel);\n    assertThat(record.subscriptionLevelChangedAt).isEqualTo(nextAt);\n    assertThat(record.subscriptionCreatedAt).isEqualTo(at);\n    assertThat(record.getProcessorCustomer().orElseThrow()).isEqualTo(pc);\n  }\n\n  @Test\n  void testSetIapPurchaseClearCanceledAt() {\n    Instant at = Instant.ofEpochSecond(NOW_EPOCH_SECONDS + 500);\n    Instant canceledAt = at.plusSeconds(100);\n    long level = 100;\n\n    ProcessorCustomer pc = new ProcessorCustomer(\"customerId\", PaymentProvider.GOOGLE_PLAY_BILLING);\n    Record record = subscriptions.create(user, password, created).join();\n\n    // Should be able to set a fresh subscription\n    assertThat(subscriptions.setIapPurchase(record, pc, \"subscriptionId\", level, at))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    assertThat(subscriptions.setCanceledAt(record.user, canceledAt))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    record = subscriptions.get(user, password).join().record;\n    assertThat(record.canceledAt).isEqualTo(canceledAt);\n\n    // should be able to update the level\n    Instant nextAt = at.plus(Duration.ofSeconds(10));\n    long nextLevel = level + 1;\n    assertThat(subscriptions.setIapPurchase(record, pc, \"subscriptionId\", nextLevel, nextAt))\n        .succeedsWithin(DEFAULT_TIMEOUT);\n\n    // Resetting the level should clear the \"canceled at\" timestamp\n    record = subscriptions.get(user, password).join().record;\n    assertThat(record.canceledAt).isNull();\n  }\n\n  @Test\n  void testProcessorAndCustomerId() {\n    final ProcessorCustomer processorCustomer =\n        new ProcessorCustomer(\"abc\", PaymentProvider.STRIPE);\n\n    assertThat(processorCustomer.toDynamoBytes()).isEqualTo(new byte[]{1, 97, 98, 99});\n  }\n\n  @Nonnull\n  private static Consumer<Record> checkFreshlyCreatedRecord(\n      byte[] user, byte[] password, Instant created) {\n    return record -> {\n      assertThat(record).isNotNull();\n      assertThat(record.user).isEqualTo(user);\n      assertThat(record.password).isEqualTo(password);\n      assertThat(record.processorCustomer).isNull();\n      assertThat(record.createdAt).isEqualTo(created);\n      assertThat(record.subscriptionId).isNull();\n      assertThat(record.subscriptionCreatedAt).isNull();\n      assertThat(record.subscriptionLevel).isNull();\n      assertThat(record.subscriptionLevelChangedAt).isNull();\n      assertThat(record.accessedAt).isEqualTo(created);\n      assertThat(record.canceledAt).isNull();\n      assertThat(record.currentPeriodEndsAt).isNull();\n    };\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/TestcontainersFoundationDbDatabaseLifecycleManager.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport com.apple.foundationdb.Database;\nimport com.apple.foundationdb.FDB;\nimport earth.adi.testcontainers.containers.FoundationDBContainer;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.testcontainers.utility.DockerImageName;\n\nclass TestcontainersFoundationDbDatabaseLifecycleManager implements FoundationDbDatabaseLifecycleManager {\n\n  private FoundationDBContainer foundationDBContainer;\n  private Database database;\n\n  private static final String FOUNDATIONDB_IMAGE_NAME = \"foundationdb/foundationdb:\" + FoundationDbVersion.getFoundationDbVersion();\n\n  private static final Logger log = LoggerFactory.getLogger(TestcontainersFoundationDbDatabaseLifecycleManager.class);\n\n  @Override\n  public void initializeDatabase(final FDB fdb) {\n    log.info(\"Using Testcontainers FoundationDB container: {}\", FOUNDATIONDB_IMAGE_NAME);\n\n    foundationDBContainer = new FoundationDBContainer(DockerImageName.parse(FOUNDATIONDB_IMAGE_NAME));\n    foundationDBContainer.start();\n\n    database = fdb.open(foundationDBContainer.getClusterFilePath());\n  }\n\n  @Override\n  public Database getDatabase() {\n    return database;\n  }\n\n  @Override\n  public void closeDatabase() {\n    database.close();\n    foundationDBContainer.close();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/VerificationSessionsTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.storage;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertInstanceOf;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.concurrent.CompletionException;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.registration.VerificationSession;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.telephony.CarrierData;\nimport org.whispersystems.textsecuregcm.util.ExceptionUtils;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\n\nclass VerificationSessionsTest {\n\n  private static final Clock clock = Clock.systemUTC();\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.VERIFICATION_SESSIONS);\n\n  private VerificationSessions verificationSessions;\n\n  @BeforeEach\n  void setUp() {\n    verificationSessions = new VerificationSessions(\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), Tables.VERIFICATION_SESSIONS.tableName(), clock);\n  }\n\n  @Test\n  void testExpiration() {\n    final Instant created = Instant.now().minusSeconds(60);\n    final Instant updates = Instant.now();\n    final Duration remoteExpiration = Duration.ofMinutes(2);\n\n    final VerificationSession verificationSession = new VerificationSession(\"test\", null, null,\n        List.of(VerificationSession.Information.PUSH_CHALLENGE), Collections.emptyList(), null, null, true,\n        created.toEpochMilli(), updates.toEpochMilli(), remoteExpiration.toSeconds());\n\n    assertEquals(updates.plus(remoteExpiration).getEpochSecond(), verificationSession.getExpirationEpochSeconds());\n  }\n\n  @Test\n  void testStore() {\n\n    assertTimeoutPreemptively(Duration.ofSeconds(5), () -> {\n\n      final String sessionId = \"sessionId\";\n\n      final Optional<VerificationSession> absentSession = verificationSessions.findForKey(sessionId).join();\n      assertTrue(absentSession.isEmpty());\n\n      final VerificationSession session = new VerificationSession(sessionId, null, new CarrierData(\"Test\", CarrierData.LineType.MOBILE, Optional.of(\"123\"), Optional.empty(), Optional.empty(), Optional.empty()),\n          List.of(VerificationSession.Information.PUSH_CHALLENGE), Collections.emptyList(), null, null, true,\n          clock.millis(), clock.millis(), Duration.ofMinutes(1).toSeconds());\n\n      verificationSessions.insert(sessionId, session).join();\n\n      assertEquals(session, verificationSessions.findForKey(sessionId).join().orElseThrow());\n\n      final CompletionException ce = assertThrows(CompletionException.class,\n          () -> verificationSessions.insert(sessionId, session).join());\n\n      final Throwable t = ExceptionUtils.unwrap(ce);\n      assertInstanceOf(ConditionalCheckFailedException.class, t,\n          \"inserting with the same key should fail conditional checks\");\n\n      final VerificationSession updatedSession = new VerificationSession(sessionId, null, new CarrierData(\"Test\", CarrierData.LineType.MOBILE, Optional.of(\"123\"), Optional.empty(), Optional.empty(), Optional.empty()), Collections.emptyList(),\n          List.of(VerificationSession.Information.PUSH_CHALLENGE), null, null, true, clock.millis(), clock.millis(),\n          Duration.ofMinutes(2).toSeconds());\n      verificationSessions.update(sessionId, updatedSession).join();\n\n      assertEquals(updatedSession, verificationSessions.findForKey(sessionId).join().orElseThrow());\n    });\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceCheckManagerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.mockStatic;\nimport static org.mockito.Mockito.when;\n\nimport com.webauthn4j.appattest.DeviceCheckManager;\nimport com.webauthn4j.appattest.authenticator.DCAppleDevice;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.function.Supplier;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.mockito.MockedStatic;\nimport org.mockito.Mockito;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport org.whispersystems.textsecuregcm.util.Util;\n\nclass AppleDeviceCheckManagerTest {\n\n  private static final UUID ACI = UUID.randomUUID();\n\n  @RegisterExtension\n  static final RedisClusterExtension CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS,\n      DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT);\n\n  private final TestClock clock = TestClock.pinned(Instant.now());\n  private AppleDeviceChecks appleDeviceChecks;\n  private Account account;\n  private AppleDeviceCheckManager appleDeviceCheckManager;\n\n  @BeforeEach\n  void setupDeviceChecks() {\n    clock.pin(Instant.now());\n    account = mock(Account.class);\n    when(account.getUuid()).thenReturn(ACI);\n\n    final DeviceCheckManager deviceCheckManager = DeviceCheckTestUtil.appleDeviceCheckManager();\n    appleDeviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DeviceCheckManager.createObjectConverter(),\n        DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(),\n        DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName());\n    appleDeviceCheckManager = new AppleDeviceCheckManager(appleDeviceChecks, CLUSTER_EXTENSION.getRedisCluster(),\n        deviceCheckManager, DeviceCheckTestUtil.SAMPLE_TEAM_ID, DeviceCheckTestUtil.SAMPLE_BUNDLE_ID);\n  }\n\n  @Test\n  public void missingChallengeAttest() {\n    assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() ->\n        appleDeviceCheckManager.registerAttestation(account,\n            DeviceCheckTestUtil.SAMPLE_KEY_ID,\n            DeviceCheckTestUtil.SAMPLE_ATTESTATION));\n  }\n\n  @Test\n  public void missingChallengeAssert() {\n    assertThatExceptionOfType(ChallengeNotFoundException.class).isThrownBy(() ->\n        appleDeviceCheckManager.validateAssert(account,\n            DeviceCheckTestUtil.SAMPLE_KEY_ID,\n            AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,\n            DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),\n            DeviceCheckTestUtil.SAMPLE_ASSERTION));\n  }\n\n  @Test\n  public void tooManyKeys() throws DuplicatePublicKeyException {\n    final DCAppleDevice dcAppleDevice = DeviceCheckTestUtil.sampleDevice();\n\n    // Fill the table with a bunch of keyIds\n    final List<byte[]> keyIds = IntStream\n        .range(0, AppleDeviceCheckManager.MAX_DEVICE_KEYS - 1)\n        .mapToObj(i -> TestRandomUtil.nextBytes(16)).toList();\n    for (byte[] keyId : keyIds) {\n      appleDeviceChecks.storeAttestation(account, keyId, dcAppleDevice);\n    }\n\n    // We're allowed 1 more key for this account\n    assertThatNoException().isThrownBy(() -> registerAttestation(account));\n\n    // a new key should be rejected\n    assertThatExceptionOfType(TooManyKeysException.class).isThrownBy(() ->\n        appleDeviceCheckManager.registerAttestation(account,\n            TestRandomUtil.nextBytes(16),\n            DeviceCheckTestUtil.SAMPLE_ATTESTATION));\n\n    // we can however accept an existing key\n    assertThatNoException().isThrownBy(() -> registerAttestation(account, false));\n  }\n\n  @Test\n  public void duplicateKeys() {\n    assertThatNoException().isThrownBy(() -> registerAttestation(account));\n    final Account duplicator = mock(Account.class);\n    when(duplicator.getUuid()).thenReturn(UUID.randomUUID());\n\n    // Both accounts use the attestation keyId, the second registration should fail\n    assertThatExceptionOfType(DuplicatePublicKeyException.class)\n        .isThrownBy(() -> registerAttestation(duplicator));\n  }\n\n  @Test\n  public void fetchingChallengeRefreshesTtl() throws RateLimitExceededException {\n    final String challenge =\n        appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account);\n    final String redisKey = AppleDeviceCheckManager.challengeKey(AppleDeviceCheckManager.ChallengeType.ATTEST,\n        account.getUuid());\n\n    final String storedChallenge = CLUSTER_EXTENSION.getRedisCluster()\n        .withCluster(cluster -> cluster.sync().get(redisKey));\n    assertThat(storedChallenge).isEqualTo(challenge);\n\n    final Supplier<Long> ttl = () -> CLUSTER_EXTENSION.getRedisCluster()\n        .withCluster(cluster -> cluster.sync().ttl(redisKey));\n\n    // Wait until the TTL visibly changes (~1sec)\n    while (ttl.get() >= AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds()) {\n      Util.sleep(100);\n    }\n\n    // Our TTL fetch needs to happen before the TTL ticks down to make sure the TTL was actually refreshed. So it must\n    // happen within 1 second. This should be plenty of time, but allow a few retries in case we get very unlucky.\n    final boolean ttlRefreshed = IntStream.range(0, 5)\n        .mapToObj(i -> {\n          assertThatNoException()\n              .isThrownBy(\n                  () -> appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account));\n          return ttl.get() == AppleDeviceCheckManager.CHALLENGE_TTL.toSeconds();\n        })\n        .anyMatch(detectedRefresh -> detectedRefresh);\n    assertThat(ttlRefreshed).isTrue();\n\n    assertThat(appleDeviceCheckManager.createChallenge(AppleDeviceCheckManager.ChallengeType.ATTEST, account))\n        .isEqualTo(challenge);\n  }\n\n  @Test\n  public void validateAssertion() {\n    assertThatNoException().isThrownBy(() -> registerAttestation(account));\n\n    // The sign counter should be 0 since we've made no attests\n    assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter())\n        .isEqualTo(0L);\n\n    // Rig redis to return our sample challenge for the assert\n    final String assertChallengeKey = AppleDeviceCheckManager.challengeKey(\n        AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION,\n        account.getUuid());\n    CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->\n        cluster.sync().set(assertChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE));\n\n    assertThatNoException().isThrownBy(() ->\n        appleDeviceCheckManager.validateAssert(\n            account,\n            DeviceCheckTestUtil.SAMPLE_KEY_ID,\n            AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,\n            DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),\n            DeviceCheckTestUtil.SAMPLE_ASSERTION));\n\n    CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->\n        assertThat(cluster.sync().get(assertChallengeKey)).isNull());\n\n    // the sign counter should now be 1 (read from our sample assert)\n    assertThat(appleDeviceChecks.lookup(account, DeviceCheckTestUtil.SAMPLE_KEY_ID).get().getCounter())\n        .isEqualTo(1L);\n  }\n\n  @Test\n  public void assertionCounterMovesBackwards() {\n    assertThatNoException().isThrownBy(() -> registerAttestation(account));\n\n    // force set the sign counter for our keyId to be larger than the sign counter in our sample assert (1)\n    appleDeviceChecks.updateCounter(account, DeviceCheckTestUtil.SAMPLE_KEY_ID, 2);\n\n    // Rig redis to return our sample challenge for the assert\n    CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> cluster.sync().set(\n        AppleDeviceCheckManager.challengeKey(\n            AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION,\n            account.getUuid()),\n        DeviceCheckTestUtil.SAMPLE_CHALLENGE));\n\n    assertThatExceptionOfType(RequestReuseException.class).isThrownBy(() ->\n        appleDeviceCheckManager.validateAssert(\n            account,\n            DeviceCheckTestUtil.SAMPLE_KEY_ID,\n            AppleDeviceCheckManager.ChallengeType.ASSERT_BACKUP_REDEMPTION, DeviceCheckTestUtil.SAMPLE_CHALLENGE,\n            DeviceCheckTestUtil.SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8),\n            DeviceCheckTestUtil.SAMPLE_ASSERTION));\n  }\n\n  private void registerAttestation(final Account account)\n      throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {\n    registerAttestation(account, true);\n  }\n\n  private void registerAttestation(final Account account, boolean assertChallengeRemoved)\n      throws DeviceCheckVerificationFailedException, ChallengeNotFoundException, TooManyKeysException, DuplicatePublicKeyException {\n    final String attestChallengeKey = AppleDeviceCheckManager.challengeKey(\n        AppleDeviceCheckManager.ChallengeType.ATTEST,\n        account.getUuid());\n    CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster ->\n        cluster.sync().set(attestChallengeKey, DeviceCheckTestUtil.SAMPLE_CHALLENGE));\n    try (MockedStatic<Instant> mocked = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) {\n      mocked.when(Instant::now).thenReturn(DeviceCheckTestUtil.SAMPLE_TIME);\n      appleDeviceCheckManager.registerAttestation(account,\n          DeviceCheckTestUtil.SAMPLE_KEY_ID,\n          DeviceCheckTestUtil.SAMPLE_ATTESTATION);\n    }\n    if (assertChallengeRemoved) {\n      CLUSTER_EXTENSION.getRedisCluster().useCluster(cluster -> {\n        // should be deleted once the attestation is registered\n        assertThat(cluster.sync().get(attestChallengeKey)).isNull();\n      });\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/AppleDeviceChecksTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.webauthn4j.appattest.DeviceCheckManager;\nimport com.webauthn4j.appattest.authenticator.DCAppleDevice;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n\nclass AppleDeviceChecksTest {\n\n  private static final UUID ACI = UUID.randomUUID();\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(\n      DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS,\n      DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT);\n\n  private AppleDeviceChecks deviceChecks;\n  private Account account;\n\n  @BeforeEach\n  void setupDeviceChecks() {\n    account = mock(Account.class);\n    when(account.getUuid()).thenReturn(ACI);\n    deviceChecks = new AppleDeviceChecks(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DeviceCheckManager.createObjectConverter(),\n        DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS.tableName(),\n        DynamoDbExtensionSchema.Tables.APPLE_DEVICE_CHECKS_KEY_CONSTRAINT.tableName());\n  }\n\n  @Test\n  public void testSerde() throws DuplicatePublicKeyException {\n    final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();\n    final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();\n    assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue();\n\n    assertThat(deviceChecks.keyIds(account)).containsExactly(keyId);\n\n    final DCAppleDevice deserialized = deviceChecks.lookup(account, keyId).orElseThrow();\n    assertThat(deserialized.getClass()).isEqualTo(appleDevice.getClass());\n    assertThat(deserialized.getAttestationStatement().getFormat())\n        .isEqualTo(appleDevice.getAttestationStatement().getFormat());\n    assertThat(deserialized.getAttestationStatement().getClass())\n        .isEqualTo(appleDevice.getAttestationStatement().getClass());\n    assertThat(deserialized.getAttestedCredentialData().getCredentialId())\n        .isEqualTo(appleDevice.getAttestedCredentialData().getCredentialId());\n    assertThat(deserialized.getAttestedCredentialData().getCOSEKey())\n        .isEqualTo(appleDevice.getAttestedCredentialData().getCOSEKey());\n    assertThat(deserialized.getAttestedCredentialData().getAaguid())\n        .isEqualTo(appleDevice.getAttestedCredentialData().getAaguid());\n    assertThat(deserialized.getAuthenticatorExtensions().getExtensions())\n        .containsExactlyEntriesOf(appleDevice.getAuthenticatorExtensions().getExtensions());\n    assertThat(deserialized.getCounter())\n        .isEqualTo(appleDevice.getCounter());\n  }\n\n  @Test\n  public void duplicateKeys() throws DuplicatePublicKeyException {\n    final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();\n    final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();\n\n    final Account dupliateAccount = mock(Account.class);\n    when(dupliateAccount.getUuid()).thenReturn(UUID.randomUUID());\n\n    deviceChecks.storeAttestation(account, keyId, appleDevice);\n\n    // Storing same key with a different account fails\n    assertThatExceptionOfType(DuplicatePublicKeyException.class)\n        .isThrownBy(() -> deviceChecks.storeAttestation(dupliateAccount, keyId, appleDevice));\n\n    // Storing the same key with the same account is fine\n    assertThatNoException().isThrownBy(() -> deviceChecks.storeAttestation(account, keyId, appleDevice));\n  }\n\n  @Test\n  public void multipleKeys() throws DuplicatePublicKeyException {\n    final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();\n\n    final List<byte[]> keyIds = IntStream.range(0, 10).mapToObj(i -> TestRandomUtil.nextBytes(16)).toList();\n\n    for (byte[] keyId : keyIds) {\n      // The keyId should typically match the device attestation, but we don't check that at this layer\n      assertThat(deviceChecks.storeAttestation(account, keyId, appleDevice)).isTrue();\n      assertThat(deviceChecks.lookup(account, keyId)).isNotEmpty();\n    }\n    final List<byte[]> actual = deviceChecks.keyIds(account);\n\n    assertThat(actual).containsExactlyInAnyOrderElementsOf(keyIds);\n  }\n\n  @Test\n  public void updateCounter() throws DuplicatePublicKeyException {\n    final DCAppleDevice appleDevice = DeviceCheckTestUtil.appleSampleDevice();\n    final byte[] keyId = appleDevice.getAttestedCredentialData().getCredentialId();\n\n    assertThat(appleDevice.getCounter()).isEqualTo(0L);\n    deviceChecks.storeAttestation(account, keyId, appleDevice);\n    assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(0L);\n    assertThat(deviceChecks.updateCounter(account, keyId, 2)).isTrue();\n    assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L);\n\n    // Should not update since the counter is stale\n    assertThat(deviceChecks.updateCounter(account, keyId, 1)).isFalse();\n    assertThat(deviceChecks.lookup(account, keyId).get().getCounter()).isEqualTo(2L);\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/devicecheck/DeviceCheckTestUtil.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.storage.devicecheck;\n\nimport static org.mockito.Mockito.mockStatic;\n\nimport com.webauthn4j.appattest.DeviceCheckManager;\nimport com.webauthn4j.appattest.authenticator.DCAppleDevice;\nimport com.webauthn4j.appattest.authenticator.DCAppleDeviceImpl;\nimport com.webauthn4j.appattest.data.DCAttestationData;\nimport com.webauthn4j.appattest.data.DCAttestationParameters;\nimport com.webauthn4j.appattest.data.DCAttestationRequest;\nimport com.webauthn4j.appattest.server.DCServerProperty;\nimport com.webauthn4j.data.attestation.AttestationObject;\nimport com.webauthn4j.data.client.challenge.DefaultChallenge;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.UncheckedIOException;\nimport java.nio.charset.StandardCharsets;\nimport java.security.MessageDigest;\nimport java.security.NoSuchAlgorithmException;\nimport java.time.Instant;\nimport java.util.Base64;\nimport org.mockito.MockedStatic;\nimport org.mockito.Mockito;\n\npublic class DeviceCheckTestUtil {\n\n  // https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem\n  private static final String APPLE_APP_ATTEST_ROOT = \"\"\"\n      -----BEGIN CERTIFICATE-----\n      MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw\n      JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK\n      QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa\n      Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv\n      biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y\n      bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh\n      NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au\n      Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/\n      MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw\n      CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn\n      53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV\n      oyFraWVIyd/dganmrduC1bmTBGwD\n      -----END CERTIFICATE-----\n      \"\"\";\n\n  // Sample attestation from apple docs:\n  // https://developer.apple.com/documentation/devicecheck/attestation-object-validation-guide#Example-setup\n  final static String APPLE_SAMPLE_TEAM_ID = \"0352187391\";\n  final static String APPLE_SAMPLE_BUNDLE_ID = \"com.apple.example_app_attest\";\n  final static String APPLE_SAMPLE_CHALLENGE = \"test_server_challenge\";\n  final static byte[] APPLE_SAMPLE_KEY_ID = Base64.getDecoder().decode(\"bSrEhF8TIzIvWSPwvZ0i2+UOBre4ASH84rK15m6emNY=\");\n  final static byte[] APPLE_SAMPLE_ATTESTATION = loadBinaryResource(\"apple-sample-attestation\");\n  // Leaf certificate in apple sample attestation expires 2024-04-20\n  final static Instant APPLE_SAMPLE_TIME = Instant.parse(\"2024-04-19T00:00:00.00Z\");\n\n  // Sample attestation from webauthn4j:\n  // https://github.com/webauthn4j/webauthn4j/blob/6b7a8f8edce4ab589c49ecde8740873ab96c4218/webauthn4j-appattest/src/test/java/com/webauthn4j/appattest/DeviceCheckManagerTest.java#L126\n  final static String SAMPLE_TEAM_ID = \"8YE23NZS57\";\n  final static String SAMPLE_BUNDLE_ID = \"com.kayak.travel\";\n  final static byte[] SAMPLE_KEY_ID = Base64.getDecoder().decode(\"VnfqjSp0rWyyqNhrfh+9/IhLIvXuYTPAmJEVQwl4dko=\");\n  final static String SAMPLE_CHALLENGE = \"1234567890abcdefgh\"; // same challenge used for the attest and assert\n  final static byte[] SAMPLE_ASSERTION = loadBinaryResource(\"webauthn4j-sample-assertion\");\n  final static byte[] SAMPLE_ATTESTATION = loadBinaryResource(\"webauthn4j-sample-attestation\");\n  // Leaf certificate in sample attestation expires 2020-09-30\n  final static Instant SAMPLE_TIME = Instant.parse(\"2020-09-28T00:00:00Z\");\n\n\n  public static DeviceCheckManager appleDeviceCheckManager() {\n    return new DeviceCheckManager(new AppleDeviceCheckTrustAnchor());\n  }\n\n  public static DCAppleDevice sampleDevice() {\n    final byte[] clientDataHash = sha256(SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8));\n    return validate(SAMPLE_CHALLENGE, clientDataHash, SAMPLE_KEY_ID, SAMPLE_ATTESTATION, SAMPLE_TEAM_ID,\n        SAMPLE_BUNDLE_ID, SAMPLE_TIME);\n  }\n\n  public static DCAppleDevice appleSampleDevice() {\n    // Note: the apple example provides the clientDataHash (typically the SHA256 of the challenge), NOT the challenge,\n    // despite them referring to the value as a challenge\n    final byte[] clientDataHash = APPLE_SAMPLE_CHALLENGE.getBytes(StandardCharsets.UTF_8);\n\n    return validate(APPLE_SAMPLE_CHALLENGE, clientDataHash, APPLE_SAMPLE_KEY_ID, APPLE_SAMPLE_ATTESTATION,\n        APPLE_SAMPLE_TEAM_ID, APPLE_SAMPLE_BUNDLE_ID, APPLE_SAMPLE_TIME);\n  }\n\n  private static DCAppleDevice validate(final String challengePlainText, final byte[] clientDataHash,\n      final byte[] keyId, final byte[] attestation, final String teamId, final String bundleId, final Instant now) {\n\n    final DCAttestationRequest dcAttestationRequest = new DCAttestationRequest(keyId, attestation, clientDataHash);\n\n    final DCAttestationData dcAttestationData;\n    try (final MockedStatic<Instant> instantMock = mockStatic(Instant.class, Mockito.CALLS_REAL_METHODS)) {\n      instantMock.when(Instant::now).thenReturn(now);\n\n      dcAttestationData = appleDeviceCheckManager().validate(dcAttestationRequest, new DCAttestationParameters(\n          new DCServerProperty(\n              teamId, bundleId,\n              new DefaultChallenge(challengePlainText.getBytes(StandardCharsets.UTF_8)))));\n    }\n\n    final AttestationObject attestationObject = dcAttestationData.getAttestationObject();\n    return new DCAppleDeviceImpl(\n        attestationObject.getAuthenticatorData().getAttestedCredentialData(),\n        attestationObject.getAttestationStatement(),\n        attestationObject.getAuthenticatorData().getSignCount(),\n        attestationObject.getAuthenticatorData().getExtensions());\n  }\n\n  private static byte[] sha256(byte[] bytes) {\n    final MessageDigest sha256;\n    try {\n      sha256 = MessageDigest.getInstance(\"SHA-256\");\n    } catch (final NoSuchAlgorithmException e) {\n      // All Java implementations are required to support SHA-256\n      throw new AssertionError(e);\n    }\n    return sha256.digest(bytes);\n  }\n\n  private static byte[] loadBinaryResource(final String resourceName) {\n    try (InputStream stream = DeviceCheckTestUtil.class.getResourceAsStream(resourceName)) {\n      if (stream == null) {\n        throw new IllegalArgumentException(\"Resource not found: \" + resourceName);\n      }\n      return stream.readAllBytes();\n    } catch (IOException e) {\n      throw new UncheckedIOException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/storage/foundationdb/FoundationDbMessageStoreTest.java",
    "content": "package org.whispersystems.textsecuregcm.storage.foundationdb;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.apple.foundationdb.KeyValue;\nimport com.apple.foundationdb.async.AsyncUtil;\nimport com.apple.foundationdb.tuple.Tuple;\nimport com.apple.foundationdb.tuple.Versionstamp;\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport java.io.UncheckedIOException;\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.Collections;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.Executors;\nimport java.util.function.Function;\nimport java.util.stream.Collectors;\nimport java.util.stream.IntStream;\nimport java.util.stream.Stream;\nimport io.dropwizard.util.DataSize;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.FoundationDbClusterExtension;\nimport org.whispersystems.textsecuregcm.util.Conversions;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass FoundationDbMessageStoreTest {\n\n  @RegisterExtension\n  static FoundationDbClusterExtension FOUNDATION_DB_EXTENSION = new FoundationDbClusterExtension(2);\n\n  private FoundationDbMessageStore foundationDbMessageStore;\n\n  private static final Clock CLOCK = Clock.fixed(Instant.ofEpochSecond(500), ZoneId.of(\"UTC\"));\n\n  @BeforeEach\n  void setup() {\n    foundationDbMessageStore = new FoundationDbMessageStore(\n        FOUNDATION_DB_EXTENSION.getDatabases(),\n        Executors.newVirtualThreadPerTaskExecutor(),\n        CLOCK);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void insert(final long presenceUpdatedBeforeSeconds, final boolean ephemeral, final boolean expectMessagesInserted,\n      final boolean expectVersionstampUpdated, final boolean expectPresenceState) {\n    final AciServiceIdentifier aci = new AciServiceIdentifier(UUID.randomUUID());\n    final List<Byte> deviceIds = IntStream.range(Device.PRIMARY_ID, Device.PRIMARY_ID + 6)\n        .mapToObj(i -> (byte) i)\n        .toList();\n    deviceIds.forEach(deviceId -> writePresenceKey(aci, deviceId, 1, presenceUpdatedBeforeSeconds));\n    final Map<Byte, MessageProtos.Envelope> messagesByDeviceId = deviceIds.stream()\n        .collect(Collectors.toMap(Function.identity(), _ -> generateRandomMessage(ephemeral)));\n    final Map<Byte, FoundationDbMessageStore.InsertResult> result = foundationDbMessageStore.insert(aci, messagesByDeviceId).join();\n    assertNotNull(result);\n\n    final Optional<Versionstamp> returnedVersionstamp = result.values().stream().findFirst()\n        .flatMap(FoundationDbMessageStore.InsertResult::versionstamp);\n    if (expectMessagesInserted) {\n      assertTrue(returnedVersionstamp.isPresent());\n      assertTrue(result.values().stream().allMatch(insertResult -> returnedVersionstamp.equals(insertResult.versionstamp())));\n      final Map<Byte, MessageProtos.Envelope> storedMessagesByDeviceId = deviceIds.stream()\n          .collect(Collectors.toMap(Function.identity(), deviceId -> {\n            try {\n              return MessageProtos.Envelope.parseFrom(\n                  getMessageByVersionstamp(aci, deviceId, returnedVersionstamp.get()));\n            } catch (final InvalidProtocolBufferException e) {\n              throw new UncheckedIOException(e);\n            }\n          }));\n\n      assertEquals(messagesByDeviceId, storedMessagesByDeviceId);\n    } else {\n      assertTrue(result.values().stream().allMatch(insertResult -> insertResult.versionstamp().isEmpty()));\n    }\n\n    if (expectVersionstampUpdated) {\n      final Optional<Versionstamp> messagesAvailableWatchVersionstamp = getMessagesAvailableWatch(aci);\n      assertTrue(messagesAvailableWatchVersionstamp.isPresent());\n      assertEquals(returnedVersionstamp, messagesAvailableWatchVersionstamp,\n          \"messages available versionstamp should be the versionstamp of the last insert transaction\");\n    } else {\n      assertTrue(getMessagesAvailableWatch(aci).isEmpty());\n    }\n\n    assertTrue(result.values().stream().allMatch(insertResult -> insertResult.present() == expectPresenceState));\n  }\n\n  private static Stream<Arguments> insert() {\n    return Stream.of(\n        Arguments.argumentSet(\"Non-ephemeral messages with all devices online\",\n            10L, false, true, true, true),\n        Arguments.argumentSet(\n            \"Ephemeral messages with presence updated exactly at the second before which the device would be considered offline\",\n            300L, true, true, true, true),\n        Arguments.argumentSet(\"Non-ephemeral messages for with all devices offline\",\n            310L, false, true, false, false),\n        Arguments.argumentSet(\"Ephemeral messages with all devices offline\",\n            310L, true, false, false, false)\n    );\n  }\n\n  @Test\n  void versionstampCorrectlyUpdatedOnMultipleInserts() {\n    final AciServiceIdentifier aci = new AciServiceIdentifier(UUID.randomUUID());\n    writePresenceKey(aci, Device.PRIMARY_ID, 1, 10L);\n    foundationDbMessageStore.insert(Map.of(aci, Map.of(Device.PRIMARY_ID, generateRandomMessage(false)))).join();\n    final Map<Byte, FoundationDbMessageStore.InsertResult> secondMessageInsertResult = foundationDbMessageStore.insert(aci,\n        Map.of(Device.PRIMARY_ID, generateRandomMessage(false))).join();\n\n    final Optional<Versionstamp> messagesAvailableWatchVersionstamp = getMessagesAvailableWatch(aci);\n    assertTrue(messagesAvailableWatchVersionstamp.isPresent());\n    assertEquals(\n        secondMessageInsertResult.get(Device.PRIMARY_ID).versionstamp(),\n        messagesAvailableWatchVersionstamp);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void insertOnlyOneDevicePresent(final boolean ephemeral) {\n    final AciServiceIdentifier aci = new AciServiceIdentifier(UUID.randomUUID());\n    final List<Byte> deviceIds = IntStream.range(Device.PRIMARY_ID, Device.PRIMARY_ID + 6)\n        .mapToObj(i -> (byte) i)\n        .toList();\n    // Only 1 device has a recent presence, the others do not have presence keys present.\n    writePresenceKey(aci, Device.PRIMARY_ID, 1, 10L);\n    final Map<Byte, MessageProtos.Envelope> messagesByDeviceId = deviceIds.stream()\n        .collect(Collectors.toMap(Function.identity(), _ -> generateRandomMessage(ephemeral)));\n    final Map<Byte, FoundationDbMessageStore.InsertResult> result = foundationDbMessageStore.insert(aci, messagesByDeviceId).join();\n    assertNotNull(result);\n    final Optional<Versionstamp> returnedVersionstamp = result.get(Device.PRIMARY_ID).versionstamp();\n    assertTrue(returnedVersionstamp.isPresent(),\n        \"versionstamp should be present for online device\");\n\n    assertArrayEquals(\n        messagesByDeviceId.get(Device.PRIMARY_ID).toByteArray(),\n        getMessageByVersionstamp(aci, Device.PRIMARY_ID, returnedVersionstamp.get()),\n        \"Message for primary should always be stored since it has a recently updated presence\");\n\n    if (ephemeral) {\n      assertTrue(IntStream.range(Device.PRIMARY_ID + 1, Device.PRIMARY_ID + 6)\n          .mapToObj(deviceId -> getMessageByVersionstamp(aci, (byte) deviceId, returnedVersionstamp.get()))\n          .allMatch(Objects::isNull), \"Ephemeral messages for non-present devices must not be stored\");\n      assertTrue(IntStream.range(Device.PRIMARY_ID + 1, Device.PRIMARY_ID + 6)\n              .mapToObj(deviceId -> result.get((byte) deviceId).versionstamp())\n              .allMatch(Optional::isEmpty),\n          \"Unexpected versionstamp found for one or more devices that didn't have any messages inserted\");\n    } else {\n      IntStream.range(Device.PRIMARY_ID + 1, Device.PRIMARY_ID)\n          .forEach(deviceId -> {\n            final byte[] messageBytes = getMessageByVersionstamp(aci, (byte) deviceId, returnedVersionstamp.get());\n            assertEquals(messagesByDeviceId.get((byte) deviceId).toByteArray(), messageBytes,\n                \"Non-ephemeral messages must always be stored\");\n          });\n    }\n\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void isClientPresent(final byte[] presenceValueBytes, final boolean expectPresent) {\n    assertEquals(expectPresent, foundationDbMessageStore.isClientPresent(presenceValueBytes));\n  }\n\n  static Stream<Arguments> isClientPresent() {\n    return Stream.of(\n        Arguments.argumentSet(\"Presence value doesn't exist\",\n            null, false),\n        Arguments.argumentSet(\"Presence updated recently\",\n            Conversions.longToByteArray(constructPresenceValue(42, getEpochSecondsBeforeClock(5))), true),\n        Arguments.argumentSet(\"Presence updated same second as current time\",\n            Conversions.longToByteArray(constructPresenceValue(42, getEpochSecondsBeforeClock(0))), true),\n        Arguments.argumentSet(\n            \"Presence updated exactly at the second before which it would have been considered offline\",\n            Conversions.longToByteArray(constructPresenceValue(42, getEpochSecondsBeforeClock(300))), true),\n        Arguments.argumentSet(\"Presence expired\",\n            Conversions.longToByteArray(constructPresenceValue(42, getEpochSecondsBeforeClock(400))), false)\n    );\n  }\n\n  /// Represents a cohort of recipients with the same config\n  record MultiRecipientTestConfig(int shardNum, int numRecipients, boolean devicePresent,\n                                  boolean generateEphemeralMessages, boolean expectMessagesInserted) {}\n\n  @ParameterizedTest\n  @MethodSource\n  void insertMultiRecipient(final List<MultiRecipientTestConfig> testConfigs, final DataSize contentSize,\n      final int[] expectedNumTransactionsByShard) {\n    // Generate a list of ACIs for each test config\n    final List<List<AciServiceIdentifier>> acisByConfig = testConfigs.stream()\n        .map(testConfig -> IntStream.range(0, testConfig.numRecipients())\n            .mapToObj(_ -> generateRandomAciForShard(testConfig.shardNum()))\n            .toList())\n        .toList();\n\n    // Generate MRM bundles for each ACI, for each test config. Later, we'll assert if the stored messages (if expected)\n    // are the same as those we generated.\n    final List<Map<AciServiceIdentifier, Map<Byte, MessageProtos.Envelope>>> mrmByConfig = IntStream.range(0,\n            testConfigs.size())\n        .mapToObj(i -> {\n          final List<AciServiceIdentifier> acis = acisByConfig.get(i);\n          final MultiRecipientTestConfig testConfig = testConfigs.get(i);\n          return acis.stream()\n              .collect(Collectors.toMap(\n                  Function.identity(),\n                  _ -> Map.of(Device.PRIMARY_ID,\n                      generateRandomMessage(testConfig.generateEphemeralMessages(), (int) contentSize.toBytes()))));\n\n        })\n        .toList();\n\n    // Create the consolidated MRM bundle by ACI.\n    final Map<AciServiceIdentifier, Map<Byte, MessageProtos.Envelope>> mrmBundle = new HashMap<>();\n    mrmByConfig.forEach(mrmBundle::putAll);\n\n    // Write a presence key for the cohort of recipients if the config indicates that the device must be present.\n    for (int i = 0; i < testConfigs.size(); i++) {\n      final List<AciServiceIdentifier> acis = acisByConfig.get(i);\n      final MultiRecipientTestConfig testConfig = testConfigs.get(i);\n      if (testConfig.devicePresent()) {\n        acis.forEach(aci -> writePresenceKey(aci, Device.PRIMARY_ID, 1, 10L));\n      }\n    }\n\n    final Map<AciServiceIdentifier, Map<Byte, FoundationDbMessageStore.InsertResult>> result = foundationDbMessageStore.insert(mrmBundle).join();\n    assertNotNull(result);\n\n    // Compute the set of versionstamps by shard number from the individual device insert results, so that we can\n    // assert that each shard has the expected number of committed transactions.\n    final Map<Integer, Set<Versionstamp>> returnedVersionstampsByShard = new HashMap<>();\n    result.forEach((aci, deviceResults) -> {\n      final int shardNum = foundationDbMessageStore.hashAciToShardNumber(aci);\n      final Set<Versionstamp> versionstampSet = returnedVersionstampsByShard.computeIfAbsent(shardNum, _ -> new HashSet<>());\n      deviceResults.forEach((_, deviceResult) -> deviceResult.versionstamp().ifPresent(versionstampSet::add));\n    });\n\n    final int[] returnedNumVersionstampsByShard = new int[FOUNDATION_DB_EXTENSION.getDatabases().length];\n    for (int i = 0; i < returnedNumVersionstampsByShard.length; i++) {\n      returnedNumVersionstampsByShard[i] = returnedVersionstampsByShard.getOrDefault(i, Collections.emptySet()).size();\n    }\n\n    assertArrayEquals(expectedNumTransactionsByShard, returnedNumVersionstampsByShard);\n\n    // For each cohort of recipients, check whether the stored messages (if expected) are the same as those we inserted\n    // and whether the returned device presence states are the same as the configured states.\n    IntStream.range(0, testConfigs.size()).forEach(i -> {\n      final List<AciServiceIdentifier> acis = acisByConfig.get(i);\n      final MultiRecipientTestConfig shardConfig = testConfigs.get(i);\n      if (shardConfig.expectMessagesInserted()) {\n        final Map<AciServiceIdentifier, Map<Byte, MessageProtos.Envelope>> storedMrmBundle = acis.stream()\n            .collect(Collectors.toMap(Function.identity(), aci -> {\n              final List<KeyValue> items = getItemsInDeviceQueue(aci, Device.PRIMARY_ID);\n              assertEquals(1, items.size());\n              try {\n                final MessageProtos.Envelope envelope = MessageProtos.Envelope.parseFrom(items.getFirst().getValue());\n                return Map.of(Device.PRIMARY_ID, envelope);\n              } catch (final InvalidProtocolBufferException e) {\n                throw new UncheckedIOException(e);\n              }\n            }));\n        assertEquals(mrmByConfig.get(i), storedMrmBundle,\n            \"Stored message bundle does not match inserted message bundle\");\n      } else {\n        assertEquals(0, acis\n            .stream()\n            .mapToInt(aci -> getItemsInDeviceQueue(aci, Device.PRIMARY_ID).size())\n            .sum(), \"Unexpected messages found in device queue\");\n      }\n\n      assertTrue(acis\n              .stream()\n              .allMatch(\n                  aci -> result.get(aci).get(Device.PRIMARY_ID).present() == shardConfig.devicePresent()),\n          \"Device presence state from insert result does not match expected state\");\n    });\n  }\n\n  static Stream<Arguments> insertMultiRecipient() {\n    return Stream.of(\n        Arguments.argumentSet(\"Multiple recipients on a single shard should result in a single transaction\",\n            List.of(\n                new MultiRecipientTestConfig(0, 5, true, false, true)),\n            DataSize.bytes(128), new int[] {1, 0}),\n        Arguments.argumentSet(\n            \"Multiple recipients on a single shard exceeding the transaction limit should be broken up into multiple transactions\",\n            List.of(\n                new MultiRecipientTestConfig(0, 15, true, false, true)),\n            DataSize.kilobytes(90), new int[] {2, 0}),\n        Arguments.argumentSet(\"Multiple recipients on different shards should result in multiple transactions\",\n            List.of(\n                new MultiRecipientTestConfig(0, 5, true, false, true),\n                new MultiRecipientTestConfig(1, 5, true, false, true)),\n            DataSize.bytes(128), new int[] {1, 1}),\n        Arguments.argumentSet(\n            \"Multiple recipients on different shards each exceeding the transaction limit should be broken up into multiple transactions on each shard\",\n            List.of(\n                new MultiRecipientTestConfig(0, 15, true, false, true),\n                new MultiRecipientTestConfig(1, 15, true, false, true)),\n           DataSize.kilobytes(90), new int[] {2, 2}),\n        Arguments.argumentSet(\n            \"Multiple recipients on a single shard with ephemeral messages and no devices present should result in no transactions committed\",\n            List.of(\n                new MultiRecipientTestConfig(0, 5, false, true, false)),\n            DataSize.bytes(128), new int[] {0, 0}),\n        Arguments.argumentSet(\n            \"Multiple recipients on different shards with ephemeral messages and no devices present should result in no transactions committed\",\n            List.of(\n                new MultiRecipientTestConfig(0, 5, false, true, false),\n                new MultiRecipientTestConfig(1, 5, false, true, false)),\n            DataSize.bytes(128), new int[] {0, 0}),\n        Arguments.argumentSet(\n            \"Multiple recipients on two shards with one shard having no devices present should result in only one transaction\",\n            List.of(\n                new MultiRecipientTestConfig(0, 5, false, true, false),\n                new MultiRecipientTestConfig(1, 5, true, true, true)),\n            DataSize.bytes(128), new int[] {0, 1}),\n        Arguments.argumentSet(\n            \"Multiple recipients on a single shard with some recipients having no devices present should result in only one transaction\",\n            List.of(\n                new MultiRecipientTestConfig(0, 3, false, true, false),\n                new MultiRecipientTestConfig(0, 3, true, true, true)),\n            DataSize.bytes(128), new int[] {1, 0}),\n        Arguments.argumentSet(\n            \"Multiple recipients on a single shard with total size just exceeding 2 chunks should result in 3 transactions\",\n            List.of(\n                new MultiRecipientTestConfig(0, 23, true, false, true)),\n            DataSize.kilobytes(90), new int[] {3, 0})\n    );\n  }\n\n  @Test\n  void insertEmptyBundle() {\n    assertThrows(IllegalArgumentException.class, () -> foundationDbMessageStore.insert(\n        Map.of(generateRandomAciForShard(0), Collections.emptyMap())));\n  }\n\n  private static MessageProtos.Envelope generateRandomMessage(final boolean ephemeral) {\n    return generateRandomMessage(ephemeral, 16);\n  }\n\n  private static MessageProtos.Envelope generateRandomMessage(final boolean ephemeral, final int contentSize) {\n    return MessageProtos.Envelope.newBuilder()\n        .setContent(ByteString.copyFrom(TestRandomUtil.nextBytes(contentSize)))\n        .setEphemeral(ephemeral)\n        .build();\n  }\n\n  private byte[] getMessageByVersionstamp(final AciServiceIdentifier aci, final byte deviceId,\n      final Versionstamp versionstamp) {\n    return foundationDbMessageStore.getShardForAci(aci).read(transaction -> {\n      final byte[] key = foundationDbMessageStore.getDeviceQueueSubspace(aci, deviceId)\n          .pack(Tuple.from(versionstamp));\n      return transaction.get(key);\n    }).join();\n  }\n\n  private Optional<Versionstamp> getMessagesAvailableWatch(final AciServiceIdentifier aci) {\n    return foundationDbMessageStore.getShardForAci(aci)\n        .read(transaction -> transaction.get(foundationDbMessageStore.getMessagesAvailableWatchKey(aci))\n            .thenApply(value -> value == null ? null : Tuple.fromBytes(value).getVersionstamp(0))\n            .thenApply(Optional::ofNullable))\n        .join();\n  }\n\n  private void writePresenceKey(final AciServiceIdentifier aci, final byte deviceId, final int serverId,\n      final long secondsBeforeCurrentTime) {\n    foundationDbMessageStore.getShardForAci(aci).run(transaction -> {\n      final byte[] presenceKey = foundationDbMessageStore.getPresenceKey(aci, deviceId);\n      final long presenceUpdateEpochSeconds = getEpochSecondsBeforeClock(secondsBeforeCurrentTime);\n      final long presenceValue = constructPresenceValue(serverId, presenceUpdateEpochSeconds);\n      transaction.set(presenceKey, Conversions.longToByteArray(presenceValue));\n      return null;\n    });\n  }\n\n  private static long getEpochSecondsBeforeClock(final long secondsBefore) {\n    return CLOCK.instant().minusSeconds(secondsBefore).getEpochSecond();\n  }\n\n  private static long constructPresenceValue(final int serverId, final long presenceUpdateEpochSeconds) {\n    return (long) (serverId & 0x0ffff) << 48 | (presenceUpdateEpochSeconds & 0x0000ffffffffffffL);\n  }\n\n  private AciServiceIdentifier generateRandomAciForShard(final int shardNumber) {\n    assert shardNumber < FOUNDATION_DB_EXTENSION.getDatabases().length;\n    while (true) {\n      final AciServiceIdentifier aci = new AciServiceIdentifier(UUID.randomUUID());\n      if (foundationDbMessageStore.hashAciToShardNumber(aci) == shardNumber) {\n        return aci;\n      }\n    }\n  }\n\n  private List<KeyValue> getItemsInDeviceQueue(final AciServiceIdentifier aci, final byte deviceId) {\n    return foundationDbMessageStore.getShardForAci(aci).readAsync(transaction -> AsyncUtil.collect(transaction.getRange(\n        foundationDbMessageStore.getDeviceQueueSubspace(aci, deviceId).range()))).join();\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreClientTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatException;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.apple.itunes.storekit.client.APIError;\nimport com.apple.itunes.storekit.client.APIException;\nimport com.apple.itunes.storekit.client.AppStoreServerAPIClient;\nimport com.apple.itunes.storekit.model.Environment;\nimport com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;\nimport com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;\nimport com.apple.itunes.storekit.model.LastTransactionsItem;\nimport com.apple.itunes.storekit.model.Status;\nimport com.apple.itunes.storekit.model.StatusResponse;\nimport com.apple.itunes.storekit.verification.SignedDataVerifier;\nimport com.apple.itunes.storekit.verification.VerificationException;\nimport java.io.IOException;\nimport java.io.UncheckedIOException;\nimport io.micrometer.core.instrument.Tags;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\n\nclass AppleAppStoreClientTest {\n\n  private final static String ORIGINAL_TX_ID = \"originalTxIdTest\";\n  private final static String SIGNED_RENEWAL_INFO = \"signedRenewalInfoTest\";\n  private final static String SIGNED_TX_INFO = \"signedRenewalInfoTest\";\n  private final static String PRODUCT_ID = \"productIdTest\";\n\n  private final AppStoreServerAPIClient productionClient = mock(AppStoreServerAPIClient.class);\n  private final AppStoreServerAPIClient sandboxClient = mock(AppStoreServerAPIClient.class);\n  private final SignedDataVerifier productionSignedDataVerifier = mock(SignedDataVerifier.class);\n  private final SignedDataVerifier sandboxSignedDataVerifier = mock(SignedDataVerifier.class);\n  private AppleAppStoreClient apiWrapper;\n\n  @BeforeEach\n  public void setup() {\n    reset(productionClient, productionSignedDataVerifier, sandboxClient, sandboxSignedDataVerifier);\n    apiWrapper = new AppleAppStoreClient(Environment.PRODUCTION, productionSignedDataVerifier, productionClient,\n        sandboxSignedDataVerifier, sandboxClient, null);\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = APIError.class, mode = EnumSource.Mode.EXCLUDE, names = \"TRANSACTION_ID_NOT_FOUND\")\n  public void noFallbackOnOtherErrors(APIError error) throws APIException, IOException {\n    when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenThrow(new APIException(404, error, \"test\"));\n\n    assertThatException().isThrownBy(() -> apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID, Tags.empty()));\n    verifyNoInteractions(sandboxClient);\n  }\n\n  @Test\n  public void fallbackOnNoTransactionFound()\n      throws APIException, IOException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException {\n    when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenThrow(new APIException(404, APIError.TRANSACTION_ID_NOT_FOUND, \"test\"));\n\n    when(sandboxClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenReturn(new StatusResponse().environment(Environment.SANDBOX));\n\n    final StatusResponse allSubscriptions = apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID, Tags.empty());\n\n    assertThat(allSubscriptions.getEnvironment()).isEqualTo(Environment.SANDBOX);\n    verify(productionClient).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});\n    verify(sandboxClient).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});\n  }\n\n  @Test\n  public void retryEventuallyWorks()\n      throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException {\n    // Should retry up to 3 times\n    when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), \"test\"))\n        .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), \"test\"))\n        .thenReturn(new StatusResponse().environment(Environment.PRODUCTION));\n    final StatusResponse statusResponse = apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID, Tags.empty());\n    assertThat(statusResponse.getEnvironment()).isEqualTo(Environment.PRODUCTION);\n    verifyNoInteractions(sandboxClient);\n  }\n\n  @Test\n  public void retryEventuallyGivesUp() throws APIException, IOException, VerificationException {\n    // Should retry up to 3 times\n    when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), \"test\"));\n    assertThatException()\n        .isThrownBy(() -> apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID, Tags.empty()))\n        .isInstanceOf(UncheckedIOException.class)\n        .withRootCauseInstanceOf(APIException.class);\n\n    verify(productionClient, times(3)).getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});\n    verifyNoInteractions(sandboxClient);\n  }\n\n  @Test\n  public void sandboxDoesRetries()\n      throws APIException, IOException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException {\n    when(productionClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenThrow(new APIException(404, APIError.TRANSACTION_ID_NOT_FOUND, \"test\"));\n\n    when(sandboxClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), \"test\"))\n        .thenThrow(new APIException(404, APIError.ORIGINAL_TRANSACTION_ID_NOT_FOUND_RETRYABLE.errorCode(), \"test\"))\n        .thenReturn(new StatusResponse().environment(Environment.SANDBOX));\n\n    final StatusResponse statusResponse = apiWrapper.getAllSubscriptions(ORIGINAL_TX_ID, Tags.empty());\n    assertThat(statusResponse.getEnvironment()).isEqualTo(Environment.SANDBOX);\n\n    verify(productionClient, times(3))\n        .getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});\n    verify(sandboxClient, times(3))\n        .getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{});\n  }\n\n  @ParameterizedTest\n  @EnumSource(value = Environment.class, mode = EnumSource.Mode.INCLUDE, names = {\"SANDBOX\", \"PRODUCTION\"})\n  public void verifySignatureTest(Environment environment) throws VerificationException {\n    final SignedDataVerifier expectedVerifier, unexpectedVerifier;\n    if (environment.equals(Environment.SANDBOX)) {\n      expectedVerifier = sandboxSignedDataVerifier;\n      unexpectedVerifier = productionSignedDataVerifier;\n    } else {\n      expectedVerifier = productionSignedDataVerifier;\n      unexpectedVerifier = sandboxSignedDataVerifier;\n    }\n\n    when(expectedVerifier.verifyAndDecodeTransaction(SIGNED_TX_INFO))\n        .thenReturn(new JWSTransactionDecodedPayload().productId(PRODUCT_ID));\n    when(expectedVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO))\n        .thenReturn(new JWSRenewalInfoDecodedPayload());\n\n    apiWrapper.verify(environment, new LastTransactionsItem()\n        .originalTransactionId(ORIGINAL_TX_ID)\n        .status(Status.ACTIVE)\n        .signedRenewalInfo(SIGNED_RENEWAL_INFO)\n        .signedTransactionInfo(SIGNED_TX_INFO));\n\n    verify(expectedVerifier).verifyAndDecodeTransaction(SIGNED_TX_INFO);\n    verify(expectedVerifier).verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO);\n    verifyNoInteractions(unexpectedVerifier);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/AppleAppStoreManagerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.when;\n\nimport com.apple.itunes.storekit.client.APIException;\nimport com.apple.itunes.storekit.client.AppStoreServerAPIClient;\nimport com.apple.itunes.storekit.model.AutoRenewStatus;\nimport com.apple.itunes.storekit.model.Environment;\nimport com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;\nimport com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;\nimport com.apple.itunes.storekit.model.LastTransactionsItem;\nimport com.apple.itunes.storekit.model.Status;\nimport com.apple.itunes.storekit.model.StatusResponse;\nimport com.apple.itunes.storekit.model.SubscriptionGroupIdentifierItem;\nimport com.apple.itunes.storekit.verification.SignedDataVerifier;\nimport com.apple.itunes.storekit.verification.VerificationException;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\n\nclass AppleAppStoreManagerTest {\n\n  private final static long LEVEL = 123L;\n  private final static String ORIGINAL_TX_ID = \"originalTxIdTest\";\n  private final static String SUBSCRIPTION_GROUP_ID = \"subscriptionGroupIdTest\";\n  private final static String SIGNED_RENEWAL_INFO = \"signedRenewalInfoTest\";\n  private final static String SIGNED_TX_INFO = \"signedRenewalInfoTest\";\n  private final static String PRODUCT_ID = \"productIdTest\";\n  private final static String WEB_ORDER_LINE_ITEM = \"webOrderLineItemTest\";\n\n  private final AppStoreServerAPIClient apiClient = mock(AppStoreServerAPIClient.class);\n  private final SignedDataVerifier signedDataVerifier = mock(SignedDataVerifier.class);\n  private AppleAppStoreManager appleAppStoreManager;\n\n  @BeforeEach\n  public void setup() {\n    reset(apiClient, signedDataVerifier);\n    appleAppStoreManager = new AppleAppStoreManager(new AppleAppStoreClient(Environment.PRODUCTION,\n        signedDataVerifier, apiClient,\n        signedDataVerifier, apiClient, null),\n        SUBSCRIPTION_GROUP_ID, Map.of(PRODUCT_ID, LEVEL));\n  }\n\n  @Test\n  public void lookupTransaction() throws APIException, IOException, VerificationException, SubscriptionException, RateLimitExceededException {\n    mockValidSubscription();\n    final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);\n\n    assertThat(info.active()).isTrue();\n    assertThat(info.paymentProcessing()).isFalse();\n    assertThat(info.level()).isEqualTo(LEVEL);\n    assertThat(info.cancelAtPeriodEnd()).isFalse();\n    assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE);\n    assertThat(info.price().amount().compareTo(new BigDecimal(\"150\"))).isEqualTo(0); // 150 cents\n  }\n\n  @Test\n  public void validateTransaction()\n      throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException {\n    mockValidSubscription();\n    assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID)).isEqualTo(LEVEL);\n  }\n\n  @Test\n  public void generateReceipt()\n      throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException {\n    mockValidSubscription();\n    final SubscriptionPaymentProcessor.ReceiptItem receipt = appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID);\n    assertThat(receipt.level()).isEqualTo(LEVEL);\n    assertThat(receipt.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO))\n        .isEqualTo(Instant.EPOCH.plus(Duration.ofDays(2)));\n    assertThat(receipt.itemId()).isEqualTo(WEB_ORDER_LINE_ITEM);\n  }\n\n  @Test\n  public void generateReceiptExpired()\n      throws VerificationException, APIException, IOException {\n    mockSubscription(Status.EXPIRED, AutoRenewStatus.ON);\n    assertThatExceptionOfType(SubscriptionPaymentRequiredException.class)\n        .isThrownBy(() -> appleAppStoreManager.getReceiptItem(ORIGINAL_TX_ID));\n  }\n\n  @Test\n  public void autoRenewOff() throws VerificationException, APIException, IOException, SubscriptionException, RateLimitExceededException {\n    mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF);\n    final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);\n\n    assertThat(info.cancelAtPeriodEnd()).isTrue();\n\n    assertThat(info.active()).isTrue();\n    assertThat(info.paymentProcessing()).isFalse();\n    assertThat(info.level()).isEqualTo(LEVEL);\n    assertThat(info.status()).isEqualTo(SubscriptionStatus.ACTIVE);\n  }\n\n  @Test\n  public void lookupMultipleProducts() throws APIException, IOException, VerificationException, RateLimitExceededException, SubscriptionException {\n    // The lookup should select the transaction at i=1\n    final List<String> products = List.of(\"otherProduct1\", PRODUCT_ID, \"otherProduct3\");\n\n    when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenReturn(new StatusResponse().data(List.of(new SubscriptionGroupIdentifierItem()\n            .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID)\n            .lastTransactions(products.stream().map(product -> new LastTransactionsItem()\n                    .originalTransactionId(ORIGINAL_TX_ID)\n                    .status(Status.ACTIVE)\n                    .signedRenewalInfo(SIGNED_RENEWAL_INFO)\n                    .signedTransactionInfo(product + \"_signed_tx\"))\n                .toList())))\n            .environment(Environment.PRODUCTION));\n    when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO))\n        .thenReturn(new JWSRenewalInfoDecodedPayload()\n            .autoRenewStatus(AutoRenewStatus.ON));\n\n    for (int i = 0; i < products.size(); i++) {\n      // Give each productId a different price, the selected transaction should have priceMillis 1000\n      final long priceMillis = i * 1000L;\n      final String productId = products.get(i);\n      when(signedDataVerifier.verifyAndDecodeTransaction(productId + \"_signed_tx\"))\n          .thenReturn(new JWSTransactionDecodedPayload()\n              .productId(productId)\n              .currency(\"usd\").price(priceMillis)\n              .originalPurchaseDate(Instant.EPOCH.toEpochMilli())\n              .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli()));\n    }\n    final SubscriptionInformation info = appleAppStoreManager.getSubscriptionInformation(ORIGINAL_TX_ID);\n\n    assertThat(info.price().amount().compareTo(new BigDecimal(\"100\"))).isEqualTo(0);\n\n  }\n\n  @Test\n  public void multipleLastTransactionsItems()\n      throws VerificationException, APIException, IOException, SubscriptionPaymentRequiredException, SubscriptionInvalidArgumentsException, SubscriptionNotFoundException, RateLimitExceededException {\n    when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenReturn(new StatusResponse()\n            .data(List.of(new SubscriptionGroupIdentifierItem()\n                .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID)\n                .addLastTransactionsItem(new LastTransactionsItem()\n                    .originalTransactionId(ORIGINAL_TX_ID + \"-different\")\n                    .status(Status.ACTIVE)\n                    .signedRenewalInfo(SIGNED_RENEWAL_INFO)\n                    .signedTransactionInfo(SIGNED_TX_INFO))\n                .addLastTransactionsItem(new LastTransactionsItem()\n                    .originalTransactionId(ORIGINAL_TX_ID)\n                    .status(Status.ACTIVE)\n                    .signedRenewalInfo(SIGNED_RENEWAL_INFO)\n                    .signedTransactionInfo(SIGNED_TX_INFO))))\n            .environment(Environment.PRODUCTION));\n    mockDecode(AutoRenewStatus.ON);\n    assertThat(appleAppStoreManager.validateTransaction(ORIGINAL_TX_ID)).isEqualTo(LEVEL);\n  }\n\n  @Test\n  public void cancelRenewalDisabled() throws APIException, VerificationException, IOException {\n    mockSubscription(Status.ACTIVE, AutoRenewStatus.OFF);\n    assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));\n  }\n\n  @ParameterizedTest\n  @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = {\"EXPIRED\", \"REVOKED\"})\n  public void cancelFailsForActiveSubscription(Status status) throws APIException, VerificationException, IOException {\n    mockSubscription(status, AutoRenewStatus.ON);\n    assertThatExceptionOfType(SubscriptionInvalidArgumentsException.class)\n        .isThrownBy(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));\n  }\n\n  @ParameterizedTest\n  @EnumSource(mode = EnumSource.Mode.INCLUDE, names = {\"EXPIRED\", \"REVOKED\"})\n  public void cancelInactiveStatus(Status status) throws APIException, VerificationException, IOException {\n    mockSubscription(status, AutoRenewStatus.ON);\n    assertDoesNotThrow(() -> appleAppStoreManager.cancelAllActiveSubscriptions(ORIGINAL_TX_ID));\n  }\n\n  private void mockSubscription(final Status status, final AutoRenewStatus autoRenewStatus)\n      throws APIException, IOException, VerificationException {\n    when(apiClient.getAllSubscriptionStatuses(ORIGINAL_TX_ID, new Status[]{}))\n        .thenReturn(new StatusResponse()\n            .data(List.of(new SubscriptionGroupIdentifierItem()\n            .subscriptionGroupIdentifier(SUBSCRIPTION_GROUP_ID)\n            .addLastTransactionsItem(new LastTransactionsItem()\n                .originalTransactionId(ORIGINAL_TX_ID)\n                .status(status)\n                .signedRenewalInfo(SIGNED_RENEWAL_INFO)\n                .signedTransactionInfo(SIGNED_TX_INFO))))\n            .environment(Environment.PRODUCTION));\n    mockDecode(autoRenewStatus);\n  }\n\n  private void mockValidSubscription() throws APIException, IOException, VerificationException {\n    mockSubscription(Status.ACTIVE, AutoRenewStatus.ON);\n  }\n\n  private void mockDecode(final AutoRenewStatus autoRenewStatus) throws VerificationException {\n    when(signedDataVerifier.verifyAndDecodeTransaction(SIGNED_TX_INFO))\n        .thenReturn(new JWSTransactionDecodedPayload()\n            .productId(PRODUCT_ID)\n            .currency(\"usd\").price(1500L) // $1.50\n            .originalPurchaseDate(Instant.EPOCH.toEpochMilli())\n            .expiresDate(Instant.EPOCH.plus(Duration.ofDays(1)).toEpochMilli())\n            .webOrderLineItemId(WEB_ORDER_LINE_ITEM));\n    when(signedDataVerifier.verifyAndDecodeRenewalInfo(SIGNED_RENEWAL_INFO))\n        .thenReturn(new JWSRenewalInfoDecodedPayload()\n            .autoRenewStatus(autoRenewStatus));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeGraphqlClientTest.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.braintree.graphql.clientoperation.CreatePayPalOneTimePaymentMutation;\nimport jakarta.ws.rs.ServiceUnavailableException;\nimport java.math.BigDecimal;\nimport java.net.http.HttpHeaders;\nimport java.net.http.HttpResponse;\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\n\nclass BraintreeGraphqlClientTest {\n\n  private static final String CURRENCY = \"xts\";\n  private static final String RETURN_URL = \"https://example.com/return\";\n  private static final String CANCEL_URL = \"https://example.com/cancel\";\n  private static final String LOCALE = \"xx\";\n  private static final String LOCALIZED_LINE_ITEM_NAME = \"Donation to Signal Technology Foundation\";\n\n  private FaultTolerantHttpClient httpClient;\n  private BraintreeGraphqlClient braintreeGraphqlClient;\n\n\n  @BeforeEach\n  void setUp() {\n    httpClient = mock(FaultTolerantHttpClient.class);\n\n    braintreeGraphqlClient = new BraintreeGraphqlClient(httpClient, \"https://example.com\", \"public\", \"super-secret\");\n  }\n\n  @Test\n  void createPayPalOneTimePayment() {\n\n    final HttpResponse<Object> response = mock(HttpResponse.class);\n    when(httpClient.sendAsync(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(response));\n\n    final String paymentId = \"PAYID-AAA1AAAA1A11111AA111111A\";\n    when(response.body())\n        .thenReturn(createPayPalOneTimePaymentResponse(paymentId));\n    when(response.statusCode())\n        .thenReturn(200);\n\n    final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(\n        BigDecimal.ONE, CURRENCY,\n        RETURN_URL, CANCEL_URL, LOCALE, LOCALIZED_LINE_ITEM_NAME);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {\n      final CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment result = future.get();\n\n      assertEquals(paymentId, result.paymentId);\n      assertNotNull(result.approvalUrl);\n    });\n  }\n\n  @Test\n  void createPayPalOneTimePaymentHttpError() {\n\n    final HttpResponse<Object> response = mock(HttpResponse.class);\n    when(httpClient.sendAsync(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(response));\n\n    when(response.statusCode())\n        .thenReturn(500);\n    final HttpHeaders httpheaders = mock(HttpHeaders.class);\n    when(httpheaders.firstValue(any())).thenReturn(Optional.empty());\n    when(response.headers())\n        .thenReturn(httpheaders);\n\n    final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(\n        BigDecimal.ONE, CURRENCY,\n        RETURN_URL, CANCEL_URL, LOCALE, LOCALIZED_LINE_ITEM_NAME);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {\n\n      final ExecutionException e = assertThrows(ExecutionException.class, future::get);\n\n      assertTrue(e.getCause() instanceof ServiceUnavailableException);\n    });\n  }\n\n  @Test\n  void createPayPalOneTimePaymentGraphQlError() {\n\n    final HttpResponse<Object> response = mock(HttpResponse.class);\n    when(httpClient.sendAsync(any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(response));\n\n    when(response.body())\n        .thenReturn(createErrorResponse(\"createPayPalOneTimePayment\", \"12345\"));\n    when(response.statusCode())\n        .thenReturn(200);\n\n    final CompletableFuture<CreatePayPalOneTimePaymentMutation.CreatePayPalOneTimePayment> future = braintreeGraphqlClient.createPayPalOneTimePayment(\n        BigDecimal.ONE, CURRENCY,\n        RETURN_URL, CANCEL_URL, LOCALE, LOCALIZED_LINE_ITEM_NAME);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(3), () -> {\n\n      final ExecutionException e = assertThrows(ExecutionException.class, future::get);\n      assertTrue(e.getCause() instanceof ServiceUnavailableException);\n    });\n  }\n\n  private String createPayPalOneTimePaymentResponse(final String paymentId) {\n    final String cannedToken = \"EC-1AA11111AA111111A\";\n    return String.format(\"\"\"\n        {\n          \"data\": {\n            \"createPayPalOneTimePayment\": {\n              \"approvalUrl\": \"https://www.sandbox.paypal.com/checkoutnow?nolegacy=1&token=%2$s\",\n              \"paymentId\": \"%1$s\"\n            }\n          },\n          \"extensions\": {\n            \"requestId\": \"%3$s\"\n          }\n        }\n        \"\"\", paymentId, cannedToken, UUID.randomUUID());\n  }\n\n  private String createErrorResponse(final String operationName, final String legacyCode) {\n    return String.format(\"\"\"\n        {\n          \"data\": {\n            \"%1$s\": null\n          },\n          \"errors\": [ {\n            \"message\": \"This is a test error message.\",\n            \"locations\": [ {\n              \"line\": 2,\n              \"column\": 7\n             } ],\n            \"path\": [ \"%1$s\" ],\n            \"extensions\": {\n              \"errorType\": \"user_error\",\n              \"errorClass\": \"VALIDATION\",\n              \"legacyCode\": \"%2$s\",\n              \"inputPath\": [ \"input\", \"testField\" ]\n            }\n          }],\n          \"extensions\": {\n            \"requestId\": \"%3$s\"\n          }\n        }\n        \"\"\", operationName, legacyCode, UUID.randomUUID());\n  }\n\n  @Test\n  void tokenizePayPalOneTimePayment() {\n  }\n\n  @Test\n  void chargeOneTimePayment() {\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/BraintreeManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.braintreegateway.BraintreeGateway;\nimport com.braintreegateway.Customer;\nimport com.braintreegateway.CustomerGateway;\nimport java.time.Duration;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.Executors;\nimport com.google.cloud.pubsub.v1.Publisher;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.currency.CurrencyConversionManager;\n\nclass BraintreeManagerTest {\n\n  private BraintreeGateway braintreeGateway;\n  private BraintreeManager braintreeManager;\n\n  @BeforeEach\n  void setup() {\n    braintreeGateway = mock(BraintreeGateway.class);\n    braintreeManager = new BraintreeManager(braintreeGateway,\n        Map.of(PaymentMethod.CARD, Set.of(\"usd\")),\n        Map.of(\"usd\", \"usdMerchant\"),\n        mock(BraintreeGraphqlClient.class),\n        mock(CurrencyConversionManager.class),\n        mock(Publisher.class),\n        Executors.newSingleThreadExecutor());\n  }\n\n  @Test\n  void cancelAllActiveSubscriptions_nullDefaultPaymentMethod() {\n\n    final Customer customer = mock(Customer.class);\n    when(customer.getDefaultPaymentMethod()).thenReturn(null);\n\n    final CustomerGateway customerGateway = mock(CustomerGateway.class);\n    when(customerGateway.find(anyString())).thenReturn(customer);\n\n    when(braintreeGateway.customer()).thenReturn(customerGateway);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(5), () ->\n        braintreeManager.cancelAllActiveSubscriptions(\"customerId\"));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/GooglePlayBillingManagerTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatException;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.api.client.http.HttpResponseException;\nimport com.google.api.services.androidpublisher.AndroidPublisher;\nimport com.google.api.services.androidpublisher.model.AutoRenewingPlan;\nimport com.google.api.services.androidpublisher.model.Money;\nimport com.google.api.services.androidpublisher.model.OfferDetails;\nimport com.google.api.services.androidpublisher.model.SubscriptionPurchaseLineItem;\nimport com.google.api.services.androidpublisher.model.SubscriptionPurchaseV2;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.EnumSource;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.util.MockUtils;\nimport org.whispersystems.textsecuregcm.util.MutableClock;\n\nclass GooglePlayBillingManagerTest {\n\n  private static final String PRODUCT_ID = \"productId\";\n  private static final String PACKAGE_NAME = \"package.name\";\n  private static final String PURCHASE_TOKEN = \"purchaseToken\";\n  private static final String ORDER_ID = \"orderId\";\n\n  // Returned in response to a purchases.subscriptionsv2.get\n  private final AndroidPublisher.Purchases.Subscriptionsv2.Get subscriptionsv2Get =\n      mock(AndroidPublisher.Purchases.Subscriptionsv2.Get.class);\n\n  // Returned in response to a purchases.subscriptions.acknowledge\n  private final AndroidPublisher.Purchases.Subscriptions.Acknowledge acknowledge =\n      mock(AndroidPublisher.Purchases.Subscriptions.Acknowledge.class);\n\n  // Returned in response to a purchases.subscriptionscancel.\n  private final AndroidPublisher.Purchases.Subscriptions.Cancel cancel =\n      mock(AndroidPublisher.Purchases.Subscriptions.Cancel.class);\n\n  private final MutableClock clock = MockUtils.mutableClock(0L);\n\n  private GooglePlayBillingManager googlePlayBillingManager;\n\n  @BeforeEach\n  public void setup() throws IOException {\n    reset(subscriptionsv2Get);\n    clock.setTimeMillis(0L);\n\n    AndroidPublisher androidPublisher = mock(AndroidPublisher.class);\n    AndroidPublisher.Purchases purchases = mock(AndroidPublisher.Purchases.class);\n    AndroidPublisher.Monetization monetization = mock(AndroidPublisher.Monetization.class);\n\n    when(androidPublisher.purchases()).thenReturn(purchases);\n    when(androidPublisher.monetization()).thenReturn(monetization);\n\n    AndroidPublisher.Purchases.Subscriptionsv2 subscriptionsv2 = mock(AndroidPublisher.Purchases.Subscriptionsv2.class);\n    when(purchases.subscriptionsv2()).thenReturn(subscriptionsv2);\n    when(subscriptionsv2.get(PACKAGE_NAME, PURCHASE_TOKEN)).thenReturn(subscriptionsv2Get);\n\n    AndroidPublisher.Purchases.Subscriptions subscriptions = mock(AndroidPublisher.Purchases.Subscriptions.class);\n    when(purchases.subscriptions()).thenReturn(subscriptions);\n    when(subscriptions.acknowledge(eq(PACKAGE_NAME), eq(PRODUCT_ID), eq(PURCHASE_TOKEN), any()))\n        .thenReturn(acknowledge);\n    when(subscriptions.cancel(PACKAGE_NAME, PRODUCT_ID, PURCHASE_TOKEN))\n        .thenReturn(cancel);\n\n    googlePlayBillingManager = new GooglePlayBillingManager(\n        androidPublisher, clock, PACKAGE_NAME, Map.of(PRODUCT_ID, 201L));\n  }\n\n  @Test\n  public void validatePurchase() throws IOException, RateLimitExceededException, SubscriptionException {\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())\n        .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())\n            .setProductId(PRODUCT_ID))));\n\n    final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager.validateToken(PURCHASE_TOKEN);\n\n    assertThat(result.getLevel()).isEqualTo(201);\n    assertThatNoException().isThrownBy(result::acknowledgePurchase);\n    verify(acknowledge, times(1)).execute();\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  public void rejectInactivePurchase(GooglePlayBillingManager.SubscriptionState subscriptionState) throws IOException {\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())\n        .setSubscriptionState(subscriptionState.apiString())\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())\n            .setProductId(PRODUCT_ID))));\n\n    switch (subscriptionState) {\n      case ACTIVE, IN_GRACE_PERIOD, CANCELED -> assertThatNoException()\n          .isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN));\n      default -> assertThatExceptionOfType(SubscriptionPaymentRequiredException.class)\n          .isThrownBy(() -> googlePlayBillingManager.validateToken(PURCHASE_TOKEN));\n    }\n  }\n\n  @Test\n  public void avoidDoubleAcknowledge() throws IOException, RateLimitExceededException, SubscriptionException {\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())\n        .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())\n            .setProductId(PRODUCT_ID))));\n\n    final GooglePlayBillingManager.ValidatedToken result = googlePlayBillingManager.validateToken(PURCHASE_TOKEN);\n\n    assertThat(result.getLevel()).isEqualTo(201);\n    assertThatNoException().isThrownBy(result::acknowledgePurchase);\n    verifyNoInteractions(acknowledge);\n  }\n\n  @ParameterizedTest\n  @EnumSource\n  public void cancel(GooglePlayBillingManager.SubscriptionState subscriptionState) throws IOException {\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())\n        .setSubscriptionState(subscriptionState.apiString())\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())\n            .setProductId(PRODUCT_ID))));\n    assertThatNoException().isThrownBy(() ->\n        googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN));\n    final int wanted = switch (subscriptionState) {\n      case CANCELED, EXPIRED -> 0;\n      default -> 1;\n    };\n    verify(cancel, times(wanted)).execute();\n  }\n\n  @Test\n  public void cancelMissingSubscription() throws IOException {\n    final HttpResponseException mockException = mock(HttpResponseException.class);\n    when(mockException.getStatusCode()).thenReturn(404);\n    when(subscriptionsv2Get.execute()).thenThrow(mockException);\n    assertThatNoException().isThrownBy(() ->\n        googlePlayBillingManager.cancelAllActiveSubscriptions(PURCHASE_TOKEN));\n    verifyNoInteractions(cancel);\n  }\n\n  @Test\n  public void handle429() throws IOException {\n    final HttpResponseException mockException = mock(HttpResponseException.class);\n    when(mockException.getStatusCode()).thenReturn(429);\n    when(subscriptionsv2Get.execute()).thenThrow(mockException);\n    assertThatExceptionOfType(RateLimitExceededException.class).isThrownBy(() ->\n        googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN));\n  }\n\n  @Test\n  public void getReceiptUnacknowledged() throws IOException {\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.PENDING.apiString())\n        .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())\n            .setProductId(PRODUCT_ID))));\n    assertThatExceptionOfType(IllegalStateException.class).isThrownBy(() ->\n        googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));\n  }\n\n  @Test\n  public void getReceiptExpiring()\n      throws IOException, RateLimitExceededException, SubscriptionException {\n    final Instant day9 = Instant.EPOCH.plus(Duration.ofDays(9));\n    final Instant day10 = Instant.EPOCH.plus(Duration.ofDays(10));\n\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())\n        .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.CANCELED.apiString())\n        .setLatestOrderId(ORDER_ID)\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(day10.toString().toString())\n            .setProductId(PRODUCT_ID))));\n\n    clock.setTimeInstant(day9);\n    SubscriptionPaymentProcessor.ReceiptItem item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN);\n    assertThat(item.itemId()).isEqualTo(ORDER_ID);\n    assertThat(item.level()).isEqualTo(201L);\n\n    // receipt expirations rounded to nearest next day\n    assertThat(item.paymentTime().receiptExpiration(Duration.ofDays(1), Duration.ZERO))\n        .isEqualTo(day10.plus(Duration.ofDays(1)));\n\n    // should still be able to get a receipt the next day\n    clock.setTimeInstant(day10);\n    item = googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN);\n    assertThat(item.itemId()).isEqualTo(ORDER_ID);\n\n    // next second should be expired\n    clock.setTimeInstant(day10.plus(Duration.ofSeconds(1)));\n\n    assertThatExceptionOfType(SubscriptionPaymentRequiredException.class)\n        .isThrownBy(() -> googlePlayBillingManager.getReceiptItem(PURCHASE_TOKEN));\n  }\n\n  @Test\n  public void getSubscriptionInfo() throws IOException, RateLimitExceededException, SubscriptionException {\n    final String basePlanId = \"basePlanId\";\n    when(subscriptionsv2Get.execute()).thenReturn(new SubscriptionPurchaseV2()\n        .setAcknowledgementState(GooglePlayBillingManager.AcknowledgementState.ACKNOWLEDGED.apiString())\n        .setSubscriptionState(GooglePlayBillingManager.SubscriptionState.ACTIVE.apiString())\n        .setLatestOrderId(ORDER_ID)\n        .setRegionCode(\"US\")\n        .setLineItems(List.of(new SubscriptionPurchaseLineItem()\n            .setExpiryTime(Instant.now().plus(Duration.ofDays(1)).toString())\n            .setAutoRenewingPlan(new AutoRenewingPlan()\n                .setAutoRenewEnabled(null)\n                .setRecurringPrice(new Money().setCurrencyCode(\"USD\").setUnits(1L).setNanos(750_000_000)))\n            .setProductId(PRODUCT_ID)\n            .setOfferDetails(new OfferDetails().setBasePlanId(basePlanId)))));\n\n    final SubscriptionInformation info = googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN);\n    assertThat(info.active()).isTrue();\n    assertThat(info.paymentProcessing()).isFalse();\n    assertThat(info.price().currency()).isEqualTo(\"USD\");\n    assertThat(info.price().amount().compareTo(new BigDecimal(\"175\"))).isEqualTo(0); // 175 cents\n    assertThat(info.level()).isEqualTo(201L);\n    assertThat(info.cancelAtPeriodEnd()).isTrue();\n\n  }\n\n  public static Stream<Arguments> tokenErrors() {\n    return Stream.of(\n        Arguments.of(404, SubscriptionNotFoundException.class),\n        Arguments.of(410, SubscriptionNotFoundException.class),\n        Arguments.of(400, IOException.class)\n    );\n  }\n  @ParameterizedTest\n  @MethodSource\n  public void tokenErrors(final int httpStatus, Class<? extends Exception> expected) throws IOException {\n    final HttpResponseException mockException = mock(HttpResponseException.class);\n    when(mockException.getStatusCode()).thenReturn(httpStatus);\n    when(subscriptionsv2Get.execute()).thenThrow(mockException);\n    assertThatException()\n        .isThrownBy(() -> googlePlayBillingManager.getSubscriptionInformation(PURCHASE_TOKEN))\n        // Verify the exception or its leaf cause is an instanceof expected. withRootCauseInstanceOf almost does what we\n        // want, but fails if the outermost exception does not have a cause\n        .matches(e -> {\n          Throwable cause = e;\n          while (cause.getCause() != null) {\n            cause = cause.getCause();\n          }\n          return expected.isInstance(cause);\n        });\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/PayPalDonationsTranslatorTest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.MissingResourceException;\nimport org.junit.jupiter.api.Test;\nimport org.signal.i18n.HeaderControlledResourceBundleLookup;\n\nclass PayPalDonationsTranslatorTest {\n\n  private final PayPalDonationsTranslator translator = new PayPalDonationsTranslator(\n      new HeaderControlledResourceBundleLookup());\n\n  @Test\n  void testTranslate() {\n    assertEquals(\"Donation to Signal Technology Foundation\",\n        translator.translate(List.of(Locale.ROOT), PayPalDonationsTranslator.ONE_TIME_DONATION_LINE_ITEM_KEY));\n  }\n\n  @Test\n  void testTranslateUnknownKey() {\n    assertThrows(MissingResourceException.class, () -> translator.translate(List.of(Locale.ROOT), \"unknown-key\"));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/ProcessorCustomerTest.java",
    "content": "package org.whispersystems.textsecuregcm.subscriptions;\n\nimport org.junit.jupiter.api.Test;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ProcessorCustomerTest {\n\n  @Test\n  void toDynamoBytes() {\n    final ProcessorCustomer processorCustomer = new ProcessorCustomer(\"Test\", PaymentProvider.BRAINTREE);\n\n    assertArrayEquals(new byte[] { PaymentProvider.BRAINTREE.getId(), 'T', 'e', 's', 't' },\n        processorCustomer.toDynamoBytes());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/StripeManagerTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.assertArg;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.stripe.StripeClient;\nimport com.stripe.exception.ApiException;\nimport com.stripe.exception.StripeException;\nimport com.stripe.model.Price;\nimport com.stripe.model.StripeCollection;\nimport com.stripe.model.Subscription;\nimport com.stripe.model.SubscriptionItem;\nimport com.stripe.param.SubscriptionUpdateParams;\nimport com.stripe.service.SubscriptionItemService;\nimport com.stripe.service.SubscriptionService;\nimport com.stripe.service.V1Services;\nimport java.nio.charset.StandardCharsets;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Consumer;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nclass StripeManagerTest {\n\n  private StripeClient stripeClient;\n  private SubscriptionService subscriptionService;\n  private SubscriptionItemService subscriptionItemsService;\n  private StripeManager stripeManager;\n  private ExecutorService executor;\n\n  @BeforeEach\n  void setup() {\n    this.executor = Executors.newSingleThreadExecutor();\n    this.stripeClient = mock(StripeClient.class);\n    this.stripeManager = new StripeManager(\n        this.stripeClient,\n        executor,\n        \"idempotencyKey\".getBytes(StandardCharsets.UTF_8),\n        \"boost\",\n        Map.of(PaymentMethod.CARD, Set.of(\"usd\")));\n\n    final V1Services v1Services = mock(V1Services.class);\n    when(stripeClient.v1()).thenReturn(v1Services);\n\n    subscriptionService = mock(SubscriptionService.class);\n    when(v1Services.subscriptions()).thenReturn(subscriptionService);\n    subscriptionItemsService = mock(SubscriptionItemService.class);\n    when(v1Services.subscriptionItems()).thenReturn(subscriptionItemsService);\n  }\n\n  @AfterEach\n  void teardown() throws InterruptedException {\n    this.executor.shutdownNow();\n    this.executor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void paymentRequiresAction() throws StripeException {\n    final ApiException stripeException = new ApiException(\"Payment intent requires action\",\n        UUID.randomUUID().toString(), \"subscription_payment_intent_requires_action\", 400, new Exception());\n\n    when(subscriptionService.create(any(), any())).thenThrow(stripeException);\n    assertThatExceptionOfType(SubscriptionPaymentRequiresActionException.class).isThrownBy(() ->\n        stripeManager.createSubscription(\"customerId\", \"priceId\", 1, 0));\n  }\n\n  @ParameterizedTest\n  @CsvSource(\n      {\n          \"usd, unpaid, true\",\n          \"usd, past_due, true\",\n          \"usd, incomplete, true\",\n          \"usd, active, false\",\n          \"zzz, active, true\",\n      }\n  )\n  void testEndSubscription(final String currency, final String status, final boolean expectCancelImmediately) throws Exception {\n    final Subscription subscription = mock(Subscription.class);\n    when(subscription.getId()).thenReturn(\"test-subscription\");\n    when(subscription.getStatus()).thenReturn(status);\n\n    final SubscriptionItem item = mock(SubscriptionItem.class);\n    final Price price = new Price();\n    price.setCurrency(currency);\n    when(item.getPrice()).thenReturn(price);\n\n    @SuppressWarnings(\"unchecked\") final StripeCollection<SubscriptionItem> items = mock(StripeCollection.class);\n    when(items.autoPagingIterable()).thenReturn(List.of(item));\n    when(subscriptionItemsService.list(any(), any()))\n        .thenReturn(items);\n\n    stripeManager.endSubscription(subscription);\n\n    verify(subscriptionService, expectCancelImmediately ? times(1) : never())\n        .cancel(any(), any(), any());\n    verify(subscriptionService, expectCancelImmediately ? never() : times(1))\n        .update(any(), assertArg(\n            (Consumer<SubscriptionUpdateParams>) params -> assertTrue(params.getCancelAtPeriodEnd())),\n            any());\n\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/subscriptions/SubscriptionCurrencyUtilTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.subscriptions;\n\nimport com.google.api.services.androidpublisher.model.Money;\nimport org.assertj.core.api.Assertions;\nimport org.assertj.core.data.Percentage;\nimport org.junit.jupiter.api.Test;\n\nimport java.math.BigDecimal;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass SubscriptionCurrencyUtilTest {\n\n  @Test\n  void convertGoogleMoneyToApiAmount() {\n    Money money = new Money();\n    money.setCurrencyCode(\"USD\");\n    money.setUnits(4L);\n\n    BigDecimal amt = SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(money);\n    Assertions.assertThat(amt).isCloseTo(BigDecimal.valueOf(400), Percentage.withPercentage(0.0001));\n\n    money.setNanos(990000000);\n    amt = SubscriptionCurrencyUtil.convertGoogleMoneyToApiAmount(money);\n    Assertions.assertThat(amt).isCloseTo(BigDecimal.valueOf(499), Percentage.withPercentage(0.0001));\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/telephony/hlrlookup/HlrLookupCarrierDataProviderTest.java",
    "content": "/*\n * Copyright 2026 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.telephony.hlrlookup;\n\nimport static com.github.tomakehurst.wiremock.client.WireMock.aResponse;\nimport static com.github.tomakehurst.wiremock.client.WireMock.post;\nimport static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;\nimport static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.github.tomakehurst.wiremock.junit5.WireMockExtension;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Optional;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.http.FaultTolerantHttpClient;\nimport org.whispersystems.textsecuregcm.telephony.CarrierData;\nimport org.whispersystems.textsecuregcm.telephony.CarrierDataException;\n\nclass HlrLookupCarrierDataProviderTest {\n\n  private HlrLookupCarrierDataProvider hlrLookupCarrierDataProvider;\n\n  @RegisterExtension\n  private static final WireMockExtension WIRE_MOCK_EXTENSION = WireMockExtension.newInstance()\n      .options(wireMockConfig().dynamicPort().dynamicHttpsPort())\n      .build();\n\n  private static final String HLR_LOOKUP_PATH = \"/hlr\";\n\n  @BeforeEach\n  void setUp() {\n    final FaultTolerantHttpClient faultTolerantHttpClient = FaultTolerantHttpClient.newBuilder(\"hlrLookupTest\", Runnable::run)\n        .build();\n\n    hlrLookupCarrierDataProvider = new HlrLookupCarrierDataProvider(\"test\", \"test\", faultTolerantHttpClient, URI.create(\"http://localhost:\" + WIRE_MOCK_EXTENSION.getPort() + HLR_LOOKUP_PATH));\n  }\n\n  @Test\n  void lookupCarrierData() throws IOException, CarrierDataException {\n    final String responseJson = \"\"\"\n        {\n            \"results\": [\n                {\n                    \"error\": \"NONE\",\n                    \"uuid\": \"f066f711-4043-4d54-847d-c273e6491881\",\n                    \"request_parameters\": {\n                        \"telephone_number\": \"+44(7790) 60 60 23\",\n                        \"save_to_cache\": \"YES\",\n                        \"input_format\": \"\",\n                        \"output_format\": \"\",\n                        \"cache_days_global\": 0,\n                        \"cache_days_private\": 0,\n                        \"get_ported_date\": \"NO\",\n                        \"get_landline_status\": \"NO\",\n                        \"usa_status\": \"NO\"\n                    },\n                    \"credits_spent\": 1,\n                    \"detected_telephone_number\": \"447790606023\",\n                    \"formatted_telephone_number\": \"\",\n                    \"live_status\": \"LIVE\",\n                    \"original_network\": \"AVAILABLE\",\n                    \"original_network_details\": {\n                        \"name\": \"EE Limited (Orange)\",\n                        \"mccmnc\": \"23433\",\n                        \"country_name\": \"United Kingdom\",\n                        \"country_iso3\": \"GBR\",\n                        \"area\": \"United Kingdom\",\n                        \"country_prefix\": \"44\"\n                    },\n                    \"current_network\": \"AVAILABLE\",\n                    \"current_network_details\": {\n                        \"name\": \"Virgin Mobile\",\n                        \"mccmnc\": \"23438\",\n                        \"country_name\": \"United Kingdom\",\n                        \"country_iso3\": \"GBR\",\n                        \"country_prefix\": \"44\"\n                    },\n                    \"is_ported\": \"YES\",\n                    \"disposable_number\": \"NO\",\n                    \"timestamp\": \"2022-09-08T10:56:03Z\",\n                    \"telephone_number_type\": \"MOBILE\",\n                    \"sms_email\": \"\",\n                    \"mms_email\": \"\"\n                }\n            ]\n        }\n        \"\"\";\n\n    WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"application/json\")\n            .withBody(responseJson)));\n\n    final Optional<CarrierData> maybeCarrierData =\n        hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), Duration.ZERO);\n\n    assertEquals(Optional.of(new CarrierData(\"Virgin Mobile\", CarrierData.LineType.MOBILE, Optional.of(\"234\"), Optional.of(\"38\"), Optional.of(true), Optional.of(false))),\n        maybeCarrierData);\n  }\n\n  @Test\n  void lookupCarrierDataNonSuccessStatus() {\n    WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))\n        .willReturn(aResponse()\n            .withStatus(500)));\n\n    assertThrows(CarrierDataException.class, () ->\n        hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), Duration.ZERO));\n  }\n\n  @Test\n  void lookupCarrierDataErrorMessage() {\n    final String responseJson = \"\"\"\n        { \"error\": \"UNAUTHORIZED\", \"message\": \"Invalid api_key or api_secret\" }\n        \"\"\";\n\n    WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"application/json\")\n            .withBody(responseJson)));\n\n    assertThrows(CarrierDataException.class, () ->\n        hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), Duration.ZERO));\n  }\n\n  @Test\n  void lookupCarrierDataEmptyBody() {\n    WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"application/json\")\n            .withBody(\"{}\")));\n\n    assertThrows(CarrierDataException.class, () ->\n        hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), Duration.ZERO));\n  }\n\n  @Test\n  void lookupCarrierDataPerNumberError() {\n    final String responseJson = \"\"\"\n        {\n            \"body\": {\n                \"results\": [\n                    {\n                        \"error\": \"INTERNAL_ERROR\"\n                    }\n                ]\n            }\n        }\n        \"\"\";\n\n    WIRE_MOCK_EXTENSION.stubFor(post(urlEqualTo(HLR_LOOKUP_PATH))\n        .willReturn(aResponse()\n            .withHeader(\"Content-Type\", \"application/json\")\n            .withBody(responseJson)));\n\n    assertThrows(CarrierDataException.class, () ->\n        hlrLookupCarrierDataProvider.lookupCarrierData(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), Duration.ZERO));\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void mccFromMccMnc(@Nullable final String mccMnc, final Optional<String> expectedMcc) {\n    assertEquals(expectedMcc, HlrLookupCarrierDataProvider.mccFromMccMnc(mccMnc));\n  }\n\n  private static List<Arguments> mccFromMccMnc() {\n    return List.of(\n        Arguments.argumentSet(\"Null mccMnc string\", null, Optional.empty()),\n        Arguments.argumentSet(\"Empty mccMnc string\", \"\", Optional.empty()),\n        Arguments.argumentSet(\"Blank mccMnc string\", \" \", Optional.empty()),\n        Arguments.argumentSet(\"Two-digit MNC\", \"12345\", Optional.of(\"123\")),\n        Arguments.argumentSet(\"Three-digit MNC\", \"123456\", Optional.of(\"123\")),\n        Arguments.argumentSet(\"MCC-only\", \"123\", Optional.of(\"123\"))\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void mncFromMccMnc(@Nullable final String mccMnc, final Optional<String> expectedMnc) {\n    assertEquals(expectedMnc, HlrLookupCarrierDataProvider.mncFromMccMnc(mccMnc));\n  }\n\n  private static List<Arguments> mncFromMccMnc() {\n    return List.of(\n        Arguments.argumentSet(\"Null mccMnc string\", null, Optional.empty()),\n        Arguments.argumentSet(\"Empty mccMnc string\", \"\", Optional.empty()),\n        Arguments.argumentSet(\"Blank mccMnc string\", \" \", Optional.empty()),\n        Arguments.argumentSet(\"Two-digit MNC\", \"12345\", Optional.of(\"45\")),\n        Arguments.argumentSet(\"Three-digit MNC\", \"123456\", Optional.of(\"456\")),\n        Arguments.argumentSet(\"MCC-only\", \"123\", Optional.empty())\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void lineType(@Nullable final String lineType, final CarrierData.LineType expectedLineType) {\n    assertEquals(expectedLineType, HlrLookupCarrierDataProvider.lineType(lineType));\n  }\n\n  private static List<Arguments> lineType() {\n    return List.of(\n        Arguments.argumentSet(\"Null line type\", null, CarrierData.LineType.UNKNOWN)\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void isPorted(final String isPortedString, final Optional<Boolean> expectedIsPortedValue) {\n    final HlrLookupResult hlrLookupResult =\n        new HlrLookupResult(\"NONE\", 1.0f, \"NOT_AVAILABLE\", null, \"NOT_AVAILABLE\", null, \"MOBILE\", isPortedString, null);\n\n    assertEquals(expectedIsPortedValue, HlrLookupCarrierDataProvider.isPorted(hlrLookupResult.isPorted()));\n  }\n\n  private static List<Arguments> isPorted() {\n    return List.of(\n        Arguments.argumentSet(\"Null isPorted string\", null, Optional.empty()),\n        Arguments.argumentSet(\"Is ported\", \"YES\", Optional.of(true)),\n        Arguments.argumentSet(\"Is not ported\", \"NO\", Optional.of(false)),\n        Arguments.argumentSet(\"Unrecognized isPorted string\", \"UNKNOWN\", Optional.empty())\n    );\n  }\n\n  @SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\n  @ParameterizedTest\n  @MethodSource\n  void getNetworkDetails(final HlrLookupResult hlrLookupResult, final Optional<NetworkDetails> expectedNetworkDetails) {\n    assertEquals(expectedNetworkDetails, HlrLookupCarrierDataProvider.getNetworkDetails(hlrLookupResult));\n  }\n\n  private static List<Arguments> getNetworkDetails() {\n    final NetworkDetails originalNetwork = new NetworkDetails(\n        \"Original network\",\n        \"123456\",\n        \"United States of America\",\n        \"USA\",\n        \"United States of America\",\n        \"1\");\n\n    final NetworkDetails currentNetwork = new NetworkDetails(\n        \"Current network\",\n        \"654321\",\n        \"United States of America\",\n        \"USA\",\n        \"United States of America\",\n        \"1\");\n\n    return List.of(\n        Arguments.argumentSet(\"Original and current network\",\n            resultWithNetworkDetails(originalNetwork, currentNetwork),\n            Optional.of(currentNetwork)),\n\n        Arguments.argumentSet(\"Original network only\",\n            resultWithNetworkDetails(originalNetwork, null),\n            Optional.of(originalNetwork)),\n\n        Arguments.argumentSet(\"Current network only\",\n            resultWithNetworkDetails(null, currentNetwork),\n            Optional.of(currentNetwork)),\n\n        Arguments.argumentSet(\"No network details\",\n            resultWithNetworkDetails(null, null),\n            Optional.empty())\n    );\n  }\n\n  private static HlrLookupResult resultWithNetworkDetails(@Nullable final NetworkDetails originalNetwork,\n      @Nullable final NetworkDetails currentNetwork) {\n\n    return new HlrLookupResult(null,\n        1.0f,\n        originalNetwork == null ? \"NOT_AVAILABLE\" : \"AVAILABLE\",\n        originalNetwork,\n        currentNetwork == null ? \"NOT_AVAILABLE\" : \"AVAILABLE\",\n        currentNetwork,\n        \"MOBILE\",\n        \"NO\",\n        \"UNKNOWN\");\n  }\n\n  @Test\n  void parseResponse() throws JsonProcessingException {\n    final String json = \"\"\"\n        {\n            \"results\": [\n                {\n                    \"error\": \"NONE\",\n                    \"uuid\": \"f066f711-4043-4d54-847d-c273e6491881\",\n                    \"request_parameters\": {\n                        \"telephone_number\": \"+44(7790) 60 60 23\",\n                        \"save_to_cache\": \"YES\",\n                        \"input_format\": \"\",\n                        \"output_format\": \"\",\n                        \"cache_days_global\": 0,\n                        \"cache_days_private\": 0,\n                        \"get_ported_date\": \"NO\",\n                        \"get_landline_status\": \"NO\",\n                        \"usa_status\": \"NO\"\n                    },\n                    \"credits_spent\": 1,\n                    \"detected_telephone_number\": \"447790606023\",\n                    \"formatted_telephone_number\": \"\",\n                    \"live_status\": \"LIVE\",\n                    \"original_network\": \"AVAILABLE\",\n                    \"original_network_details\": {\n                        \"name\": \"EE Limited (Orange)\",\n                        \"mccmnc\": \"23433\",\n                        \"country_name\": \"United Kingdom\",\n                        \"country_iso3\": \"GBR\",\n                        \"area\": \"United Kingdom\",\n                        \"country_prefix\": \"44\"\n                    },\n                    \"current_network\": \"AVAILABLE\",\n                    \"current_network_details\": {\n                        \"name\": \"Virgin Mobile\",\n                        \"mccmnc\": \"23438\",\n                        \"country_name\": \"United Kingdom\",\n                        \"country_iso3\": \"GBR\",\n                        \"country_prefix\": \"44\"\n                    },\n                    \"is_ported\": \"YES\",\n                    \"timestamp\": \"2022-09-08T10:56:03Z\",\n                    \"telephone_number_type\": \"MOBILE\",\n                    \"sms_email\": \"\",\n                    \"mms_email\": \"\"\n                }\n            ]\n        }\n        \"\"\";\n\n    final HlrLookupResponse response = HlrLookupCarrierDataProvider.parseResponse(json);\n    assertNull(response.error());\n    assertNull(response.message());\n    assertNotNull(response.results());\n    assertEquals(1, response.results().size());\n\n    final HlrLookupResult result = response.results().getFirst();\n    assertEquals(\"NONE\", result.error());\n    assertEquals(\"MOBILE\", result.telephoneNumberType());\n\n    assertEquals(\"AVAILABLE\", result.originalNetwork());\n    assertEquals(\"EE Limited (Orange)\", result.originalNetworkDetails().name());\n    assertEquals(\"23433\", result.originalNetworkDetails().mccmnc());\n    assertEquals(\"United Kingdom\", result.originalNetworkDetails().countryName());\n    assertEquals(\"GBR\", result.originalNetworkDetails().countryIso3());\n    assertEquals(\"United Kingdom\", result.originalNetworkDetails().area());\n    assertEquals(\"44\", result.originalNetworkDetails().countryPrefix());\n\n    assertEquals(\"AVAILABLE\", result.currentNetwork());\n    assertEquals(\"Virgin Mobile\", result.currentNetworkDetails().name());\n    assertEquals(\"23438\", result.currentNetworkDetails().mccmnc());\n    assertEquals(\"United Kingdom\", result.currentNetworkDetails().countryName());\n    assertEquals(\"GBR\", result.currentNetworkDetails().countryIso3());\n    assertNull(result.currentNetworkDetails().area());\n    assertEquals(\"44\", result.currentNetworkDetails().countryPrefix());\n  }\n\n  @Test\n  void parseResponseError() throws JsonProcessingException {\n    final String json = \"\"\"\n        { \"error\": \"UNAUTHORIZED\", \"message\": \"Invalid api_key or api_secret\" }\n        \"\"\";\n\n    final HlrLookupResponse response = HlrLookupCarrierDataProvider.parseResponse(json);\n    assertEquals(\"UNAUTHORIZED\", response.error());\n    assertEquals(\"Invalid api_key or api_secret\", response.message());\n    assertNull(response.results());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AccountsHelper.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.mockingDetails;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.io.IOException;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.function.Consumer;\nimport org.mockito.MockingDetails;\nimport org.mockito.stubbing.Stubbing;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.entities.AccountAttributes;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DeviceSpec;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class AccountsHelper {\n\n  public static Account generateTestAccount(String number, List<Device> devices) {\n    return generateTestAccount(number, UUID.randomUUID(), UUID.randomUUID(), devices, null);\n  }\n\n  public static Account generateTestAccount(String number, UUID uuid, final UUID phoneNumberIdentifier, List<Device> devices, byte[] unidentifiedAccessKey) {\n    final Account account = new Account();\n    account.setNumber(number, phoneNumberIdentifier);\n    account.setUuid(uuid);\n    devices.forEach(account::addDevice);\n    account.setUnidentifiedAccessKey(unidentifiedAccessKey);\n\n    return account;\n  }\n\n  public static void setupMockUpdate(final AccountsManager mockAccountsManager) {\n    setupMockUpdate(mockAccountsManager, true);\n  }\n\n  /**\n   * Only for use by {@link AuthHelper}\n   */\n  public static void setupMockUpdateForAuthHelper(final AccountsManager mockAccountsManager) {\n    setupMockUpdate(mockAccountsManager, false);\n  }\n\n  /**\n   * Sets up stubbing for:\n   * <ul>\n   *    <li>{@link AccountsManager#update(Account, Consumer)}</li>\n   *    <li>{@link AccountsManager#updateDevice(Account, byte, Consumer)}</li>\n   * </ul>\n   *\n   * with multiple calls to the {@link Consumer<Account>}. This simulates retries from {@link org.whispersystems.textsecuregcm.storage.ContestedOptimisticLockException}.\n   * Callers will typically set up stubbing for relevant {@link Account} methods with multiple {@link org.mockito.stubbing.OngoingStubbing#thenReturn(Object)}\n   * calls:\n   * <pre>\n   *   // example stubbing\n   *   when(account.getNextDeviceId())\n   *     .thenReturn(2)\n   *     .thenReturn(3);\n   * </pre>\n   */\n  @SuppressWarnings(\"unchecked\")\n  public static void setupMockUpdateWithRetries(final AccountsManager mockAccountsManager, final int retryCount) {\n    when(mockAccountsManager.update(any(), any())).thenAnswer(answer -> {\n      final Account account = answer.getArgument(0, Account.class);\n\n      for (int i = 0; i < retryCount; i++) {\n        answer.getArgument(1, Consumer.class).accept(account);\n      }\n\n      return copyAndMarkStale(account);\n    });\n\n    when(mockAccountsManager.updateDevice(any(), anyByte(), any())).thenAnswer(answer -> {\n      final Account account = answer.getArgument(0, Account.class);\n      final byte deviceId = answer.getArgument(1, Byte.class);\n\n      for (int i = 0; i < retryCount; i++) {\n        account.getDevice(deviceId).ifPresent(answer.getArgument(2, Consumer.class));\n      }\n\n      return copyAndMarkStale(account);\n    });\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  private static void setupMockUpdate(final AccountsManager mockAccountsManager, final boolean markStale) {\n    when(mockAccountsManager.update(any(), any())).thenAnswer(answer -> {\n      final Account account = answer.getArgument(0, Account.class);\n      answer.getArgument(1, Consumer.class).accept(account);\n\n      return markStale ? copyAndMarkStale(account) : account;\n    });\n\n    when(mockAccountsManager.updateDevice(any(), anyByte(), any())).thenAnswer(answer -> {\n      final Account account = answer.getArgument(0, Account.class);\n      final byte deviceId = answer.getArgument(1, Byte.class);\n      account.getDevice(deviceId).ifPresent(answer.getArgument(2, Consumer.class));\n\n      return markStale ? copyAndMarkStale(account) : account;\n    });\n\n    when(mockAccountsManager.updateDeviceLastSeen(any(), any(), anyLong())).thenAnswer(answer -> {\n      answer.getArgument(1, Device.class).setLastSeen(answer.getArgument(2, Long.class));\n      return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {});\n    });\n\n    when(mockAccountsManager.updateDeviceAuthentication(any(), any(), any())).thenAnswer(answer -> {\n      answer.getArgument(1, Device.class).setAuthTokenHash(answer.getArgument(2, SaltedTokenHash.class));\n      return mockAccountsManager.update(answer.getArgument(0, Account.class), account -> {});\n    });\n  }\n\n  public static void setupMockGet(final AccountsManager mockAccountsManager, final Set<Account> mockAccounts) {\n    when(mockAccountsManager.getByAccountIdentifier(any(UUID.class))).thenAnswer(answer -> {\n\n      final UUID uuid = answer.getArgument(0, UUID.class);\n\n      return mockAccounts.stream()\n          .filter(account -> uuid.equals(account.getUuid()))\n          .findFirst()\n          .map(account -> {\n            try {\n              return copyAndMarkStale(account);\n            } catch (final Exception e) {\n              throw new RuntimeException(e);\n            }\n          });\n    });\n  }\n\n  private static Account copyAndMarkStale(Account account) throws IOException {\n    MockingDetails mockingDetails = mockingDetails(account);\n\n    final Account updatedAccount;\n    if (mockingDetails.isMock()) {\n\n      updatedAccount = mock(Account.class);\n\n      // it’s not possible to make `account` behave as if it were stale, because we use static mocks in AuthHelper\n\n      for (Stubbing stubbing : mockingDetails.getStubbings()) {\n        switch (stubbing.getInvocation().getMethod().getName()) {\n          case \"getUuid\" -> when(updatedAccount.getUuid()).thenAnswer(stubbing);\n          case \"getPhoneNumberIdentifier\" -> when(updatedAccount.getPhoneNumberIdentifier()).thenAnswer(stubbing);\n          case \"getIdentifier\" -> when(updatedAccount.getIdentifier(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);\n          case \"isIdentifiedBy\" -> when(updatedAccount.isIdentifiedBy(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);\n          case \"getNumber\" -> when(updatedAccount.getNumber()).thenAnswer(stubbing);\n          case \"getUsername\" -> when(updatedAccount.getUsernameHash()).thenAnswer(stubbing);\n          case \"getUsernameHash\" -> when(updatedAccount.getUsernameHash()).thenAnswer(stubbing);\n          case \"getUsernameLinkHandle\" -> when(updatedAccount.getUsernameLinkHandle()).thenAnswer(stubbing);\n          case \"getDevices\" -> when(updatedAccount.getDevices()).thenAnswer(stubbing);\n          case \"getDevice\" -> when(updatedAccount.getDevice(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);\n          case \"getPrimaryDevice\" -> when(updatedAccount.getPrimaryDevice()).thenAnswer(stubbing);\n          case \"isDiscoverableByPhoneNumber\" -> when(updatedAccount.isDiscoverableByPhoneNumber()).thenAnswer(stubbing);\n          case \"getNextDeviceId\" -> when(updatedAccount.getNextDeviceId()).thenAnswer(stubbing);\n          case \"hasCapability\" -> when(updatedAccount.hasCapability(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);\n          case \"getRegistrationLock\" -> when(updatedAccount.getRegistrationLock()).thenAnswer(stubbing);\n          case \"getIdentityKey\" ->\n              when(updatedAccount.getIdentityKey(stubbing.getInvocation().getArgument(0))).thenAnswer(stubbing);\n          case \"getBadges\" -> when(updatedAccount.getBadges()).thenAnswer(stubbing);\n          case \"getBackupVoucher\" -> when(updatedAccount.getBackupVoucher()).thenAnswer(stubbing);\n          case \"getLastSeen\" -> when(updatedAccount.getLastSeen()).thenAnswer(stubbing);\n          case \"hasLockedCredentials\" -> when(updatedAccount.hasLockedCredentials()).thenAnswer(stubbing);\n          case \"getCurrentProfileVersion\" -> when(updatedAccount.getCurrentProfileVersion()).thenAnswer(stubbing);\n          case \"getUnidentifiedAccessKey\" -> when(updatedAccount.getUnidentifiedAccessKey()).thenAnswer(stubbing);\n          default -> throw new IllegalArgumentException(\"unsupported method: Account#\" + stubbing.getInvocation().getMethod().getName());\n        }\n      }\n\n    } else {\n      final ObjectMapper mapper = SystemMapper.jsonMapper();\n      updatedAccount = mapper.readValue(mapper.writeValueAsBytes(account), Account.class);\n      updatedAccount.setNumber(account.getNumber(), account.getPhoneNumberIdentifier());\n      account.markStale();\n    }\n\n    return updatedAccount;\n  }\n\n  public static Account eqUuid(Account value) {\n    return argThat(other -> other.getUuid().equals(value.getUuid()));\n  }\n\n  public static Account createAccount(final AccountsManager accountsManager, final String e164)\n      throws InterruptedException {\n\n    return createAccount(accountsManager, e164, new AccountAttributes());\n  }\n\n  public static Account createAccount(final AccountsManager accountsManager, final String e164, final AccountAttributes accountAttributes)\n      throws InterruptedException {\n\n    return createAccount(accountsManager, e164, accountAttributes, ECKeyPair.generate(), ECKeyPair.generate());\n  }\n\n  public static Account createAccount(final AccountsManager accountsManager,\n      final String e164,\n      final AccountAttributes accountAttributes,\n      final ECKeyPair aciKeyPair,\n      final ECKeyPair pniKeyPair) throws InterruptedException {\n\n    return accountsManager.create(e164,\n        accountAttributes,\n        new ArrayList<>(),\n        new IdentityKey(aciKeyPair.getPublicKey()),\n        new IdentityKey(pniKeyPair.getPublicKey()),\n        new DeviceSpec(\n            accountAttributes.getName(),\n            \"password\",\n            \"OWT\",\n            accountAttributes.getCapabilities(),\n            accountAttributes.getRegistrationId(),\n            accountAttributes.getPhoneNumberIdentityRegistrationId(),\n            accountAttributes.getFetchesMessages(),\n            Optional.empty(),\n            Optional.empty(),\n            KeysHelper.signedECPreKey(1, aciKeyPair),\n            KeysHelper.signedECPreKey(2, pniKeyPair),\n            KeysHelper.signedKEMPreKey(3, aciKeyPair),\n            KeysHelper.signedKEMPreKey(4, pniKeyPair)),\n        null);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/AuthHelper.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.collect.ImmutableMap;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport io.dropwizard.auth.AuthFilter;\nimport io.dropwizard.auth.PolymorphicAuthDynamicFeature;\nimport io.dropwizard.auth.basic.BasicCredentialAuthFilter;\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport java.security.Principal;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.Collection;\nimport java.util.HashSet;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.Random;\nimport java.util.UUID;\nimport java.util.stream.Stream;\n\nimport org.junit.jupiter.api.extension.AfterEachCallback;\nimport org.junit.jupiter.api.extension.ExtensionContext;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ServiceId;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.zkgroup.ServerPublicParams;\nimport org.signal.libsignal.zkgroup.ServerSecretParams;\nimport org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;\nimport org.signal.libsignal.zkgroup.groups.GroupMasterKey;\nimport org.signal.libsignal.zkgroup.groups.GroupSecretParams;\nimport org.signal.libsignal.zkgroup.groups.UuidCiphertext;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendDerivedKeyPair;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;\nimport org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse.ReceivedEndorsements;\nimport org.whispersystems.textsecuregcm.auth.AccountAuthenticator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.SaltedTokenHash;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.identity.PniServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\n\npublic class AuthHelper {\n  // Static seed to ensure reproducible tests.\n  private static final Random random = new Random(0xf744df3b43a3339cL);\n\n  public static final TestAccount[] TEST_ACCOUNTS = generateTestAccounts();\n\n  public static final String VALID_NUMBER   = \"+14150000000\";\n  public static final UUID   VALID_UUID     = UUID.randomUUID();\n  public static final UUID   VALID_PNI      = UUID.randomUUID();\n  public static final String VALID_PASSWORD = \"foo\";\n\n  public static final String VALID_NUMBER_TWO = \"+201511111110\";\n  public static final UUID   VALID_UUID_TWO    = UUID.randomUUID();\n  public static final UUID   VALID_PNI_TWO     = UUID.randomUUID();\n  public static final String VALID_PASSWORD_TWO = \"baz\";\n\n  public static final String VALID_NUMBER_3           = \"+14445556666\";\n  public static final UUID   VALID_UUID_3             = UUID.randomUUID();\n  public static final UUID   VALID_PNI_3              = UUID.randomUUID();\n  public static final String VALID_PASSWORD_3_PRIMARY = \"3primary\";\n  public static final String VALID_PASSWORD_3_LINKED  = \"3linked\";\n\n  public static final UUID   INVALID_UUID     = UUID.randomUUID();\n  public static final String INVALID_PASSWORD = \"bar\";\n\n  public static final String UNDISCOVERABLE_NUMBER   = \"+18005551234\";\n  public static final UUID   UNDISCOVERABLE_UUID     = UUID.randomUUID();\n  public static final UUID   UNDISCOVERABLE_PNI      = UUID.randomUUID();\n  public static final String UNDISCOVERABLE_PASSWORD = \"IT'S A SECRET TO EVERYBODY.\";\n\n  public static final ECKeyPair VALID_IDENTITY_KEY_PAIR = ECKeyPair.generate();\n  public static final IdentityKey VALID_IDENTITY = new IdentityKey(VALID_IDENTITY_KEY_PAIR.getPublicKey());\n\n  public static final ECKeyPair VALID_PNI_IDENTITY_KEY_PAIR = ECKeyPair.generate();\n  public static final IdentityKey VALID_PNI_IDENTITY = new IdentityKey(VALID_PNI_IDENTITY_KEY_PAIR.getPublicKey());\n\n  public static AccountsManager ACCOUNTS_MANAGER       = mock(AccountsManager.class);\n  public static Account         VALID_ACCOUNT          = mock(Account.class        );\n  public static Account         VALID_ACCOUNT_TWO      = mock(Account.class        );\n  public static Account         UNDISCOVERABLE_ACCOUNT = mock(Account.class        );\n  public static Account         VALID_ACCOUNT_3        = mock(Account.class        );\n\n  public static Device VALID_DEVICE           = mock(Device.class);\n  public static Device VALID_DEVICE_TWO       = mock(Device.class);\n  public static Device UNDISCOVERABLE_DEVICE  = mock(Device.class);\n  public static Device VALID_DEVICE_3_PRIMARY = mock(Device.class);\n  public static Device VALID_DEVICE_3_LINKED  = mock(Device.class);\n\n  public static final byte VALID_DEVICE_3_LINKED_ID = Device.PRIMARY_ID + 1;\n\n  private static SaltedTokenHash VALID_CREDENTIALS           = mock(SaltedTokenHash.class);\n  private static SaltedTokenHash VALID_CREDENTIALS_TWO       = mock(SaltedTokenHash.class);\n  private static SaltedTokenHash VALID_CREDENTIALS_3_PRIMARY = mock(SaltedTokenHash.class);\n  private static SaltedTokenHash VALID_CREDENTIALS_3_LINKED  = mock(SaltedTokenHash.class);\n  private static SaltedTokenHash UNDISCOVERABLE_CREDENTIALS  = mock(SaltedTokenHash.class);\n\n  private static final Collection<TestAccount> EXTENSION_TEST_ACCOUNTS = new HashSet<>();\n\n  public static PolymorphicAuthDynamicFeature<? extends Principal> getAuthFilter() {\n    when(VALID_CREDENTIALS.verify(\"foo\")).thenReturn(true);\n    when(VALID_CREDENTIALS_TWO.verify(\"baz\")).thenReturn(true);\n    when(VALID_CREDENTIALS_3_PRIMARY.verify(VALID_PASSWORD_3_PRIMARY)).thenReturn(true);\n    when(VALID_CREDENTIALS_3_LINKED.verify(VALID_PASSWORD_3_LINKED)).thenReturn(true);\n    when(UNDISCOVERABLE_CREDENTIALS.verify(UNDISCOVERABLE_PASSWORD)).thenReturn(true);\n\n    when(VALID_DEVICE.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS);\n    when(VALID_DEVICE_TWO.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS_TWO);\n    when(VALID_DEVICE_3_PRIMARY.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS_3_PRIMARY);\n    when(VALID_DEVICE_3_LINKED.getAuthTokenHash()).thenReturn(VALID_CREDENTIALS_3_LINKED);\n    when(UNDISCOVERABLE_DEVICE.getAuthTokenHash()).thenReturn(UNDISCOVERABLE_CREDENTIALS);\n\n    when(VALID_DEVICE.isPrimary()).thenReturn(true);\n    when(VALID_DEVICE_TWO.isPrimary()).thenReturn(true);\n    when(UNDISCOVERABLE_DEVICE.isPrimary()).thenReturn(true);\n    when(VALID_DEVICE_3_PRIMARY.isPrimary()).thenReturn(true);\n    when(VALID_DEVICE_3_LINKED.isPrimary()).thenReturn(false);\n\n    when(VALID_DEVICE.getId()).thenReturn(Device.PRIMARY_ID);\n    when(VALID_DEVICE_TWO.getId()).thenReturn(Device.PRIMARY_ID);\n    when(UNDISCOVERABLE_DEVICE.getId()).thenReturn(Device.PRIMARY_ID);\n    when(VALID_DEVICE_3_PRIMARY.getId()).thenReturn(Device.PRIMARY_ID);\n    when(VALID_DEVICE_3_LINKED.getId()).thenReturn(VALID_DEVICE_3_LINKED_ID);\n\n    when(UNDISCOVERABLE_DEVICE.isPrimary()).thenReturn(true);\n\n    when(VALID_ACCOUNT.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(VALID_DEVICE));\n    when(VALID_ACCOUNT.getPrimaryDevice()).thenReturn(VALID_DEVICE);\n    when(VALID_ACCOUNT_TWO.getDevice(eq(Device.PRIMARY_ID))).thenReturn(Optional.of(VALID_DEVICE_TWO));\n    when(VALID_ACCOUNT_TWO.getPrimaryDevice()).thenReturn(VALID_DEVICE_TWO);\n    when(UNDISCOVERABLE_ACCOUNT.getDevice(eq(Device.PRIMARY_ID))).thenReturn(Optional.of(UNDISCOVERABLE_DEVICE));\n    when(UNDISCOVERABLE_ACCOUNT.getPrimaryDevice()).thenReturn(UNDISCOVERABLE_DEVICE);\n    when(VALID_ACCOUNT_3.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(VALID_DEVICE_3_PRIMARY));\n    when(VALID_ACCOUNT_3.getPrimaryDevice()).thenReturn(VALID_DEVICE_3_PRIMARY);\n    when(VALID_ACCOUNT_3.getDevice((byte) 2)).thenReturn(Optional.of(VALID_DEVICE_3_LINKED));\n\n    when(VALID_ACCOUNT.getDevices()).thenReturn(List.of(VALID_DEVICE));\n    when(VALID_ACCOUNT_TWO.getDevices()).thenReturn(List.of(VALID_DEVICE_TWO));\n    when(UNDISCOVERABLE_ACCOUNT.getDevices()).thenReturn(List.of(UNDISCOVERABLE_DEVICE));\n    when(VALID_ACCOUNT_3.getDevices()).thenReturn(List.of(VALID_DEVICE_3_PRIMARY, VALID_DEVICE_3_LINKED));\n\n    when(VALID_ACCOUNT.getNumber()).thenReturn(VALID_NUMBER);\n    when(VALID_ACCOUNT.getUuid()).thenReturn(VALID_UUID);\n    when(VALID_ACCOUNT.getPhoneNumberIdentifier()).thenReturn(VALID_PNI);\n    when(VALID_ACCOUNT.getIdentifier(IdentityType.ACI)).thenReturn(VALID_UUID);\n    when(VALID_ACCOUNT.getIdentifier(IdentityType.PNI)).thenReturn(VALID_PNI);\n    when(VALID_ACCOUNT_TWO.getNumber()).thenReturn(VALID_NUMBER_TWO);\n    when(VALID_ACCOUNT_TWO.getUuid()).thenReturn(VALID_UUID_TWO);\n    when(VALID_ACCOUNT_TWO.getPhoneNumberIdentifier()).thenReturn(VALID_PNI_TWO);\n    when(VALID_ACCOUNT_TWO.getIdentifier(IdentityType.ACI)).thenReturn(VALID_UUID_TWO);\n    when(VALID_ACCOUNT_TWO.getPhoneNumberIdentifier()).thenReturn(VALID_PNI_TWO);\n    when(UNDISCOVERABLE_ACCOUNT.getNumber()).thenReturn(UNDISCOVERABLE_NUMBER);\n    when(UNDISCOVERABLE_ACCOUNT.getUuid()).thenReturn(UNDISCOVERABLE_UUID);\n    when(UNDISCOVERABLE_ACCOUNT.getPhoneNumberIdentifier()).thenReturn(UNDISCOVERABLE_PNI);\n    when(UNDISCOVERABLE_ACCOUNT.getIdentifier(IdentityType.ACI)).thenReturn(UNDISCOVERABLE_UUID);\n    when(UNDISCOVERABLE_ACCOUNT.getIdentifier(IdentityType.PNI)).thenReturn(UNDISCOVERABLE_PNI);\n    when(VALID_ACCOUNT_3.getNumber()).thenReturn(VALID_NUMBER_3);\n    when(VALID_ACCOUNT_3.getUuid()).thenReturn(VALID_UUID_3);\n    when(VALID_ACCOUNT_3.getPhoneNumberIdentifier()).thenReturn(VALID_PNI_3);\n    when(VALID_ACCOUNT_3.getIdentifier(IdentityType.ACI)).thenReturn(VALID_UUID_3);\n    when(VALID_ACCOUNT_3.getIdentifier(IdentityType.PNI)).thenReturn(VALID_PNI_3);\n\n    when(VALID_ACCOUNT.isDiscoverableByPhoneNumber()).thenReturn(true);\n    when(VALID_ACCOUNT_TWO.isDiscoverableByPhoneNumber()).thenReturn(true);\n    when(UNDISCOVERABLE_ACCOUNT.isDiscoverableByPhoneNumber()).thenReturn(false);\n    when(VALID_ACCOUNT_3.isDiscoverableByPhoneNumber()).thenReturn(true);\n\n    when(VALID_ACCOUNT.isIdentifiedBy(new AciServiceIdentifier(VALID_UUID))).thenReturn(true);\n    when(VALID_ACCOUNT.isIdentifiedBy(new PniServiceIdentifier(VALID_PNI))).thenReturn(true);\n    when(VALID_ACCOUNT_TWO.isIdentifiedBy(new AciServiceIdentifier(VALID_UUID_TWO))).thenReturn(true);\n    when(VALID_ACCOUNT_TWO.isIdentifiedBy(new PniServiceIdentifier(VALID_PNI_TWO))).thenReturn(true);\n    when(UNDISCOVERABLE_ACCOUNT.isIdentifiedBy(new AciServiceIdentifier(UNDISCOVERABLE_UUID))).thenReturn(true);\n    when(VALID_ACCOUNT_3.isIdentifiedBy(new AciServiceIdentifier(VALID_UUID_3))).thenReturn(true);\n    when(VALID_ACCOUNT_3.isIdentifiedBy(new PniServiceIdentifier(VALID_PNI_3))).thenReturn(true);\n\n    when(VALID_ACCOUNT.getIdentityKey(IdentityType.ACI)).thenReturn(VALID_IDENTITY);\n    when(VALID_ACCOUNT.getIdentityKey(IdentityType.PNI)).thenReturn(VALID_PNI_IDENTITY);\n\n    reset(ACCOUNTS_MANAGER);\n\n    when(ACCOUNTS_MANAGER.getByE164(VALID_NUMBER)).thenReturn(Optional.of(VALID_ACCOUNT));\n    when(ACCOUNTS_MANAGER.getByAccountIdentifier(VALID_UUID)).thenReturn(Optional.of(VALID_ACCOUNT));\n    when(ACCOUNTS_MANAGER.getByPhoneNumberIdentifier(VALID_PNI)).thenReturn(Optional.of(VALID_ACCOUNT));\n\n    when(ACCOUNTS_MANAGER.getByE164(VALID_NUMBER_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO));\n    when(ACCOUNTS_MANAGER.getByAccountIdentifier(VALID_UUID_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO));\n    when(ACCOUNTS_MANAGER.getByPhoneNumberIdentifier(VALID_PNI_TWO)).thenReturn(Optional.of(VALID_ACCOUNT_TWO));\n\n    when(ACCOUNTS_MANAGER.getByE164(UNDISCOVERABLE_NUMBER)).thenReturn(Optional.of(UNDISCOVERABLE_ACCOUNT));\n    when(ACCOUNTS_MANAGER.getByAccountIdentifier(UNDISCOVERABLE_UUID)).thenReturn(Optional.of(UNDISCOVERABLE_ACCOUNT));\n\n    when(ACCOUNTS_MANAGER.getByE164(VALID_NUMBER_3)).thenReturn(Optional.of(VALID_ACCOUNT_3));\n    when(ACCOUNTS_MANAGER.getByAccountIdentifier(VALID_UUID_3)).thenReturn(Optional.of(VALID_ACCOUNT_3));\n    when(ACCOUNTS_MANAGER.getByPhoneNumberIdentifier(VALID_PNI_3)).thenReturn(Optional.of(VALID_ACCOUNT_3));\n\n    AccountsHelper.setupMockUpdateForAuthHelper(ACCOUNTS_MANAGER);\n\n    for (TestAccount testAccount : TEST_ACCOUNTS) {\n      testAccount.setup(ACCOUNTS_MANAGER);\n    }\n\n    AuthFilter<BasicCredentials, AuthenticatedDevice> accountAuthFilter = new BasicCredentialAuthFilter.Builder<AuthenticatedDevice>().setAuthenticator(\n        new AccountAuthenticator(ACCOUNTS_MANAGER)).buildAuthFilter();\n\n    return new PolymorphicAuthDynamicFeature<>(ImmutableMap.of(AuthenticatedDevice.class, accountAuthFilter));\n  }\n\n  public static String getAuthHeader(UUID uuid, byte deviceId, String password) {\n    return HeaderUtils.basicAuthHeader(uuid.toString() + \".\" + deviceId, password);\n  }\n\n  public static String getAuthHeader(UUID uuid, String password) {\n    return HeaderUtils.basicAuthHeader(uuid.toString(), password);\n  }\n\n  public static String getProvisioningAuthHeader(String number, String password) {\n    return HeaderUtils.basicAuthHeader(number, password);\n  }\n\n  public static String getUnidentifiedAccessHeader(byte[] key) {\n    return Base64.getEncoder().encodeToString(key);\n  }\n\n  public static UUID getRandomUUID(Random random) {\n    long mostSignificantBits  = random.nextLong();\n    long leastSignificantBits = random.nextLong();\n    mostSignificantBits  &= 0xffffffffffff0fffL;\n    mostSignificantBits  |= 0x0000000000004000L;\n    leastSignificantBits &= 0x3fffffffffffffffL;\n    leastSignificantBits |= 0x8000000000000000L;\n    return new UUID(mostSignificantBits, leastSignificantBits);\n  }\n\n  public static final class TestAccount {\n    public final String                    number;\n    public final UUID                      uuid;\n    public final String                    password;\n    public final Account                   account                   = mock(Account.class);\n    public final Device                    device                    = mock(Device.class);\n    public final SaltedTokenHash saltedTokenHash = mock(SaltedTokenHash.class);\n\n    public TestAccount(String number, UUID uuid, String password) {\n      this.number = number;\n      this.uuid = uuid;\n      this.password = password;\n    }\n\n    public String getAuthHeader() {\n      return AuthHelper.getAuthHeader(uuid, password);\n    }\n\n    private void setup(final AccountsManager accountsManager) {\n      when(saltedTokenHash.verify(password)).thenReturn(true);\n      when(device.getAuthTokenHash()).thenReturn(saltedTokenHash);\n      when(device.isPrimary()).thenReturn(true);\n      when(device.getId()).thenReturn(Device.PRIMARY_ID);\n      when(account.getDevice(Device.PRIMARY_ID)).thenReturn(Optional.of(device));\n      when(account.getPrimaryDevice()).thenReturn(device);\n      when(account.getNumber()).thenReturn(number);\n      when(account.getUuid()).thenReturn(uuid);\n      when(account.getIdentifier(IdentityType.ACI)).thenReturn(uuid);\n      when(accountsManager.getByE164(number)).thenReturn(Optional.of(account));\n      when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.of(account));\n    }\n\n    private void teardown(final AccountsManager accountsManager) {\n      when(accountsManager.getByAccountIdentifier(uuid)).thenReturn(Optional.empty());\n      when(accountsManager.getByE164(number)).thenReturn(Optional.empty());\n    }\n  }\n\n  private static TestAccount[] generateTestAccounts() {\n    final TestAccount[] testAccounts = new TestAccount[20];\n    final long numberBase = 1_409_000_0000L;\n    for (int i = 0; i < testAccounts.length; i++) {\n      long currentNumber = numberBase + i;\n      testAccounts[i] = new TestAccount(\"+\" + currentNumber, getRandomUUID(random), \"TestAccountPassword-\" + currentNumber);\n    }\n    return testAccounts;\n  }\n\n  /**\n   * JUnit 5 extension for creating {@link TestAccount}s scoped to a single test\n   */\n  public static class AuthFilterExtension implements AfterEachCallback {\n\n    public TestAccount createTestAccount() {\n      final UUID uuid = UUID.randomUUID();\n      final String region = new ArrayList<>((PhoneNumberUtil.getInstance().getSupportedRegions())).get(\n          EXTENSION_TEST_ACCOUNTS.size());\n      final Phonenumber.PhoneNumber phoneNumber = PhoneNumberUtil.getInstance().getExampleNumber(region);\n\n      final TestAccount testAccount = new TestAccount(\n          PhoneNumberUtil.getInstance().format(phoneNumber, PhoneNumberUtil.PhoneNumberFormat.E164), uuid,\n          \"extension-password-\" + region);\n      testAccount.setup(ACCOUNTS_MANAGER);\n\n      EXTENSION_TEST_ACCOUNTS.add(testAccount);\n\n      return testAccount;\n    }\n\n    @Override\n    public void afterEach(final ExtensionContext context) {\n      EXTENSION_TEST_ACCOUNTS.forEach(testAccount -> testAccount.teardown(ACCOUNTS_MANAGER));\n\n      EXTENSION_TEST_ACCOUNTS.clear();\n    }\n  }\n\n  public static byte[] validGroupSendToken(ServerSecretParams serverSecretParams, List<ServiceIdentifier> recipients, Instant expiration) throws Exception {\n    final ServerPublicParams serverPublicParams = serverSecretParams.getPublicParams();\n    final GroupMasterKey groupMasterKey = new GroupMasterKey(new byte[32]);\n    final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);\n    final ClientZkGroupCipher clientZkGroupCipher = new ClientZkGroupCipher(groupSecretParams);\n\n    final ServiceId.Aci sender = new ServiceId.Aci(UUID.randomUUID());\n    List<ServiceId> groupPlaintexts = Stream.concat(Stream.of(sender), recipients.stream().map(ServiceIdentifier::toLibsignal)).toList();\n    List<UuidCiphertext> groupCiphertexts = groupPlaintexts.stream()\n        .map(clientZkGroupCipher::encrypt)\n        .toList();\n    GroupSendDerivedKeyPair keyPair = GroupSendDerivedKeyPair.forExpiration(expiration, serverSecretParams);\n    GroupSendEndorsementsResponse endorsementsResponse =\n        GroupSendEndorsementsResponse.issue(groupCiphertexts, keyPair);\n    ReceivedEndorsements endorsements =\n        endorsementsResponse.receive(\n            groupPlaintexts,\n            sender,\n            expiration.minus(Duration.ofDays(1)),\n            groupSecretParams,\n            serverPublicParams);\n    GroupSendFullToken token = endorsements.combinedEndorsement().toFullToken(groupSecretParams, expiration);\n    return token.serialize();\n  }\n\n  public static String validGroupSendTokenHeader(ServerSecretParams serverSecretParams, List<ServiceIdentifier> recipients, Instant expiration) throws Exception {\n    return Base64.getEncoder().encodeToString(validGroupSendToken(serverSecretParams, recipients, expiration));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/DevicesHelper.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport java.util.Random;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\npublic class DevicesHelper {\n\n  private static final Random RANDOM = new Random();\n\n  public static Device createDevice(final byte deviceId) {\n    return createDevice(deviceId, 0);\n  }\n\n  public static Device createDevice(final byte deviceId, final long lastSeen) {\n    return createDevice(deviceId, lastSeen, 0);\n  }\n\n  public static Device createDevice(final byte deviceId, final long lastSeen, final int registrationId) {\n    final Device device = new Device();\n    device.setId(deviceId);\n    device.setLastSeen(lastSeen);\n    device.setUserAgent(\"OWT\");\n    device.setRegistrationId(registrationId);\n\n    return device;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ExperimentHelper.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicExperimentEnrollmentConfiguration;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.UUID;\n\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\npublic class ExperimentHelper {\n\n  private static DynamicConfigurationManager<DynamicConfiguration> withEnrollment(\n      final String experimentName,\n      final Set<UUID> enrolledUuids,\n      final int enrollmentPercentage) {\n    final DynamicConfigurationManager<DynamicConfiguration> dcm = mock(DynamicConfigurationManager.class);\n    final DynamicConfiguration dc = mock(DynamicConfiguration.class);\n    when(dcm.getConfiguration()).thenReturn(dc);\n    final DynamicExperimentEnrollmentConfiguration exp = mock(DynamicExperimentEnrollmentConfiguration.class);\n    when(dc.getExperimentEnrollmentConfiguration(experimentName)).thenReturn(Optional.of(exp));\n    final DynamicExperimentEnrollmentConfiguration.UuidSelector uuidSelector =\n        mock(DynamicExperimentEnrollmentConfiguration.UuidSelector.class);\n    when(exp.getUuidSelector()).thenReturn(uuidSelector);\n\n    when(exp.getEnrollmentPercentage()).thenReturn(enrollmentPercentage);\n    when(uuidSelector.getUuids()).thenReturn(enrolledUuids);\n    when(uuidSelector.getUuidEnrollmentPercentage()).thenReturn(100);\n    return dcm;\n  }\n\n  public static ExperimentEnrollmentManager withEnrollment(final String experimentName, final Set<UUID> enrolledUuids) {\n    return new ExperimentEnrollmentManager(withEnrollment(experimentName, enrolledUuids, 0));\n  }\n\n  public static ExperimentEnrollmentManager withEnrollment(final String experimentName, final UUID enrolledUuid) {\n    return new ExperimentEnrollmentManager(withEnrollment(experimentName, Set.of(enrolledUuid), 0));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/FakeDynamicConfigurationManager.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\n\npublic class FakeDynamicConfigurationManager<T> extends DynamicConfigurationManager<T> {\n\n  T staticConfiguration;\n\n  public FakeDynamicConfigurationManager(T staticConfiguration) {\n    super(null, (Class<T>) staticConfiguration.getClass());\n    this.staticConfiguration = staticConfiguration;\n  }\n\n  @Override\n  public T getConfiguration() {\n    return staticConfiguration;\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/JsonHelpers.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.io.IOException;\nimport io.dropwizard.util.Resources;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\n\npublic class JsonHelpers {\n\n  private static final ObjectMapper objectMapper = SystemMapper.jsonMapper();\n\n  public static String asJson(Object object) throws JsonProcessingException {\n    return objectMapper.writeValueAsString(object);\n  }\n\n  public static <T> T fromJson(String value, Class<T> clazz) throws IOException {\n    return objectMapper.readValue(value, clazz);\n  }\n\n  public static String jsonFixture(String filename) throws IOException {\n    return objectMapper.writeValueAsString(\n        objectMapper.readValue(Resources.getResource(filename), JsonNode.class));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/KeysHelper.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\nimport org.signal.libsignal.protocol.kem.KEMKeyPair;\nimport org.signal.libsignal.protocol.kem.KEMKeyType;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\nimport org.whispersystems.textsecuregcm.entities.ECPreKey;\nimport org.whispersystems.textsecuregcm.entities.ECSignedPreKey;\nimport org.whispersystems.textsecuregcm.entities.KEMSignedPreKey;\n\npublic final class KeysHelper {\n\n  public static ECPreKey ecPreKey(final long id) {\n    return new ECPreKey(id, ECKeyPair.generate().getPublicKey());\n  }\n\n  public static ECSignedPreKey signedECPreKey(long id, final ECKeyPair identityKeyPair) {\n    final ECPublicKey pubKey = ECKeyPair.generate().getPublicKey();\n    final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());\n    return new ECSignedPreKey(id, pubKey, sig);\n  }\n\n  public static KEMSignedPreKey signedKEMPreKey(long id, final ECKeyPair identityKeyPair) {\n    final KEMPublicKey pubKey = KEMKeyPair.generate(KEMKeyType.KYBER_1024).getPublicKey();\n    final byte[] sig = identityKeyPair.getPrivateKey().calculateSignature(pubKey.serialize());\n    return new KEMSignedPreKey(id, pubKey, sig);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MessageHelper.java",
    "content": "/*\n * Copyright 2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport com.google.protobuf.ByteString;\nimport java.nio.charset.StandardCharsets;\nimport java.util.UUID;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\n\npublic class MessageHelper {\n\n  public static MessageProtos.Envelope createMessage(UUID senderUuid, final byte senderDeviceId, UUID destinationUuid,\n      long timestamp, String content) {\n    return MessageProtos.Envelope.newBuilder()\n        .setServerGuid(UUID.randomUUID().toString())\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(0)\n        .setSourceServiceId(senderUuid.toString())\n        .setSourceDevice(senderDeviceId)\n        .setDestinationServiceId(destinationUuid.toString())\n        .setContent(ByteString.copyFrom(content.getBytes(StandardCharsets.UTF_8)))\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MockRedisFuture.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport io.lettuce.core.RedisFuture;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeUnit;\n\npublic class MockRedisFuture<T> extends CompletableFuture<T> implements RedisFuture<T> {\n\n  public static <T> MockRedisFuture<T> completedFuture(final T value) {\n    final MockRedisFuture<T> future = new MockRedisFuture<T>();\n    future.complete(value);\n    return future;\n  }\n\n  public static <U> MockRedisFuture<U> failedFuture(final Throwable cause) {\n    final MockRedisFuture<U> future = new MockRedisFuture<U>();\n    future.completeExceptionally(cause);\n    return future;\n  }\n\n  @Override\n  public String getError() {\n    return null;\n  }\n\n  @Override\n  public boolean await(final long l, final TimeUnit timeUnit) throws InterruptedException {\n    return false;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/MultiRecipientMessageHelper.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport java.nio.ByteBuffer;\nimport java.util.List;\n\npublic class MultiRecipientMessageHelper {\n\n  private MultiRecipientMessageHelper() {\n  }\n\n  public static byte[] generateMultiRecipientMessage(final List<TestRecipient> recipients) {\n    return generateMultiRecipientMessage(recipients, 32);\n  }\n\n  public static byte[] generateMultiRecipientMessage(final List<TestRecipient> recipients, final int sharedPayloadSize) {\n    if (sharedPayloadSize < 32) {\n      throw new IllegalArgumentException(\"Shared payload size must be at least 32 bytes\");\n    }\n\n    final ByteBuffer buffer = ByteBuffer.allocate(payloadSize(recipients, sharedPayloadSize));\n\n    // first write the header\n    buffer.put((byte) 0x23);  // version byte\n\n    // count varint\n    writeVarint(buffer, recipients.size());\n\n    recipients.forEach(recipient -> {\n      buffer.put(recipient.uuid().toFixedWidthByteArray());\n\n      assert recipient.deviceIds().length == recipient.registrationIds().length;\n\n      for (int i = 0; i < recipient.deviceIds().length; i++) {\n        final int hasMore = i == recipient.deviceIds().length - 1 ? 0 : 0x8000;\n        buffer.put(recipient.deviceIds()[i]); // device id (1 byte)\n        buffer.putShort((short) (recipient.registrationIds()[i] | hasMore)); // registration id (2 bytes)\n      }\n\n      buffer.put(recipient.perRecipientKeyMaterial()); // key material (48 bytes)\n    });\n\n    // now write the actual message body (empty for now)\n    writeVarint(buffer, sharedPayloadSize);\n    buffer.put(new byte[sharedPayloadSize]);\n\n    return buffer.array();\n  }\n\n  private static void writeVarint(final ByteBuffer buffer, long n) {\n    if (n < 0) {\n      throw new IllegalArgumentException();\n    }\n\n    while (n >= 0x80) {\n      buffer.put ((byte) (n & 0x7F | 0x80));\n      n >>= 7;\n    }\n    buffer.put((byte) (n & 0x7F));\n  }\n\n  private static int payloadSize(final List<TestRecipient> recipients, final int sharedPayloadSize) {\n    final int fixedBytesPerRecipient = 17 // Service identifier length\n        + 48; // Per-recipient key material\n\n    final int bytesForDevices = 3 * recipients.stream()\n        .mapToInt(recipient -> recipient.deviceIds().length)\n        .sum();\n\n    return 1 // Version byte\n        + varintLength(recipients.size())\n        + (recipients.size() * fixedBytesPerRecipient)\n        + bytesForDevices\n        + varintLength(sharedPayloadSize)\n        + sharedPayloadSize;\n  }\n\n  private static int varintLength(long n) {\n    int length = 0;\n\n    while (n >= 0x80) {\n      length += 1;\n      n >>= 7;\n    }\n\n    return length + 1;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/ProfileTestHelper.java",
    "content": "package org.whispersystems.textsecuregcm.tests.util;\n\nimport java.util.Base64;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\n\npublic class ProfileTestHelper {\n  public static String generateRandomBase64FromByteArray(final int byteArrayLength) {\n    return encodeToBase64(TestRandomUtil.nextBytes(byteArrayLength));\n  }\n\n  public static String encodeToBase64(final byte[] input) {\n    return Base64.getEncoder().encodeToString(input);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/RedisClusterHelper.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.cluster.api.StatefulRedisClusterConnection;\nimport io.lettuce.core.cluster.api.async.RedisAdvancedClusterAsyncCommands;\nimport io.lettuce.core.cluster.api.reactive.RedisAdvancedClusterReactiveCommands;\nimport io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands;\nimport io.lettuce.core.cluster.pubsub.StatefulRedisClusterPubSubConnection;\nimport io.lettuce.core.cluster.pubsub.api.async.RedisClusterPubSubAsyncCommands;\nimport io.lettuce.core.cluster.pubsub.api.sync.RedisClusterPubSubCommands;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantPubSubClusterConnection;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClusterClient;\n\npublic class RedisClusterHelper {\n\n  public static RedisClusterHelper.Builder builder() {\n    return new Builder();\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  private static FaultTolerantRedisClusterClient buildMockRedisCluster(\n      final RedisAdvancedClusterCommands<String, String> stringCommands,\n      final RedisAdvancedClusterAsyncCommands<String, String> stringAsyncCommands,\n      final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands,\n      final RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands,\n      final RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands,\n      final RedisClusterPubSubCommands<String, String> stringPubSubCommands,\n      final RedisClusterPubSubAsyncCommands<String, String> stringAsyncPubSubCommands,\n      final RedisClusterPubSubCommands<byte[], byte[]> binaryPubSubCommands,\n      final RedisClusterPubSubAsyncCommands<byte[], byte[]> binaryAsyncPubSubCommands) {\n\n    final FaultTolerantRedisClusterClient cluster = mock(FaultTolerantRedisClusterClient.class);\n    final StatefulRedisClusterConnection<String, String> stringConnection = mock(StatefulRedisClusterConnection.class);\n    final StatefulRedisClusterConnection<byte[], byte[]> binaryConnection = mock(StatefulRedisClusterConnection.class);\n\n    when(stringConnection.sync()).thenReturn(stringCommands);\n    when(stringConnection.async()).thenReturn(stringAsyncCommands);\n    when(binaryConnection.sync()).thenReturn(binaryCommands);\n    when(binaryConnection.async()).thenReturn(binaryAsyncCommands);\n    when(binaryConnection.reactive()).thenReturn(binaryReactiveCommands);\n\n    when(cluster.withCluster(any(Function.class))).thenAnswer(invocation -> {\n      return invocation.getArgument(0, Function.class).apply(stringConnection);\n    });\n\n    doAnswer(invocation -> {\n      invocation.getArgument(0, Consumer.class).accept(stringConnection);\n      return null;\n    }).when(cluster).useCluster(any(Consumer.class));\n\n    when(cluster.withBinaryCluster(any(Function.class))).thenAnswer(invocation -> {\n      return invocation.getArgument(0, Function.class).apply(binaryConnection);\n    });\n\n    doAnswer(invocation -> {\n      invocation.getArgument(0, Consumer.class).accept(binaryConnection);\n      return null;\n    }).when(cluster).useBinaryCluster(any(Consumer.class));\n\n    final StatefulRedisClusterPubSubConnection<String, String> stringPubSubConnection =\n        mock(StatefulRedisClusterPubSubConnection.class);\n\n    final StatefulRedisClusterPubSubConnection<byte[], byte[]> binaryPubSubConnection =\n        mock(StatefulRedisClusterPubSubConnection.class);\n\n    final FaultTolerantPubSubClusterConnection<String, String> faultTolerantPubSubClusterConnection =\n        mock(FaultTolerantPubSubClusterConnection.class);\n\n    final FaultTolerantPubSubClusterConnection<byte[], byte[]> faultTolerantBinaryPubSubClusterConnection =\n        mock(FaultTolerantPubSubClusterConnection.class);\n\n    when(stringPubSubConnection.sync()).thenReturn(stringPubSubCommands);\n    when(stringPubSubConnection.async()).thenReturn(stringAsyncPubSubCommands);\n    when(binaryPubSubConnection.sync()).thenReturn(binaryPubSubCommands);\n    when(binaryPubSubConnection.async()).thenReturn(binaryAsyncPubSubCommands);\n\n    when(cluster.createPubSubConnection()).thenReturn(faultTolerantPubSubClusterConnection);\n    when(cluster.createBinaryPubSubConnection()).thenReturn(faultTolerantBinaryPubSubClusterConnection);\n\n    when(faultTolerantPubSubClusterConnection.withPubSubConnection(any(Function.class))).thenAnswer(invocation -> {\n      return invocation.getArgument(0, Function.class).apply(stringPubSubConnection);\n    });\n\n    doAnswer(invocation -> {\n      invocation.getArgument(0, Consumer.class).accept(stringPubSubConnection);\n      return null;\n    }).when(faultTolerantPubSubClusterConnection).usePubSubConnection(any(Consumer.class));\n\n    when(faultTolerantBinaryPubSubClusterConnection.withPubSubConnection(any(Function.class))).thenAnswer(\n        invocation -> {\n          return invocation.getArgument(0, Function.class).apply(binaryPubSubConnection);\n        });\n\n    doAnswer(invocation -> {\n      invocation.getArgument(0, Consumer.class).accept(binaryPubSubConnection);\n      return null;\n    }).when(faultTolerantBinaryPubSubClusterConnection).usePubSubConnection(any(Consumer.class));\n\n    return cluster;\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  public static class Builder {\n\n    private RedisAdvancedClusterCommands<String, String> stringCommands = mock(RedisAdvancedClusterCommands.class);\n    private RedisAdvancedClusterAsyncCommands<String, String> stringAsyncCommands =\n        mock(RedisAdvancedClusterAsyncCommands.class);\n\n    private RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands = mock(RedisAdvancedClusterCommands.class);\n\n    private RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands =\n        mock(RedisAdvancedClusterAsyncCommands.class);\n\n    private RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands =\n        mock(RedisAdvancedClusterReactiveCommands.class);\n\n    private RedisClusterPubSubCommands<String, String> stringPubSubCommands =\n        mock(RedisClusterPubSubCommands.class);\n\n    private RedisClusterPubSubCommands<byte[], byte[]> binaryPubSubCommands =\n        mock(RedisClusterPubSubCommands.class);\n\n    private RedisClusterPubSubAsyncCommands<String, String> stringPubSubAsyncCommands =\n        mock(RedisClusterPubSubAsyncCommands.class);\n\n    private RedisClusterPubSubAsyncCommands<byte[], byte[]> binaryPubSubAsyncCommands =\n        mock(RedisClusterPubSubAsyncCommands.class);\n\n    private Builder() {\n\n    }\n\n    public Builder stringCommands(final RedisAdvancedClusterCommands<String, String> stringCommands) {\n      this.stringCommands = stringCommands;\n      return this;\n    }\n\n    public Builder stringAsyncCommands(final RedisAdvancedClusterAsyncCommands<String, String> stringAsyncCommands) {\n      this.stringAsyncCommands = stringAsyncCommands;\n      return this;\n    }\n\n    public Builder binaryCommands(final RedisAdvancedClusterCommands<byte[], byte[]> binaryCommands) {\n      this.binaryCommands = binaryCommands;\n      return this;\n    }\n\n    public Builder binaryAsyncCommands(final RedisAdvancedClusterAsyncCommands<byte[], byte[]> binaryAsyncCommands) {\n      this.binaryAsyncCommands = binaryAsyncCommands;\n      return this;\n    }\n\n    public Builder binaryReactiveCommands(\n        final RedisAdvancedClusterReactiveCommands<byte[], byte[]> binaryReactiveCommands) {\n      this.binaryReactiveCommands = binaryReactiveCommands;\n      return this;\n    }\n\n    public Builder stringPubSubCommands(final RedisClusterPubSubCommands<String, String> stringPubSubCommands) {\n      this.stringPubSubCommands = stringPubSubCommands;\n      return this;\n    }\n\n    public Builder binaryPubSubCommands(final RedisClusterPubSubCommands<byte[], byte[]> binaryPubSubCommands) {\n      this.binaryPubSubCommands = binaryPubSubCommands;\n      return this;\n    }\n\n    public Builder stringPubSubAsyncCommands(\n        final RedisClusterPubSubAsyncCommands<String, String> stringPubSubAsyncCommands) {\n      this.stringPubSubAsyncCommands = stringPubSubAsyncCommands;\n      return this;\n    }\n\n    public Builder binaryPubSubAsyncCommands(\n        final RedisClusterPubSubAsyncCommands<byte[], byte[]> binaryPubSubAsyncCommands) {\n      this.binaryPubSubAsyncCommands = binaryPubSubAsyncCommands;\n      return this;\n    }\n\n    public FaultTolerantRedisClusterClient build() {\n      return RedisClusterHelper.buildMockRedisCluster(stringCommands, stringAsyncCommands, binaryCommands,\n          binaryAsyncCommands,\n          binaryReactiveCommands, stringPubSubCommands, stringPubSubAsyncCommands, binaryPubSubCommands,\n          binaryPubSubAsyncCommands);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/RedisServerHelper.java",
    "content": "package org.whispersystems.textsecuregcm.tests.util;\n\nimport io.lettuce.core.api.StatefulRedisConnection;\nimport io.lettuce.core.api.async.RedisAsyncCommands;\nimport io.lettuce.core.api.reactive.RedisReactiveCommands;\nimport io.lettuce.core.api.sync.RedisCommands;\nimport org.whispersystems.textsecuregcm.redis.FaultTolerantRedisClient;\nimport java.util.function.Consumer;\nimport java.util.function.Function;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\npublic class RedisServerHelper {\n\n  public static RedisServerHelper.Builder builder() {\n    return new RedisServerHelper.Builder();\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  private static FaultTolerantRedisClient buildMockRedisClient(\n      final RedisCommands<String, String> stringCommands,\n      final RedisAsyncCommands<String, String> stringAsyncCommands,\n      final RedisCommands<byte[], byte[]> binaryCommands,\n      final RedisAsyncCommands<byte[], byte[]> binaryAsyncCommands,\n      final RedisReactiveCommands<byte[], byte[]> binaryReactiveCommands) {\n    final FaultTolerantRedisClient client = mock(FaultTolerantRedisClient.class);\n    final StatefulRedisConnection<String, String> stringConnection = mock(StatefulRedisConnection.class);\n    final StatefulRedisConnection<byte[], byte[]> binaryConnection = mock(StatefulRedisConnection.class);\n\n    when(stringConnection.sync()).thenReturn(stringCommands);\n    when(stringConnection.async()).thenReturn(stringAsyncCommands);\n    when(binaryConnection.sync()).thenReturn(binaryCommands);\n    when(binaryConnection.async()).thenReturn(binaryAsyncCommands);\n    when(binaryConnection.reactive()).thenReturn(binaryReactiveCommands);\n\n    when(client.withConnection(any(Function.class))).thenAnswer(invocation -> {\n      return invocation.getArgument(0, Function.class).apply(stringConnection);\n    });\n\n    doAnswer(invocation -> {\n      invocation.getArgument(0, Consumer.class).accept(stringConnection);\n      return null;\n    }).when(client).useConnection(any(Consumer.class));\n\n    when(client.withBinaryConnection(any(Function.class))).thenAnswer(invocation -> {\n      return invocation.getArgument(0, Function.class).apply(binaryConnection);\n    });\n\n    doAnswer(invocation -> {\n      invocation.getArgument(0, Consumer.class).accept(binaryConnection);\n      return null;\n    }).when(client).useBinaryConnection(any(Consumer.class));\n\n    return client;\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  public static class Builder {\n\n    private RedisCommands<String, String> stringCommands = mock(RedisCommands.class);\n    private RedisAsyncCommands<String, String> stringAsyncCommands = mock(RedisAsyncCommands.class);\n\n    private RedisCommands<byte[], byte[]> binaryCommands = mock(RedisCommands.class);\n\n    private RedisAsyncCommands<byte[], byte[]> binaryAsyncCommands =\n        mock(RedisAsyncCommands.class);\n\n    private RedisReactiveCommands<byte[], byte[]> binaryReactiveCommands =\n        mock(RedisReactiveCommands.class);\n\n    private Builder() {\n\n    }\n\n    public RedisServerHelper.Builder stringCommands(final RedisCommands<String, String> stringCommands) {\n      this.stringCommands = stringCommands;\n      return this;\n    }\n\n    public RedisServerHelper.Builder stringAsyncCommands(final RedisAsyncCommands<String, String> stringAsyncCommands) {\n      this.stringAsyncCommands = stringAsyncCommands;\n      return this;\n    }\n\n    public RedisServerHelper.Builder binaryCommands(final RedisCommands<byte[], byte[]> binaryCommands) {\n      this.binaryCommands = binaryCommands;\n      return this;\n    }\n\n    public RedisServerHelper.Builder binaryAsyncCommands(final RedisAsyncCommands<byte[], byte[]> binaryAsyncCommands) {\n      this.binaryAsyncCommands = binaryAsyncCommands;\n      return this;\n    }\n\n    public RedisServerHelper.Builder binaryReactiveCommands(\n        final RedisReactiveCommands<byte[], byte[]> binaryReactiveCommands) {\n      this.binaryReactiveCommands = binaryReactiveCommands;\n      return this;\n    }\n\n    public FaultTolerantRedisClient build() {\n      return RedisServerHelper.buildMockRedisClient(stringCommands,\n          stringAsyncCommands,\n          binaryCommands,\n          binaryAsyncCommands,\n          binaryReactiveCommands);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/SynchronousExecutorService.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport com.google.common.util.concurrent.SettableFuture;\nimport java.util.Collection;\nimport java.util.Collections;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\n\npublic class SynchronousExecutorService implements ExecutorService {\n\n  private boolean shutdown = false;\n\n  @Override\n  public void shutdown() {\n    shutdown = true;\n  }\n\n  @Override\n  public List<Runnable> shutdownNow() {\n    shutdown = true;\n    return Collections.emptyList();\n  }\n\n  @Override\n  public boolean isShutdown() {\n    return shutdown;\n  }\n\n  @Override\n  public boolean isTerminated() {\n    return shutdown;\n  }\n\n  @Override\n  public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {\n    return true;\n  }\n\n  @Override\n  public <T> Future<T> submit(Callable<T> task) {\n    SettableFuture<T> future = null;\n    try {\n      future = SettableFuture.create();\n      future.set(task.call());\n    } catch (Throwable e) {\n      future.setException(e);\n    }\n\n    return future;\n  }\n\n  @Override\n  public <T> Future<T> submit(Runnable task, T result) {\n    SettableFuture<T> future = SettableFuture.create();\n    task.run();\n\n    future.set(result);\n\n    return future;\n  }\n\n  @Override\n  public Future<?> submit(Runnable task) {\n    SettableFuture future = SettableFuture.create();\n    task.run();\n    future.set(null);\n    return future;\n  }\n\n  @Override\n  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException {\n    List<Future<T>> results = new LinkedList<>();\n    for (Callable<T> callable : tasks) {\n      SettableFuture<T> future = SettableFuture.create();\n      try {\n        future.set(callable.call());\n      } catch (Throwable e) {\n        future.setException(e);\n      }\n      results.add(future);\n    }\n    return results;\n  }\n\n  @Override\n  public <T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException {\n    return invokeAll(tasks);\n  }\n\n  @Override\n  public <T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException {\n    return null;\n  }\n\n  @Override\n  public <T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {\n    return null;\n  }\n\n  @Override\n  public void execute(Runnable command) {\n    command.run();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/TestPrincipal.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport java.security.Principal;\nimport java.util.Optional;\n\npublic class TestPrincipal implements Principal {\n\n  private final String name;\n\n  private TestPrincipal(String name) {\n    this.name = name;\n  }\n\n  @Override\n  public String getName() {\n    return name;\n  }\n\n  public static Optional<TestPrincipal> authenticatedTestPrincipal(final String name) {\n    return Optional.of(new TestPrincipal(name));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/TestRecipient.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport org.whispersystems.textsecuregcm.identity.ServiceIdentifier;\n\npublic record TestRecipient(ServiceIdentifier uuid,\n                            byte[] deviceIds,\n                            int[] registrationIds,\n                            byte[] perRecipientKeyMaterial) {\n\n  public TestRecipient(ServiceIdentifier uuid,\n                       byte deviceId,\n                       int registrationId,\n                       byte[] perRecipientKeyMaterial) {\n\n    this(uuid, new byte[]{deviceId}, new int[]{registrationId}, perRecipientKeyMaterial);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/tests/util/TestWebsocketListener.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.tests.util;\n\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.WebSocketListener;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.messages.WebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;\n\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.util.List;\nimport java.util.Objects;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.atomic.AtomicLong;\n\npublic class TestWebsocketListener implements WebSocketListener {\n\n  private final AtomicLong requestId = new AtomicLong();\n  private final CompletableFuture<Session> started = new CompletableFuture<>();\n  private final CompletableFuture<Integer> closed = new CompletableFuture<>();\n  private final ConcurrentHashMap<Long, CompletableFuture<WebSocketResponseMessage>> responseFutures = new ConcurrentHashMap<>();\n  protected final WebSocketMessageFactory messageFactory;\n\n  public TestWebsocketListener() {\n    this.messageFactory = new ProtobufWebSocketMessageFactory();\n  }\n\n\n  @Override\n  public void onWebSocketConnect(final Session session) {\n    started.complete(session);\n\n  }\n\n  @Override\n  public void onWebSocketClose(int statusCode, String reason) {\n    closed.complete(statusCode);\n  }\n\n  public CompletableFuture<Integer> closeFuture() {\n    return closed;\n  }\n\n  public CompletableFuture<WebSocketResponseMessage> doGet(final String requestPath) {\n    return sendRequest(requestPath, \"GET\", List.of(\"Accept: application/json\"), Optional.empty());\n  }\n\n  public CompletableFuture<WebSocketResponseMessage> sendRequest(\n      final String requestPath,\n      final String verb,\n      final List<String> headers,\n      final Optional<byte[]> body) {\n    return started.thenCompose(session -> {\n      final long id = requestId.incrementAndGet();\n      final CompletableFuture<WebSocketResponseMessage> future = new CompletableFuture<>();\n      responseFutures.put(id, future);\n      final byte[] requestBytes = messageFactory.createRequest(\n          Optional.of(id), verb, requestPath, headers, body).toByteArray();\n      try {\n        session.getRemote().sendBytes(ByteBuffer.wrap(requestBytes));\n      } catch (IOException e) {\n        throw new RuntimeException(e);\n      }\n      return future;\n    });\n  }\n\n  @Override\n  public void onWebSocketBinary(final byte[] payload, final int offset, final int length) {\n    try {\n      WebSocketMessage webSocketMessage = messageFactory.parseMessage(payload, offset, length);\n      if (Objects.requireNonNull(webSocketMessage.getType()) == WebSocketMessage.Type.RESPONSE_MESSAGE) {\n        responseFutures.get(webSocketMessage.getResponseMessage().getRequestId())\n            .complete(webSocketMessage.getResponseMessage());\n      } else {\n        throw new RuntimeException(\"Unexpected message type: \" + webSocketMessage.getType());\n      }\n    } catch (final Exception e) {\n      throw new RuntimeException(e);\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/AttributeValuesTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport software.amazon.awssdk.core.SdkBytes;\nimport software.amazon.awssdk.services.dynamodb.model.AttributeValue;\nimport java.nio.ByteBuffer;\nimport java.util.Base64;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.stream.Stream;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\npublic class AttributeValuesTest {\n  @Test\n  void testUUIDRoundTrip() {\n    UUID orig = UUID.randomUUID();\n    AttributeValue av = AttributeValues.fromUUID(orig);\n    UUID returned = AttributeValues.getUUID(Map.of(\"foo\", av), \"foo\", null);\n    assertEquals(orig, returned);\n  }\n\n  @Test\n  void testLongRoundTrip() {\n    long orig = 12345;\n    AttributeValue av = AttributeValues.fromLong(orig);\n    long returned = AttributeValues.getLong(Map.of(\"foo\", av), \"foo\", -1);\n    assertEquals(orig, returned);\n  }\n\n  @Test\n  void testIntRoundTrip() {\n    int orig = 12345;\n    AttributeValue av = AttributeValues.fromInt(orig);\n    int returned = AttributeValues.getInt(Map.of(\"foo\", av), \"foo\", -1);\n    assertEquals(orig, returned);\n  }\n\n  @Test\n  void testByteBuffer() {\n    byte[] bytes = {1, 2, 3};\n    ByteBuffer bb = ByteBuffer.wrap(bytes);\n    AttributeValue av = AttributeValues.fromByteBuffer(bb);\n    byte[] returned = av.b().asByteArray();\n    assertArrayEquals(bytes, returned);\n    returned = AttributeValues.getByteArray(Map.of(\"foo\", av), \"foo\", null);\n    assertArrayEquals(bytes, returned);\n  }\n\n  @Test\n  void testByteBuffer2() {\n    final ByteBuffer byteBuffer = ByteBuffer.wrap(new byte[8]);\n    byteBuffer.putLong(123);\n    assertEquals(byteBuffer.remaining(), 0);\n    AttributeValue av = AttributeValues.fromByteBuffer(byteBuffer.flip());\n    assertArrayEquals(new byte[]{0, 0, 0, 0, 0, 0, 0, 123}, AttributeValues.getByteArray(Map.of(\"foo\", av), \"foo\", null));\n  }\n\n  @Test\n  void testNullUuid() {\n    final Map<String, AttributeValue> item = Map.of(\"key\", AttributeValue.builder().nul(true).build());\n    assertNull(AttributeValues.getUUID(item, \"key\", null));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void extractByteArray(final AttributeValue attributeValue, final byte[] expectedByteArray) {\n    assertArrayEquals(expectedByteArray, AttributeValues.extractByteArray(attributeValue, \"counter\"));\n  }\n\n  private static Stream<Arguments> extractByteArray() {\n    final byte[] key = Base64.getDecoder().decode(\"c+k+8zv8WaFdDjR9IOvCk6BcY5OI7rge/YUDkaDGyRc=\");\n\n    return Stream.of(\n        Arguments.of(AttributeValue.fromB(SdkBytes.fromByteArray(key)), key),\n        Arguments.of(AttributeValue.fromS(Base64.getEncoder().encodeToString(key)), key),\n        Arguments.of(AttributeValue.fromS(Base64.getEncoder().withoutPadding().encodeToString(key)), key)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void extractByteArrayIllegalArgument(final AttributeValue attributeValue) {\n    assertThrows(IllegalArgumentException.class, () -> AttributeValues.extractByteArray(attributeValue, \"counter\"));\n  }\n\n  private static Stream<Arguments> extractByteArrayIllegalArgument() {\n    return Stream.of(\n        Arguments.of(AttributeValue.fromN(\"12\")),\n        Arguments.of(AttributeValue.fromS(\"\")),\n        Arguments.of(AttributeValue.fromS(\"Definitely not legitimate base64 👎\"))\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/BoundedVirtualThreadFactoryTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.fail;\n\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.RejectedExecutionException;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.IntStream;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\n\nclass BoundedVirtualThreadFactoryTest {\n\n  private final static int MAX_THREADS = 10;\n\n  private BoundedVirtualThreadFactory factory;\n\n  @BeforeEach\n  void setUp() {\n    factory = new BoundedVirtualThreadFactory(\"test\", MAX_THREADS);\n  }\n\n  @Test\n  void releaseWhenTaskThrows() throws InterruptedException {\n    final UncaughtExceptionHolder uncaughtExceptionHolder = new UncaughtExceptionHolder();\n    final Thread t = submit(() -> {\n      throw new IllegalArgumentException(\"test\");\n    }, uncaughtExceptionHolder);\n    assertThat(t).isNotNull();\n    t.join(Duration.ofSeconds(1));\n    assertThat(uncaughtExceptionHolder.exception).isNotNull().isInstanceOf(IllegalArgumentException.class);\n\n    submitUntilRejected();\n  }\n\n  @Test\n  void releaseWhenRejected() throws InterruptedException {\n    submitUntilRejected();\n    submitUntilRejected();\n  }\n\n  @Test\n  void executorServiceRejectsAtLimit() throws InterruptedException {\n    try (final ExecutorService executor = Executors.newThreadPerTaskExecutor(factory)) {\n\n      final CountDownLatch latch = new CountDownLatch(1);\n      for (int i = 0; i < MAX_THREADS; i++) {\n        executor.submit(() -> {\n          try {\n            latch.await();\n          } catch (InterruptedException e) {\n            throw new RuntimeException(e);\n          }\n        });\n      }\n      assertThatExceptionOfType(RejectedExecutionException.class).isThrownBy(() -> executor.submit(Util.NOOP));\n      latch.countDown();\n\n      executor.shutdown();\n      assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();\n    }\n  }\n\n  @Test\n  void stressTest() throws InterruptedException {\n    for (int iteration = 0; iteration < 50; iteration++) {\n      final CountDownLatch latch = new CountDownLatch(MAX_THREADS);\n\n      // submit a task that submits a task maxThreads/2 times\n      final Thread[] threads = new Thread[MAX_THREADS];\n      for (int i = 0; i < MAX_THREADS; i+=2) {\n        int outerThreadIndex = i;\n        int innerThreadIndex = i + 1;\n\n        threads[outerThreadIndex] = submit(() -> {\n          latch.countDown();\n          threads[innerThreadIndex] = submit(latch::countDown);\n        });\n      }\n      latch.await();\n\n      // All threads must be created at this point, wait for them all to complete\n      for (Thread thread : threads) {\n        assertThat(thread).isNotNull();\n        thread.join();\n      }\n\n      assertThat(factory.getRunningThreads()).isEqualTo(0);\n    }\n\n    submitUntilRejected();\n  }\n\n  /**\n   * Verify we can submit up to the concurrency limit (and no more)\n   */\n  private void submitUntilRejected() throws InterruptedException {\n    final CountDownLatch finish = new CountDownLatch(1);\n    final List<Thread> threads = IntStream.range(0, MAX_THREADS).mapToObj(_ -> submit(() -> {\n      try {\n        finish.await();\n      } catch (InterruptedException e) {\n        throw new RuntimeException(e);\n      }\n    })).toList();\n\n    assertThat(submit(Util.NOOP)).isNull();\n\n    finish.countDown();\n\n    for (Thread thread : threads) {\n      thread.join();\n    }\n    assertThat(factory.getRunningThreads()).isEqualTo(0);\n  }\n\n  private Thread submit(final Runnable runnable) {\n    return submit(runnable, (t, e) ->\n      fail(\"Uncaught exception on thread {} : {}\", t, e));\n  }\n\n  private Thread submit(final Runnable runnable, final Thread.UncaughtExceptionHandler handler) {\n    final Thread thread = factory.newThread(runnable);\n    if (thread == null) {\n      return null;\n    }\n    if (handler != null) {\n      thread.setUncaughtExceptionHandler(handler);\n    }\n    thread.start();\n    return thread;\n  }\n\n  private static class UncaughtExceptionHolder implements Thread.UncaughtExceptionHandler {\n    Throwable exception = null;\n\n    @Override\n    public void uncaughtException(final Thread t, final Throwable e) {\n        exception = e;\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/ClosableEpochTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\n\nimport java.util.concurrent.BrokenBarrierException;\nimport java.util.concurrent.CyclicBarrier;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\nimport static org.junit.jupiter.api.Assertions.*;\n\nclass ClosableEpochTest {\n\n  @Test\n  void close() {\n    {\n      final AtomicBoolean closed = new AtomicBoolean(false);\n      final ClosableEpoch closableEpoch = new ClosableEpoch(() -> closed.set(true));\n\n      assertTrue(closableEpoch.tryArrive(), \"New callers should be allowed to arrive before closure\");\n      assertEquals(1, closableEpoch.getActiveCallers());\n\n      closableEpoch.close();\n      assertFalse(closableEpoch.tryArrive(), \"New callers should not be allowed to arrive after closure\");\n      assertEquals(1, closableEpoch.getActiveCallers());\n      assertFalse(closed.get(), \"Close handler should not fire until all callers have departed\");\n\n      closableEpoch.depart();\n      assertTrue(closed.get(), \"Close handler should fire after last caller departs\");\n      assertEquals(0, closableEpoch.getActiveCallers());\n\n      assertThrows(IllegalStateException.class, closableEpoch::close,\n          \"Double-closing a epoch should throw an exception\");\n    }\n\n    {\n      final AtomicBoolean closed = new AtomicBoolean(false);\n      final ClosableEpoch closableEpoch = new ClosableEpoch(() -> closed.set(true));\n\n      closableEpoch.close();\n      assertTrue(closed.get(), \"Empty epoch should fire close handler immediately on closure\");\n      assertEquals(0, closableEpoch.getActiveCallers());\n    }\n  }\n\n  @Test\n  @Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\n  void closeConcurrent() throws InterruptedException {\n    final AtomicBoolean closed = new AtomicBoolean(false);\n    final ClosableEpoch closableEpoch = new ClosableEpoch(() -> {\n      synchronized (closed) {\n        closed.set(true);\n        closed.notifyAll();\n      }\n    });\n\n    final int threadCount = 128;\n    final CyclicBarrier cyclicBarrier = new CyclicBarrier(threadCount);\n\n    // Spawn a bunch of threads doing some simulated work. Close the epoch roughly halfway through. Some threads should\n    // successfully enter the critical section and others should be rejected.\n    for (int t = 0; t < threadCount; t++) {\n      final boolean shouldClose = t == threadCount / 2;\n\n      Thread.ofVirtual().start(() -> {\n        try {\n          // Wait for all threads to reach the proverbial starting line\n          cyclicBarrier.await();\n        } catch (final InterruptedException | BrokenBarrierException ignored) {\n        }\n\n        if (shouldClose) {\n          closableEpoch.close();\n        }\n\n        if (closableEpoch.tryArrive()) {\n          // Perform some simulated \"work\"\n          try {\n            Thread.sleep(1);\n          } catch (final InterruptedException ignored) {\n          } finally {\n            closableEpoch.depart();\n          }\n        }\n      });\n    }\n\n    while (!closed.get()) {\n      synchronized (closed) {\n        closed.wait();\n      }\n    }\n\n    assertEquals(0, closableEpoch.getActiveCallers());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/CompletableFutureTestUtil.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.TimeUnit;\n\npublic class CompletableFutureTestUtil {\n\n  private CompletableFutureTestUtil() {\n  }\n\n  public static <T extends Throwable> T assertFailsWithCause(final Class<T> expectedCause, final CompletableFuture<?> completableFuture) {\n    return assertFailsWithCause(expectedCause, completableFuture, null);\n  }\n\n  public static <T extends Throwable> T assertFailsWithCause(final Class<T> expectedCause, final CompletableFuture<?> completableFuture, final String message) {\n    final CompletionException completionException = assertThrows(CompletionException.class, completableFuture::join, message);\n    final Throwable unwrapped = ExceptionUtils.unwrap(completionException);\n    final String compError = \"Expected failure \" + expectedCause + \" was \" + unwrapped.getClass();\n    assertTrue(unwrapped.getClass().isAssignableFrom(expectedCause), message == null ? compError : message + \" : \" + compError);\n    return expectedCause.cast(unwrapped);\n  }\n\n  public static <T> CompletableFuture<T> almostCompletedFuture(T result) {\n    return new CompletableFuture<T>().completeOnTimeout(result, 5, TimeUnit.MILLISECONDS);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/DeviceCapabilityAdapterTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.util.EnumSet;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.storage.DeviceCapability;\n\nclass DeviceCapabilityAdapterTest {\n\n  private record TestObject(\n      @JsonSerialize(using = DeviceCapabilityAdapter.Serializer.class)\n      @JsonDeserialize(using = DeviceCapabilityAdapter.Deserializer.class)\n      @Nullable\n      EnumSet<DeviceCapability> capabilities) {\n  }\n\n  @Test\n  void serializeDeserialize() throws JsonProcessingException {\n    {\n      final TestObject testObject = new TestObject(EnumSet.of(DeviceCapability.TRANSFER, DeviceCapability.STORAGE));\n      final String json = SystemMapper.jsonMapper().writeValueAsString(testObject);\n\n      assertEquals(testObject, SystemMapper.jsonMapper().readValue(json, TestObject.class));\n    }\n\n    {\n      final TestObject testObject = new TestObject(EnumSet.noneOf(DeviceCapability.class));\n      final String json = SystemMapper.jsonMapper().writeValueAsString(testObject);\n\n      assertEquals(testObject, SystemMapper.jsonMapper().readValue(json, TestObject.class));\n    }\n\n    {\n      final TestObject testObject = new TestObject(null);\n      final String json = SystemMapper.jsonMapper().writeValueAsString(testObject);\n\n      assertEquals(testObject, SystemMapper.jsonMapper().readValue(json, TestObject.class));\n    }\n\n    {\n      final String json = \"\"\"\n          {\n            \"capabilities\": {\n              \"transfer\": true,\n              \"unrecognizedCapability\": true\n            }\n          }\n          \"\"\";\n\n      assertEquals(new TestObject(EnumSet.of(DeviceCapability.TRANSFER)),\n          SystemMapper.jsonMapper().readValue(json, TestObject.class));\n    }\n\n    {\n      final String json = \"\"\"\n          {\n            \"capabilities\": {\n              \"transfer\": true,\n              \"deleteSync\": false\n            }\n          }\n          \"\"\";\n\n      assertEquals(new TestObject(EnumSet.of(DeviceCapability.TRANSFER)),\n          SystemMapper.jsonMapper().readValue(json, TestObject.class));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/DeviceNameByteArrayAdapterTest.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonParser;\nimport com.fasterxml.jackson.databind.DeserializationContext;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nimport java.io.IOException;\nimport java.util.Base64;\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nclass DeviceNameByteArrayAdapterTest {\n\n  @Test\n  void serialize() throws IOException {\n    final byte[] deviceName = TestRandomUtil.nextBytes(16);\n    final JsonGenerator jsonGenerator = mock(JsonGenerator.class);\n\n    new DeviceNameByteArrayAdapter.Serializer().serialize(deviceName, jsonGenerator, mock(SerializerProvider.class));\n\n    verify(jsonGenerator).writeString(Base64.getEncoder().encodeToString(deviceName));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void deserialize(final String encodedString, final byte[] expectedBytes) throws IOException {\n    final JsonParser jsonParser = mock(JsonParser.class);\n    when(jsonParser.getValueAsString()).thenReturn(encodedString);\n\n    assertArrayEquals(expectedBytes,\n        new DeviceNameByteArrayAdapter.Deserializer().deserialize(jsonParser, mock(DeserializationContext.class)));\n  }\n\n  private static List<Arguments> deserialize() {\n    final byte[] deviceName = TestRandomUtil.nextBytes(16);\n\n    return List.of(\n        Arguments.of(Base64.getEncoder().encodeToString(deviceName), deviceName),\n        Arguments.of(\"This is not a valid Base64 string\", null)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/E164Test.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport jakarta.validation.ConstraintViolation;\nimport jakarta.validation.Validation;\nimport jakarta.validation.Validator;\nimport java.lang.reflect.Method;\nimport java.util.Optional;\nimport java.util.Set;\nimport org.junit.jupiter.api.Test;\n\npublic class E164Test {\n\n  private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();\n\n  private static final String E164_VALID = \"+18005550123\";\n\n  private static final String E164_INVALID = \"1(800)555-0123\";\n\n  private static final String EMPTY = \"\";\n\n  @SuppressWarnings(\"FieldCanBeLocal\")\n  private static class Data {\n\n    @E164\n    private final String number;\n\n    @E164\n    private final Optional<String> optionalNumber;\n\n    private Data(final String number, final Optional<String> optionalNumber) {\n      this.number = number;\n      this.optionalNumber = optionalNumber;\n    }\n  }\n\n  private static class Methods {\n\n    public void foo(@E164 final String number, @E164 final Optional<String> optionalNumber) {\n      // noop\n    }\n\n    @E164\n    public String bar() {\n      return \"nevermind\";\n    }\n\n    @E164\n    public Optional<String> barOptionalString() {\n      return Optional.of(\"nevermind\");\n    }\n  }\n\n  private record Rec(@E164 String number, @E164 Optional<String> optionalNumber) {\n  }\n\n  @Test\n  public void testRecord() {\n    checkNoViolations(new Rec(E164_VALID, Optional.of(E164_VALID)));\n    checkHasViolations(new Rec(E164_INVALID, Optional.of(E164_INVALID)));\n    checkHasViolations(new Rec(EMPTY, Optional.of(EMPTY)));\n  }\n\n  @Test\n  public void testClassField() {\n    checkNoViolations(new Data(E164_VALID, Optional.of(E164_VALID)));\n    checkHasViolations(new Data(E164_INVALID, Optional.of(E164_INVALID)));\n    checkHasViolations(new Data(EMPTY, Optional.of(EMPTY)));\n  }\n\n  @Test\n  public void testParameters() throws Exception {\n    final Methods m = new Methods();\n    final Method foo = Methods.class.getMethod(\"foo\", String.class, Optional.class);\n\n    final Set<ConstraintViolation<Methods>> violations1 =\n        VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_VALID, Optional.of(E164_VALID)});\n    final Set<ConstraintViolation<Methods>> violations2 =\n        VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {E164_INVALID, Optional.of(E164_INVALID)});\n    final Set<ConstraintViolation<Methods>> violations3 =\n        VALIDATOR.forExecutables().validateParameters(m, foo, new Object[] {EMPTY, Optional.of(EMPTY)});\n\n    assertTrue(violations1.isEmpty());\n    assertFalse(violations2.isEmpty());\n    assertFalse(violations3.isEmpty());\n  }\n\n  @Test\n  public void testReturnValue() throws Exception {\n    final Methods m = new Methods();\n    final Method bar = Methods.class.getMethod(\"bar\");\n\n    final Set<ConstraintViolation<Methods>> violations1 =\n        VALIDATOR.forExecutables().validateReturnValue(m, bar, E164_VALID);\n    final Set<ConstraintViolation<Methods>> violations2 =\n        VALIDATOR.forExecutables().validateReturnValue(m, bar, E164_INVALID);\n    final Set<ConstraintViolation<Methods>> violations3 =\n        VALIDATOR.forExecutables().validateReturnValue(m, bar, EMPTY);\n\n    assertTrue(violations1.isEmpty());\n    assertFalse(violations2.isEmpty());\n    assertFalse(violations3.isEmpty());\n  }\n\n  @Test\n  public void testOptionalReturnValue() throws Exception {\n    final Methods m = new Methods();\n    final Method bar = Methods.class.getMethod(\"barOptionalString\");\n\n    final Set<ConstraintViolation<Methods>> violations1 =\n        VALIDATOR.forExecutables().validateReturnValue(m, bar, Optional.of(E164_VALID));\n    final Set<ConstraintViolation<Methods>> violations2 =\n        VALIDATOR.forExecutables().validateReturnValue(m, bar, Optional.of(E164_INVALID));\n    final Set<ConstraintViolation<Methods>> violations3 =\n        VALIDATOR.forExecutables().validateReturnValue(m, bar, Optional.of(EMPTY));\n\n    assertTrue(violations1.isEmpty());\n    assertFalse(violations2.isEmpty());\n    assertFalse(violations3.isEmpty());\n  }\n\n  private static <T> void checkNoViolations(final T object) {\n    final Set<ConstraintViolation<T>> violations = VALIDATOR.validate(object);\n    assertTrue(violations.isEmpty());\n  }\n\n  private static <T> void checkHasViolations(final T object) {\n    final Set<ConstraintViolation<T>> violations = VALIDATOR.validate(object);\n    assertFalse(violations.isEmpty());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/ECPublicKeyAdapterTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.JsonMappingException;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.util.Base64;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\n\nclass ECPublicKeyAdapterTest {\n\n  private static final String JSON_TEMPLATE = \"\"\"\n      {\n        \"publicKey\": %s\n      }\n      \"\"\";\n\n  private static final ECPublicKey EC_PUBLIC_KEY = ECKeyPair.generate().getPublicKey();\n\n  private record ECPublicKeyCarrier(@JsonSerialize(using = ECPublicKeyAdapter.Serializer.class)\n                                    @JsonDeserialize(using = ECPublicKeyAdapter.Deserializer.class)\n                                    ECPublicKey publicKey) {\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void deserialize(final String json, @Nullable final ECPublicKey expectedPublicKey) throws JsonProcessingException {\n    final ECPublicKeyCarrier publicKeyCarrier = SystemMapper.jsonMapper().readValue(json, ECPublicKeyCarrier.class);\n\n    assertEquals(expectedPublicKey, publicKeyCarrier.publicKey());\n  }\n\n  private static Stream<Arguments> deserialize() {\n    return Stream.of(\n        Arguments.of(String.format(JSON_TEMPLATE, \"null\"), null),\n        Arguments.of(String.format(JSON_TEMPLATE, \"\\\"\\\"\"), null),\n        Arguments.of(String.format(JSON_TEMPLATE, \"\\\"\" + Base64.getEncoder().encodeToString(EC_PUBLIC_KEY.serialize()) + \"\\\"\"), EC_PUBLIC_KEY)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void deserializeInvalidKey(final String json) {\n    assertThrows(JsonMappingException.class,\n        () -> SystemMapper.jsonMapper().readValue(json, ECPublicKeyCarrier.class));\n  }\n\n  private static Stream<String> deserializeInvalidKey() {\n    return Stream.of(\n        String.format(JSON_TEMPLATE, \"\\\"\" + Base64.getEncoder().encodeToString(new byte[12]) + \"\\\"\"),\n        String.format(JSON_TEMPLATE, \"\\\"This is not a legal base64-encoded string\\\"\")\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/EncryptDeviceCreationTimestampUtilTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport org.junit.jupiter.api.Test;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.InvalidMessageException;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\n\nimport java.nio.ByteBuffer;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.whispersystems.textsecuregcm.util.EncryptDeviceCreationTimestampUtil.ENCRYPTION_INFO;\n\npublic class EncryptDeviceCreationTimestampUtilTest {\n  @Test\n  void encryptDecrypt() throws InvalidMessageException {\n    final long createdAt = System.currentTimeMillis();\n    final ECKeyPair keyPair = ECKeyPair.generate();\n    final byte deviceId = 1;\n    final int registrationId = 123;\n\n    final byte[] ciphertext = EncryptDeviceCreationTimestampUtil.encrypt(createdAt, new IdentityKey(keyPair.getPublicKey()),\n        deviceId, registrationId);\n    final ByteBuffer associatedData = ByteBuffer.allocate(5);\n    associatedData.put(deviceId);\n    associatedData.putInt(registrationId);\n\n    final byte[] decryptedData = keyPair.getPrivateKey().open(ciphertext, ENCRYPTION_INFO, associatedData.array());\n\n    assertEquals(createdAt, ByteBuffer.wrap(decryptedData).getLong());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/ExecutorUtilTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.assertj.core.api.Assertions.assertThatExceptionOfType;\nimport static org.assertj.core.api.Assertions.assertThatNoException;\n\nimport java.util.List;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicLong;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\n\n\nclass ExecutorUtilTest {\n\n  private ExecutorService executor;\n\n  @BeforeEach\n  void setUp() {\n    this.executor = Executors.newSingleThreadExecutor();\n  }\n\n  @AfterEach\n  void tearDown() throws InterruptedException {\n    this.executor.shutdown();\n    this.executor.awaitTermination(1, TimeUnit.SECONDS);\n  }\n\n  @Test\n  void runAllWaits() {\n    final AtomicLong c = new AtomicLong(5);\n    ExecutorUtil.runAll(executor, Stream\n        .<Runnable>generate(() -> () -> {\n          Util.sleep(1);\n          c.decrementAndGet();\n        })\n        .limit(5)\n        .toList());\n    assertThat(c.get()).isEqualTo(0);\n  }\n\n  @Test\n  void runAllWithException() {\n    assertThatExceptionOfType(IllegalStateException.class)\n        .isThrownBy(() -> ExecutorUtil.runAll(executor, List.of(Util.NOOP, Util.NOOP, () -> {\n          throw new IllegalStateException(\"oof\");\n        })));\n  }\n\n  @Test\n  void runAllEmpty() {\n    assertThatNoException().isThrownBy(() -> ExecutorUtil.runAll(executor, List.of()));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/HttpServletRequestUtilIntegrationTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assumptions.assumeTrue;\n\nimport io.dropwizard.core.Application;\nimport io.dropwizard.core.Configuration;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.testing.junit5.DropwizardAppExtension;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.client.Client;\nimport jakarta.ws.rs.core.Context;\nimport java.net.InetAddress;\nimport java.util.Arrays;\nimport java.util.Set;\nimport java.util.stream.Collectors;\nimport org.eclipse.jetty.util.HostPort;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass HttpServletRequestUtilIntegrationTest {\n\n  private static final String PATH = \"/test\";\n\n  // The Grizzly test container does not match the Jetty container used in real deployments, and JettyTestContainerFactory\n  // in jersey-test-framework-provider-jetty doesn’t easily support @Context HttpServletRequest, so this test runs a\n  // full Jetty server in a separate process\n  private final DropwizardAppExtension<Configuration> EXTENSION = new DropwizardAppExtension<>(TestApplication.class);\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"127.0.0.1\", \"0:0:0:0:0:0:0:1\"})\n  void test(String ip) throws Exception {\n    final Set<String> addresses = Arrays.stream(InetAddress.getAllByName(\"localhost\"))\n        .map(InetAddress::getHostAddress)\n        .collect(Collectors.toSet());\n\n    assumeTrue(addresses.contains(ip), String.format(\"localhost does not resolve to %s\", ip));\n\n    Client client = EXTENSION.client();\n\n    final TestResponse response = client.target(\n            String.format(\"http://%s:%d%s\", HostPort.normalizeHost(ip), EXTENSION.getLocalPort(), PATH))\n        .request(\"application/json\")\n        .get(TestResponse.class);\n\n    assertEquals(ip, response.remoteAddress());\n  }\n\n  @Path(PATH)\n  public static class TestController {\n\n    @GET\n    public TestResponse get(@Context HttpServletRequest request) {\n\n      return new TestResponse(HttpServletRequestUtil.getRemoteAddress(request));\n    }\n\n  }\n\n  public record TestResponse(String remoteAddress) {\n\n  }\n\n  public static class TestApplication extends Application<Configuration> {\n\n    @Override\n    public void run(final Configuration configuration, final Environment environment) throws Exception {\n      environment.jersey().register(new TestController());\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/HttpServletRequestUtilTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\n\nclass HttpServletRequestUtilTest {\n\n  @ParameterizedTest\n  @CsvSource({\n      \"127.0.0.1, 127.0.0.1\",\n      \"[0:0:0:0:0:0:0:1], 0:0:0:0:0:0:0:1\"\n  })\n  void testGetRemoteAddress(final String remoteAddr, final String expectedRemoteAddr) {\n    final HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);\n    when(httpServletRequest.getRemoteAddr()).thenReturn(remoteAddr);\n\n    assertEquals(expectedRemoteAddr, HttpServletRequestUtil.getRemoteAddress(httpServletRequest));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/HttpUtilsTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport org.junit.jupiter.api.Test;\nimport java.util.List;\nimport java.util.Map;\n\npublic class HttpUtilsTest {\n\n  @Test\n  public void queryParameterStringPreservesOrder() {\n    final String result = HttpUtils.queryParamString(List.of(\n        Map.entry(\"a\", \"aval\"),\n        Map.entry(\"b\", \"bval1\"),\n        Map.entry(\"b\", \"bval2\")\n    ));\n    // https://url.spec.whatwg.org/#example-constructing-urlsearchparams allows multiple parameters with the same key\n    // https://url.spec.whatwg.org/#example-searchparams-sort implies that the relative order of values for parameters\n    // with the same key must be preserved\n    assertThat(result).isEqualTo(\"?a=aval&b=bval1&b=bval2\");\n  }\n\n  @Test\n  public void queryParameterStringEncodesUnsafeChars() {\n    final String result = HttpUtils.queryParamString(List.of(Map.entry(\"&k?e=y/!\", \"=v/a?l&u;e\")));\n    assertThat(result).isEqualTo(\"?%26k%3Fe%3Dy%2F%21=%3Dv%2Fa%3Fl%26u%3Be\");\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/IdentityKeyAdapterTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.util.Base64;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.libsignal.protocol.IdentityKey;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\n\nclass IdentityKeyAdapterTest {\n\n  private static final IdentityKey IDENTITY_KEY = new IdentityKey(ECKeyPair.generate().getPublicKey());\n\n  private record IdentityKeyCarrier(@JsonSerialize(using = IdentityKeyAdapter.Serializer.class)\n                                    @JsonDeserialize(using = IdentityKeyAdapter.Deserializer.class)\n                                    IdentityKey identityKey) {\n\n  };\n\n  @ParameterizedTest\n  @MethodSource\n  void deserialize(final String json, @Nullable final IdentityKey expectedIdentityKey) throws JsonProcessingException {\n    final IdentityKeyCarrier identityKeyCarrier = SystemMapper.jsonMapper().readValue(json, IdentityKeyCarrier.class);\n\n    assertEquals(expectedIdentityKey, identityKeyCarrier.identityKey());\n  }\n\n  private static Stream<Arguments> deserialize() {\n    final String template = \"\"\"\n        {\n          \"identityKey\": %s\n        }\n        \"\"\";\n\n    return Stream.of(\n        Arguments.of(String.format(template, \"null\"), null),\n        Arguments.of(String.format(template, \"\\\"\\\"\"), null),\n        Arguments.of(\n            String.format(template, \"\\\"\" + Base64.getEncoder().encodeToString(IDENTITY_KEY.serialize()) + \"\\\"\"),\n            IDENTITY_KEY)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/InetAddressRangeTest.java",
    "content": "/*\n * Copyright 2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.core.type.TypeReference;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport java.util.Map;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass InetAddressRangeTest {\n\n  @ParameterizedTest\n  @ValueSource(strings = {\"192.168.0.1\", \"192.168.0.0/33\", \"$%#*(@!&^$/24\", \"192.168.0.0/fish\", \"signal.org\"})\n  void testBogusCidrBlock(final String cidrBlock) {\n    assertThrows(IllegalArgumentException.class, () -> new InetAddressRange(cidrBlock));\n  }\n\n  @ParameterizedTest\n  @MethodSource(\"argumentsForTestGeneratePrefixMask\")\n  void testGeneratePrefixMask(final int addressLengthBytes, final int prefixLengthBits, final byte[] expectedMask) {\n    assertArrayEquals(expectedMask, InetAddressRange.generatePrefixMask(addressLengthBytes, prefixLengthBits));\n  }\n\n  private static Stream<Arguments> argumentsForTestGeneratePrefixMask() {\n    return Stream.of(\n        Arguments.of(4, 32, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}),\n        Arguments.of(4, 24, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xff, 0x00}),\n        Arguments.of(4, 22, new byte[]{(byte) 0xff, (byte) 0xff, (byte) 0xfc, 0x00}),\n        Arguments.of(4, 0, new byte[]{0x00, 0x00, 0x00, 0x00})\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource(\"argumentsForTestContains\")\n  void testContains(final String cidrBlock, final String address, final boolean expectContains) {\n    assertEquals(expectContains, new InetAddressRange(cidrBlock).contains(address));\n  }\n\n  private static Stream<Arguments> argumentsForTestContains() {\n    return Stream.of(\n        Arguments.of(\"192.168.0.0/24\", \"192.168.0.1\", true),\n        Arguments.of(\"192.168.0.0/24\", \"192.168.1.0\", false),\n        Arguments.of(\"192.168.0.1/32\", \"192.168.0.1\", true),\n        Arguments.of(\"192.168.0.1/32\", \"192.168.0.0\", false),\n        Arguments.of(\"2001:db8::/48\", \"2001:db8:0:0:0:0:0:0\", true),\n        Arguments.of(\"2001:db8::/48\", \"2001:db8:0:ffff:ffff:ffff:ffff:ffff\", true),\n        Arguments.of(\"2001:db8::/48\", \"2001:db6:0:ffff:ffff:ffff:ffff:ffff\", false)\n    );\n  }\n\n  @Test\n  void testContainsMismatchedAddressType() {\n    assertFalse(new InetAddressRange(\"192.168.0.0/24\").contains(\"2001:db8:0:0:0:0:0:0\"));\n    assertFalse(new InetAddressRange(\"2001:db8::/48\").contains(\"192.168.0.1\"));\n  }\n\n  @Test\n  void testDeserialize() throws JsonProcessingException {\n    final TypeReference<Map<String, InetAddressRange>> typeReference = new TypeReference<>() {};\n\n    assertEquals(Map.of(\"range\", new InetAddressRange(\"192.168.0.0/24\")),\n        new ObjectMapper().readValue(\"{\\\"range\\\":\\\"192.168.0.0/24\\\"}\", typeReference));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/KEMPublicKeyAdapterTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.JsonMappingException;\nimport com.fasterxml.jackson.databind.annotation.JsonDeserialize;\nimport com.fasterxml.jackson.databind.annotation.JsonSerialize;\nimport java.util.Base64;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.signal.libsignal.protocol.kem.KEMKeyPair;\nimport org.signal.libsignal.protocol.kem.KEMKeyType;\nimport org.signal.libsignal.protocol.kem.KEMPublicKey;\n\nclass KEMPublicKeyAdapterTest {\n\n  private static final String JSON_TEMPLATE = \"\"\"\n      {\n        \"publicKey\": %s\n      }\n      \"\"\";\n\n  private static final KEMPublicKey KEM_PUBLIC_KEY = KEMKeyPair.generate(KEMKeyType.KYBER_1024).getPublicKey();\n\n  private record KEMPublicKeyCarrier(@JsonSerialize(using = KEMPublicKeyAdapter.Serializer.class)\n                                    @JsonDeserialize(using = KEMPublicKeyAdapter.Deserializer.class)\n                                    KEMPublicKey publicKey) {\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void deserialize(final String json, @Nullable final KEMPublicKey expectedPublicKey) throws JsonProcessingException {\n    final KEMPublicKeyCarrier publicKeyCarrier = SystemMapper.jsonMapper().readValue(json, KEMPublicKeyAdapterTest.KEMPublicKeyCarrier.class);\n\n    assertEquals(expectedPublicKey, publicKeyCarrier.publicKey());\n  }\n\n  private static Stream<Arguments> deserialize() {\n    return Stream.of(\n        Arguments.of(String.format(JSON_TEMPLATE, \"null\"), null),\n        Arguments.of(String.format(JSON_TEMPLATE, \"\\\"\\\"\"), null),\n        Arguments.of(String.format(JSON_TEMPLATE,\n            \"\\\"\" + Base64.getEncoder().encodeToString(KEM_PUBLIC_KEY.serialize()) + \"\\\"\"), KEM_PUBLIC_KEY)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void deserializeInvalidKey(final String json) {\n    assertThrows(JsonMappingException.class,\n        () -> SystemMapper.jsonMapper().readValue(json, KEMPublicKeyAdapterTest.KEMPublicKeyCarrier.class));\n  }\n\n  private static Stream<String> deserializeInvalidKey() {\n    return Stream.of(\n        String.format(JSON_TEMPLATE, \"\\\"\" + Base64.getEncoder().encodeToString(new byte[12]) + \"\\\"\"),\n        String.format(JSON_TEMPLATE, \"\\\"This is not a legal base64-encoded string\\\"\")\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/LocaleTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\n\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Locale.LanguageRange;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nclass LocaleTest {\n\n  private static final Set<String> SUPPORTED_LOCALES = Set.of(\"es\", \"en\", \"zh\", \"zh-HK\");\n\n  @ParameterizedTest\n  @MethodSource\n  void testFindBestLocale(@Nullable final String languageRange, @Nullable final String expectedLocale) {\n\n    final List<LanguageRange> languageRanges = Optional.ofNullable(languageRange)\n        .map(LanguageRange::parse)\n        .orElse(Collections.emptyList());\n\n    assertEquals(Optional.ofNullable(expectedLocale), Util.findBestLocale(languageRanges, SUPPORTED_LOCALES));\n  }\n\n  static Stream<Arguments> testFindBestLocale() {\n    return Stream.of(\n        // languageRange, expectedLocale\n        Arguments.of(\"en-US, fr\", \"en\"),\n        Arguments.of(\"es-ES\", \"es\"),\n        Arguments.of(\"zh-Hant-HK, zh-HK\", \"zh\"),\n        // zh-HK is supported, but Locale#lookup truncates from the end, per RFC-4647\n        Arguments.of(\"zh-Hant-HK\", \"zh\"),\n        Arguments.of(\"zh-HK\", \"zh-HK\"),\n        Arguments.of(\"de\", null),\n        Arguments.of(null, null)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/MockUtils.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doNothing;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.internal.exceptions.Reporter.noMoreInteractionsWanted;\nimport static org.mockito.internal.invocation.InvocationsFinder.findFirstUnverified;\n\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.function.Predicate;\nimport org.mockito.Mockito;\nimport org.mockito.invocation.Invocation;\nimport org.mockito.invocation.MatchableInvocation;\nimport org.mockito.verification.VerificationMode;\nimport org.whispersystems.textsecuregcm.configuration.secrets.SecretBytes;\nimport org.whispersystems.textsecuregcm.controllers.RateLimitExceededException;\nimport org.whispersystems.textsecuregcm.limits.RateLimiter;\nimport org.whispersystems.textsecuregcm.limits.RateLimiters;\nimport reactor.core.publisher.Mono;\n\npublic final class MockUtils {\n\n  private MockUtils() {\n    // utility class\n  }\n\n  @FunctionalInterface\n  public interface MockInitializer<T> {\n\n    void init(T mock) throws Exception;\n  }\n\n  public static <T> T buildMock(final Class<T> clazz, final MockInitializer<T> initializer) throws RuntimeException {\n    final T mock = Mockito.mock(clazz);\n    try {\n      initializer.init(mock);\n    } catch (Exception e) {\n      throw new RuntimeException(e);\n    }\n    return mock;\n  }\n\n  public static MutableClock mutableClock(final long timeMillis) {\n    return new MutableClock(timeMillis);\n  }\n\n  public static void updateRateLimiterResponseToAllow(\n      final RateLimiter mockRateLimiter,\n      final String input) {\n    try {\n      doNothing().when(mockRateLimiter).validate(eq(input));\n      doReturn(CompletableFuture.completedFuture(null)).when(mockRateLimiter).validateAsync(eq(input));\n      doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(mockRateLimiter).validateReactive(eq(input));\n    } catch (final RateLimitExceededException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public static void updateRateLimiterResponseToAllow(\n      final RateLimiter mockRateLimiter,\n      final UUID input) {\n    try {\n      doNothing().when(mockRateLimiter).validate(eq(input));\n      doReturn(CompletableFuture.completedFuture(null)).when(mockRateLimiter).validateAsync(eq(input));\n      doReturn(Mono.fromFuture(CompletableFuture.completedFuture(null))).when(mockRateLimiter).validateReactive(eq(input));\n    } catch (final RateLimitExceededException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public static void updateRateLimiterResponseToAllow(\n      final RateLimiters rateLimitersMock,\n      final RateLimiters.For handle,\n      final String input) {\n    final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class);\n    doReturn(mockRateLimiter).when(rateLimitersMock).forDescriptor(eq(handle));\n    updateRateLimiterResponseToAllow(mockRateLimiter, input);\n  }\n\n  public static void updateRateLimiterResponseToAllow(\n      final RateLimiters rateLimitersMock,\n      final RateLimiters.For handle,\n      final UUID input) {\n    final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class);\n    doReturn(mockRateLimiter).when(rateLimitersMock).forDescriptor(eq(handle));\n    updateRateLimiterResponseToAllow(mockRateLimiter, input);\n  }\n\n  public static Duration updateRateLimiterResponseToFail(\n      final RateLimiter mockRateLimiter,\n      final String input,\n      final Duration retryAfter) {\n    try {\n      final RateLimitExceededException exception = new RateLimitExceededException(retryAfter);\n      doThrow(exception).when(mockRateLimiter).validate(eq(input));\n      doReturn(CompletableFuture.failedFuture(exception)).when(mockRateLimiter).validateAsync(eq(input));\n      doReturn(Mono.fromFuture(CompletableFuture.failedFuture(exception))).when(mockRateLimiter).validateReactive(eq(input));\n      return retryAfter;\n    } catch (final RateLimitExceededException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public static Duration updateRateLimiterResponseToFail(\n      final RateLimiter mockRateLimiter,\n      final UUID input,\n      final Duration retryAfter) {\n    try {\n      final RateLimitExceededException exception = new RateLimitExceededException(retryAfter);\n      doThrow(exception).when(mockRateLimiter).validate(eq(input));\n      doReturn(CompletableFuture.failedFuture(exception)).when(mockRateLimiter).validateAsync(eq(input));\n      doReturn(Mono.fromFuture(CompletableFuture.failedFuture(exception))).when(mockRateLimiter).validateReactive(eq(input));\n      return retryAfter;\n    } catch (final RateLimitExceededException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public static Duration updateRateLimiterResponseToFail(\n      final RateLimiters rateLimitersMock,\n      final RateLimiters.For handle,\n      final String input,\n      final Duration retryAfter) {\n    final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class);\n    doReturn(mockRateLimiter).when(rateLimitersMock).forDescriptor(eq(handle));\n    return updateRateLimiterResponseToFail(mockRateLimiter, input, retryAfter);\n  }\n\n  public static Duration updateRateLimiterResponseToFail(\n      final RateLimiters rateLimitersMock,\n      final RateLimiters.For handle,\n      final UUID input,\n      final Duration retryAfter) {\n    final RateLimiter mockRateLimiter = Mockito.mock(RateLimiter.class);\n    doReturn(mockRateLimiter).when(rateLimitersMock).forDescriptor(eq(handle));\n    return updateRateLimiterResponseToFail(mockRateLimiter, input, retryAfter);\n  }\n\n  public static SecretBytes randomSecretBytes(final int size) {\n    return new SecretBytes(TestRandomUtil.nextBytes(size));\n  }\n\n  public static SecretBytes secretBytesOf(final int... byteVals) {\n    final byte[] bytes = new byte[byteVals.length];\n    for (int i = 0; i < byteVals.length; i++) {\n      bytes[i] = (byte) byteVals[i];\n    }\n    return new SecretBytes(bytes);\n  }\n\n  /**\n   * modeled after {@link org.mockito.Mockito#only()}, verifies that the matched invocation is the only invocation of\n   * this method\n   */\n  public static VerificationMode exactly() {\n    return exactly(1);\n  }\n\n  /**\n   * a combination of {@link #exactly()} and {@link org.mockito.Mockito#times(int)}, verifies that\n   * there are exactly N invocations of this method, and all of them match the given specification\n   */\n  public static VerificationMode exactly(int wantedCount) {\n    return data -> {\n      MatchableInvocation target = data.getTarget();\n      final List<Invocation> allInvocations = data.getAllInvocations();\n      List<Invocation> otherInvocations = allInvocations.stream()\n          .filter(target::hasSameMethod)\n          .filter(Predicate.not(target::matches))\n          .toList();\n\n      if (!otherInvocations.isEmpty()) {\n        Invocation unverified = findFirstUnverified(otherInvocations);\n        throw noMoreInteractionsWanted(unverified, (List) allInvocations);\n      }\n      Mockito.times(wantedCount).verify(data);\n    };\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/MutableClock.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicReference;\n\npublic class MutableClock extends Clock {\n\n  private final AtomicReference<Clock> delegate;\n\n\n  public MutableClock(final long timeMillis) {\n    this(fixedTimeMillis(timeMillis));\n  }\n\n  public MutableClock(final Clock clock) {\n    this.delegate = new AtomicReference<>(clock);\n  }\n\n  public MutableClock() {\n    this(Clock.systemUTC());\n  }\n\n  public MutableClock setTimeInstant(final Instant instant) {\n    delegate.set(Clock.fixed(instant, ZoneId.of(\"Etc/UTC\")));\n    return this;\n  }\n\n  public MutableClock setTimeMillis(final long timeMillis) {\n    delegate.set(fixedTimeMillis(timeMillis));\n    return this;\n  }\n\n  public MutableClock incrementMillis(final long incrementMillis) {\n    return increment(incrementMillis, TimeUnit.MILLISECONDS);\n  }\n\n  public MutableClock incrementSeconds(final long incrementSeconds) {\n    return increment(incrementSeconds, TimeUnit.SECONDS);\n  }\n\n  public MutableClock increment(final long increment, final TimeUnit timeUnit) {\n    final long current = delegate.get().instant().toEpochMilli();\n    delegate.set(fixedTimeMillis(current + timeUnit.toMillis(increment)));\n    return this;\n  }\n\n  @Override\n  public ZoneId getZone() {\n    return delegate.get().getZone();\n  }\n\n  @Override\n  public Clock withZone(final ZoneId zone) {\n    return delegate.get().withZone(zone);\n  }\n\n  @Override\n  public Instant instant() {\n    return delegate.get().instant();\n  }\n\n  private static Clock fixedTimeMillis(final long timeMillis) {\n    return Clock.fixed(Instant.ofEpochMilli(timeMillis), ZoneId.of(\"Etc/UTC\"));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/RedisClusterUtilTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertArrayEquals;\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport io.lettuce.core.cluster.SlotHash;\nimport io.lettuce.core.cluster.event.ClusterTopologyChangedEvent;\nimport io.lettuce.core.cluster.models.partitions.RedisClusterNode;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\n\nclass RedisClusterUtilTest {\n\n  @Test\n  void testGetMinimalHashTag() {\n    for (int slot = 0; slot < SlotHash.SLOT_COUNT; slot++) {\n      assertEquals(slot, SlotHash.getSlot(RedisClusterUtil.getMinimalHashTag(slot)));\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getChangedSlots(final ClusterTopologyChangedEvent event, final boolean[] expectedSlotsChanged) {\n    assertArrayEquals(expectedSlotsChanged, RedisClusterUtil.getChangedSlots(event));\n  }\n\n  private static List<Arguments> getChangedSlots() {\n    final List<Arguments> arguments = new ArrayList<>();\n\n    // Slot moved from one node to another\n    {\n      final String firstNodeId = UUID.randomUUID().toString();\n      final String secondNodeId = UUID.randomUUID().toString();\n\n      final RedisClusterNode firstNodeBefore = mock(RedisClusterNode.class);\n      when(firstNodeBefore.getNodeId()).thenReturn(firstNodeId);\n      when(firstNodeBefore.getSlots()).thenReturn(getSlotRange(0, 8192));\n\n      final RedisClusterNode secondNodeBefore = mock(RedisClusterNode.class);\n      when(secondNodeBefore.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeBefore.getSlots()).thenReturn(getSlotRange(8192, 16384));\n\n      final RedisClusterNode firstNodeAfter = mock(RedisClusterNode.class);\n      when(firstNodeAfter.getNodeId()).thenReturn(firstNodeId);\n      when(firstNodeAfter.getSlots()).thenReturn(getSlotRange(0, 8191));\n\n      final RedisClusterNode secondNodeAfter = mock(RedisClusterNode.class);\n      when(secondNodeAfter.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeAfter.getSlots()).thenReturn(getSlotRange(8191, 16384));\n\n      final ClusterTopologyChangedEvent clusterTopologyChangedEvent = new ClusterTopologyChangedEvent(\n          List.of(firstNodeBefore, secondNodeBefore),\n          List.of(firstNodeAfter, secondNodeAfter));\n\n      final boolean[] slotsChanged = new boolean[SlotHash.SLOT_COUNT];\n      slotsChanged[8191] = true;\n\n      arguments.add(Arguments.of(clusterTopologyChangedEvent, slotsChanged));\n    }\n\n    // New node added to cluster\n    {\n      final String firstNodeId = UUID.randomUUID().toString();\n      final String secondNodeId = UUID.randomUUID().toString();\n\n      final RedisClusterNode firstNodeBefore = mock(RedisClusterNode.class);\n      when(firstNodeBefore.getNodeId()).thenReturn(firstNodeId);\n      when(firstNodeBefore.getSlots()).thenReturn(getSlotRange(0, 8192));\n\n      final RedisClusterNode secondNodeBefore = mock(RedisClusterNode.class);\n      when(secondNodeBefore.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeBefore.getSlots()).thenReturn(getSlotRange(8192, 16384));\n\n      final RedisClusterNode firstNodeAfter = mock(RedisClusterNode.class);\n      when(firstNodeAfter.getNodeId()).thenReturn(firstNodeId);\n      when(firstNodeAfter.getSlots()).thenReturn(getSlotRange(0, 8192));\n\n      final RedisClusterNode secondNodeAfter = mock(RedisClusterNode.class);\n      when(secondNodeAfter.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeAfter.getSlots()).thenReturn(getSlotRange(8192, 12288));\n\n      final RedisClusterNode thirdNodeAfter = mock(RedisClusterNode.class);\n      when(thirdNodeAfter.getNodeId()).thenReturn(UUID.randomUUID().toString());\n      when(thirdNodeAfter.getSlots()).thenReturn(getSlotRange(12288, 16384));\n\n      final ClusterTopologyChangedEvent clusterTopologyChangedEvent = new ClusterTopologyChangedEvent(\n          List.of(firstNodeBefore, secondNodeBefore),\n          List.of(firstNodeAfter, secondNodeAfter, thirdNodeAfter));\n\n      final boolean[] slotsChanged = new boolean[SlotHash.SLOT_COUNT];\n\n      for (int slot = 12288; slot < 16384; slot++) {\n        slotsChanged[slot] = true;\n      }\n\n      arguments.add(Arguments.of(clusterTopologyChangedEvent, slotsChanged));\n    }\n\n    // Node removed from cluster\n    {\n      final String firstNodeId = UUID.randomUUID().toString();\n      final String secondNodeId = UUID.randomUUID().toString();\n\n      final RedisClusterNode firstNodeBefore = mock(RedisClusterNode.class);\n      when(firstNodeBefore.getNodeId()).thenReturn(firstNodeId);\n      when(firstNodeBefore.getSlots()).thenReturn(getSlotRange(0, 8192));\n\n      final RedisClusterNode secondNodeBefore = mock(RedisClusterNode.class);\n      when(secondNodeBefore.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeBefore.getSlots()).thenReturn(getSlotRange(8192, 12288));\n\n      final RedisClusterNode thirdNodeBefore = mock(RedisClusterNode.class);\n      when(thirdNodeBefore.getNodeId()).thenReturn(UUID.randomUUID().toString());\n      when(thirdNodeBefore.getSlots()).thenReturn(getSlotRange(12288, 16384));\n\n      final RedisClusterNode firstNodeAfter = mock(RedisClusterNode.class);\n      when(firstNodeAfter.getNodeId()).thenReturn(firstNodeId);\n      when(firstNodeAfter.getSlots()).thenReturn(getSlotRange(0, 8192));\n\n      final RedisClusterNode secondNodeAfter = mock(RedisClusterNode.class);\n      when(secondNodeAfter.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeAfter.getSlots()).thenReturn(getSlotRange(8192, 16384));\n\n      final ClusterTopologyChangedEvent clusterTopologyChangedEvent = new ClusterTopologyChangedEvent(\n          List.of(firstNodeBefore, secondNodeBefore, thirdNodeBefore),\n          List.of(firstNodeAfter, secondNodeAfter));\n\n      final boolean[] slotsChanged = new boolean[SlotHash.SLOT_COUNT];\n\n      for (int slot = 12288; slot < 16384; slot++) {\n        slotsChanged[slot] = true;\n      }\n\n      arguments.add(Arguments.of(clusterTopologyChangedEvent, slotsChanged));\n    }\n\n    // Node added, node removed, and slot moved\n    // Node removed from cluster\n    {\n      final String secondNodeId = UUID.randomUUID().toString();\n      final String thirdNodeId = UUID.randomUUID().toString();\n\n      final RedisClusterNode firstNodeBefore = mock(RedisClusterNode.class);\n      when(firstNodeBefore.getNodeId()).thenReturn(UUID.randomUUID().toString());\n      when(firstNodeBefore.getSlots()).thenReturn(getSlotRange(0, 1));\n\n      final RedisClusterNode secondNodeBefore = mock(RedisClusterNode.class);\n      when(secondNodeBefore.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeBefore.getSlots()).thenReturn(getSlotRange(1, 8192));\n\n      final RedisClusterNode thirdNodeBefore = mock(RedisClusterNode.class);\n      when(thirdNodeBefore.getNodeId()).thenReturn(thirdNodeId);\n      when(thirdNodeBefore.getSlots()).thenReturn(getSlotRange(8192, 16384));\n\n      final RedisClusterNode secondNodeAfter = mock(RedisClusterNode.class);\n      when(secondNodeAfter.getNodeId()).thenReturn(secondNodeId);\n      when(secondNodeAfter.getSlots()).thenReturn(getSlotRange(0, 8191));\n\n      final RedisClusterNode thirdNodeAfter = mock(RedisClusterNode.class);\n      when(thirdNodeAfter.getNodeId()).thenReturn(thirdNodeId);\n      when(thirdNodeAfter.getSlots()).thenReturn(getSlotRange(8191, 16383));\n\n      final RedisClusterNode fourthNodeAfter = mock(RedisClusterNode.class);\n      when(fourthNodeAfter.getNodeId()).thenReturn(UUID.randomUUID().toString());\n      when(fourthNodeAfter.getSlots()).thenReturn(getSlotRange(16383, 16384));\n\n      final ClusterTopologyChangedEvent clusterTopologyChangedEvent = new ClusterTopologyChangedEvent(\n          List.of(firstNodeBefore, secondNodeBefore, thirdNodeBefore),\n          List.of(secondNodeAfter, thirdNodeAfter, fourthNodeAfter));\n\n      final boolean[] slotsChanged = new boolean[SlotHash.SLOT_COUNT];\n      slotsChanged[0] = true;\n      slotsChanged[8191] = true;\n      slotsChanged[16383] = true;\n\n      arguments.add(Arguments.of(clusterTopologyChangedEvent, slotsChanged));\n    }\n\n    return arguments;\n  }\n\n  private static List<Integer> getSlotRange(final int startInclusive, final int endExclusive) {\n    final List<Integer> slots = new ArrayList<>(endExclusive - startInclusive);\n\n    for (int i = startInclusive; i < endExclusive; i++) {\n      slots.add(i);\n    }\n\n    return slots;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/SystemMapperTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.annotation.JsonFilter;\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.databind.ObjectWriter;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass SystemMapperTest {\n\n  private static final ObjectMapper MAPPER = SystemMapper.configureMapper(new ObjectMapper());\n\n  private static final String JSON_NO_FIELD = \"\"\"\n        {}\n        \"\"\".trim();\n\n  private static final String JSON_NULL_FIELD = \"\"\"\n        {\"name\":null}\n        \"\"\".trim();\n\n  private static final String JSON_WITH_FIELD = \"\"\"\n        {\"name\":\"value\"}\n        \"\"\".trim();\n\n  interface Data {\n    Optional<String> name();\n  }\n\n  @JsonInclude(JsonInclude.Include.NON_ABSENT)\n  public record DataRecord(Optional<String> name) implements Data {\n  }\n\n  public static class DataClass implements Data {\n\n    @JsonProperty\n    private Optional<String> name = Optional.empty();\n\n    public DataClass() {\n    }\n\n    public DataClass(final Optional<String> name) {\n      this.name = name;\n    }\n\n    @Override\n    public Optional<String> name() {\n      return name;\n    }\n  }\n\n  @JsonInclude(JsonInclude.Include.NON_ABSENT)\n  public static class DataClass2 extends DataClass {\n\n    public DataClass2(final Optional<String> name) {\n      super(name);\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(classes = {DataClass.class, DataRecord.class})\n  public void testOptionalField(final Class<? extends Data> clazz) throws Exception {\n    assertTrue(MAPPER.readValue(JSON_NO_FIELD, clazz).name().isEmpty());\n    assertTrue(MAPPER.readValue(JSON_NULL_FIELD, clazz).name().isEmpty());\n    assertEquals(\"value\", MAPPER.readValue(JSON_WITH_FIELD, clazz).name().orElseThrow());\n  }\n\n  @ParameterizedTest\n  @MethodSource(\"provideStringsForIsBlank\")\n  public void testSerialization(final Data data, final String expectedJson) throws Exception {\n    assertEquals(expectedJson, MAPPER.writeValueAsString(data));\n  }\n\n  private static Stream<Arguments> provideStringsForIsBlank() {\n    return Stream.of(\n        Arguments.of(new DataClass(Optional.of(\"value\")), JSON_WITH_FIELD),\n        Arguments.of(new DataClass(Optional.empty()), JSON_NULL_FIELD),\n        Arguments.of(new DataClass(null), JSON_NULL_FIELD),\n        Arguments.of(new DataClass2(Optional.of(\"value\")), JSON_WITH_FIELD),\n        Arguments.of(new DataClass2(Optional.of(\"value\")), JSON_WITH_FIELD),\n        Arguments.of(new DataClass2(Optional.empty()), JSON_NO_FIELD),\n        Arguments.of(new DataRecord(Optional.of(\"value\")), JSON_WITH_FIELD),\n        Arguments.of(new DataRecord(Optional.empty()), JSON_NO_FIELD),\n        Arguments.of(new DataRecord(null), JSON_NO_FIELD)\n    );\n  }\n\n  public record NotAnnotatedWithJsonFilter(String data) {\n  }\n\n  @JsonFilter(\"AnnotatedWithJsonFilter\")\n  public record AnnotatedWithJsonFilter(String data, String excluded) {\n  }\n\n  @Test\n  public void testFiltering() throws Exception {\n    assertThrows(IllegalStateException.class, () -> SystemMapper.excludingField(NotAnnotatedWithJsonFilter.class, List.of(\"data\")));\n    final ObjectWriter writer = SystemMapper.jsonMapper()\n        .writer(SystemMapper.excludingField(AnnotatedWithJsonFilter.class, List.of(\"excluded\")));\n    final AnnotatedWithJsonFilter obj = new AnnotatedWithJsonFilter(\"valData\", \"valExcluded\");\n    final String json = writer.writeValueAsString(obj);\n    final Map<?, ?> serializedFields = SystemMapper.jsonMapper().readValue(json, Map.class);\n    assertEquals(Map.of(\"data\", \"valData\"), serializedFields);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/TestClock.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.Optional;\n\n/**\n * Clock class specialized for testing.\n * <p>\n * This clock can be pinned to a particular instant or can provide the \"normal\" time.\n * <p>\n * Unlike normal clocks it can be dynamically pinned and unpinned to help with testing.\n * It should not be used in production.\n */\npublic class TestClock extends Clock {\n\n  private volatile Optional<Instant> pinnedInstant;\n  private final ZoneId zoneId;\n\n  private TestClock(Optional<Instant> maybePinned, ZoneId id) {\n    this.pinnedInstant = maybePinned;\n    this.zoneId = id;\n  }\n\n  /**\n   * Instantiate a test clock that returns the \"real\" time.\n   * <p>\n   * The clock can later be pinned to an instant if desired.\n   *\n   * @return unpinned test clock.\n   */\n  public static TestClock now() {\n    return new TestClock(Optional.empty(), ZoneId.of(\"UTC\"));\n  }\n\n  /**\n   * Instantiate a test clock pinned to a particular instant.\n   * <p>\n   * The clock can later be pinned to a different instant or unpinned if desired.\n   * <p>\n   * Unlike the fixed constructor no time zone is required (it defaults to UTC).\n   *\n   * @param instant the instant to pin the clock to.\n   * @return test clock pinned to the given instant.\n   */\n  public static TestClock pinned(Instant instant) {\n    return new TestClock(Optional.of(instant), ZoneId.of(\"UTC\"));\n  }\n\n  /**\n   * Pin this test clock to the given instance.\n   * <p>\n   * This modifies the existing clock in-place.\n   *\n   * @param instant the instant to pin the clock to.\n   */\n  public void pin(Instant instant) {\n    this.pinnedInstant = Optional.of(instant);\n  }\n\n  /**\n   * Unpin this test clock so it will being returning the \"real\" time.\n   * <p>\n   * This modifies the existing clock in-place.\n   */\n  public void unpin() {\n    this.pinnedInstant = Optional.empty();\n  }\n\n\n  @Override\n  public TestClock withZone(ZoneId id) {\n    return new TestClock(pinnedInstant, id);\n  }\n\n  @Override\n  public ZoneId getZone() {\n    return zoneId;\n  }\n\n  @Override\n  public Instant instant() {\n    return pinnedInstant.orElseGet(Instant::now);\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/TestRandomUtil.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport java.util.concurrent.ThreadLocalRandom;\n\npublic class TestRandomUtil {\n  private TestRandomUtil() {}\n\n  public static byte[] nextBytes(int numBytes) {\n    final byte[] bytes = new byte[numBytes];\n    ThreadLocalRandom.current().nextBytes(bytes);\n    return bytes;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/TestRemoteAddressFilterProvider.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport jakarta.annotation.Priority;\nimport jakarta.ws.rs.Priorities;\nimport jakarta.ws.rs.container.ContainerRequestContext;\nimport jakarta.ws.rs.container.ContainerRequestFilter;\nimport java.io.IOException;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\n\n/**\n * Adds the request property set by {@link RemoteAddressFilter} for test scenarios that depend on it, but do not have\n * access to a full {@code HttpServletRequest} pipline\n */\n@Priority(Priorities.AUTHENTICATION - 1) // highest priority, since other filters might depend on it\npublic class TestRemoteAddressFilterProvider implements ContainerRequestFilter {\n\n  private final String ip;\n\n  public TestRemoteAddressFilterProvider(String ip) {\n    this.ip = ip;\n  }\n\n  @Override\n  public void filter(final ContainerRequestContext requestContext) throws IOException {\n    requestContext.setProperty(RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME, ip);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/UtilTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.google.i18n.phonenumbers.NumberParseException;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport com.google.i18n.phonenumbers.Phonenumber;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.junit.jupiter.params.provider.MethodSource;\n\nclass UtilTest {\n  // libphonenumber 8.13.50 and on generate new-format numbers for Benin\n  private static final String NEW_FORMAT_BENIN_E164_STRING = PhoneNumberUtil.getInstance()\n      .format(PhoneNumberUtil.getInstance().getExampleNumber(\"BJ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n  private static final String OLD_FORMAT_BENIN_E164_STRING = NEW_FORMAT_BENIN_E164_STRING.replaceFirst(\"01\", \"\");\n\n  @ParameterizedTest\n  @MethodSource\n  void getAlternateForms(final String phoneNumber, final List<String> expectedAlternateForms) {\n    assertEquals(expectedAlternateForms, Util.getAlternateForms(phoneNumber));\n  }\n\n  static List<Arguments> getAlternateForms() {\n    final String usE164 = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    return List.of(\n        Arguments.of(usE164, List.of(usE164)),\n        Arguments.of(NEW_FORMAT_BENIN_E164_STRING, List.of(NEW_FORMAT_BENIN_E164_STRING, OLD_FORMAT_BENIN_E164_STRING)),\n        Arguments.of(OLD_FORMAT_BENIN_E164_STRING, List.of(OLD_FORMAT_BENIN_E164_STRING, NEW_FORMAT_BENIN_E164_STRING))\n    );\n  }\n\n  @Test\n  void getCanonicalNumber() {\n    final String usE164 = PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n    assertEquals(Optional.of(usE164), Util.getCanonicalNumber(List.of(usE164)));\n\n    final String newFormatBeninE164 = PhoneNumberUtil.getInstance()\n        .format(PhoneNumberUtil.getInstance().getExampleNumber(\"BJ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n    final String oldFormatBeninE164 = newFormatBeninE164.replaceFirst(\"01\", \"\");\n    assertEquals(Optional.of(newFormatBeninE164), Util.getCanonicalNumber(List.of(oldFormatBeninE164, newFormatBeninE164)));\n\n    assertEquals(Optional.empty(), Util.getCanonicalNumber(List.of()));\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"0, 1, false\",\n      \"123456789, 1, true\",\n      \"123456789, 123, true\",\n      \"123456789, 456, false\",\n  })\n  void startsWithDecimal(final long number, final long prefix, final boolean expectStartsWithPrefix) {\n    assertEquals(expectStartsWithPrefix, Util.startsWithDecimal(number, prefix));\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void isOldFormatBeninPhoneNumber4(final Phonenumber.PhoneNumber beninNumber, final boolean isOldFormatBeninNumber) {\n    if (isOldFormatBeninNumber) {\n      assertTrue(Util.isOldFormatBeninPhoneNumber(beninNumber));\n    } else {\n      assertFalse(Util.isOldFormatBeninPhoneNumber(beninNumber));\n    }\n  }\n\n  private static Stream<Arguments> isOldFormatBeninPhoneNumber4() throws NumberParseException {\n    final Phonenumber.PhoneNumber oldFormatBeninE164 = PhoneNumberUtil.getInstance().parse(OLD_FORMAT_BENIN_E164_STRING, null);\n    final Phonenumber.PhoneNumber newFormatBeninE164 = PhoneNumberUtil.getInstance().parse(NEW_FORMAT_BENIN_E164_STRING, null);\n\n    return Stream.of(\n        Arguments.of(oldFormatBeninE164, true),\n        Arguments.of(newFormatBeninE164, false),\n        Arguments.of(PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), false)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void normalizeBeninPhoneNumber(final Phonenumber.PhoneNumber beninNumber, final Phonenumber.PhoneNumber expectedBeninNumber, @Nullable Class<? extends Throwable> exception)\n      throws Exception {\n    if (exception == null) {\n      assertTrue(expectedBeninNumber.exactlySameAs(Util.canonicalizePhoneNumber(beninNumber)));\n    } else {\n      assertThrows(exception, () -> Util.canonicalizePhoneNumber(beninNumber));\n    }\n  }\n\n  private static Stream<Arguments> normalizeBeninPhoneNumber() throws NumberParseException {\n    final Phonenumber.PhoneNumber oldFormatBeninPhoneNumber = PhoneNumberUtil.getInstance().parse(OLD_FORMAT_BENIN_E164_STRING, null);\n    final Phonenumber.PhoneNumber newFormatBeninPhoneNumber = PhoneNumberUtil.getInstance().parse(NEW_FORMAT_BENIN_E164_STRING, null);\n    final Phonenumber.PhoneNumber usPhoneNumber = PhoneNumberUtil.getInstance().getExampleNumber(\"US\");\n    return Stream.of(\n        Arguments.of(newFormatBeninPhoneNumber, newFormatBeninPhoneNumber, null),\n        Arguments.of(oldFormatBeninPhoneNumber, null, ObsoletePhoneNumberFormatException.class),\n        Arguments.of(usPhoneNumber, usPhoneNumber, null)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/ValidNumberTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\n\nclass ValidNumberTest {\n\n  @ParameterizedTest\n  @ValueSource(strings = {\n      \"+447700900111\",\n      \"+14151231234\",\n      \"+71234567890\",\n      \"+447535742222\",\n      \"+4915174108888\",\n      \"+2250707312345\",\n      \"+298123456\",\n      \"+299123456\",\n      \"+376123456\",\n      \"+68512345\",\n      \"+689123456\",\n      \"+80011111111\"})\n  void requireNormalizedNumber(final String number) {\n    assertDoesNotThrow(() -> Util.requireNormalizedNumber(number));\n  }\n\n  @Test\n  void requireNormalizedNumberNull() {\n    assertThrows(ImpossiblePhoneNumberException.class, () -> Util.requireNormalizedNumber(null));\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\n      \"Definitely not a phone number at all\",\n      \"+141512312341\",\n      \"+712345678901\",\n      \"+4475357422221\",\n      \"+491517410888811111\",\n      \"71234567890\",\n      \"001447535742222\",\n      \"+1415123123a\"\n  })\n  void requireNormalizedNumberImpossibleNumber(final String number) {\n    assertThrows(ImpossiblePhoneNumberException.class, () -> Util.requireNormalizedNumber(number));\n  }\n\n  @ParameterizedTest\n  @ValueSource(strings = {\n      \"+4407700900111\",\n      \"+49493023125000\", // double country code - this e164 is \"possible\"\n      \"+1 415 123 1234\",\n      \"+1 (415) 123-1234\",\n      \"+1 415)123-1234\",\n      \" +14151231234\"})\n  void requireNormalizedNumberNonNormalized(final String number) {\n    assertThrows(NonNormalizedPhoneNumberException.class, () -> Util.requireNormalizedNumber(number));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/VirtualExecutorServiceProviderTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.assertj.core.api.AssertionsForClassTypes.assertThat;\n\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.core.Response;\nimport java.security.Principal;\nimport java.util.ArrayList;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.concurrent.BlockingQueue;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport org.glassfish.jersey.server.ManagedAsync;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass VirtualExecutorServiceProviderTest {\n\n  private final TestController testController = new TestController();\n  private final ResourceExtension resources = ResourceExtension.builder()\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addProvider(new VirtualExecutorServiceProvider( \"virtual-thread-\", 2))\n      .addResource(testController)\n      .build();\n\n  @AfterEach\n  void setUp() {\n    testController.release();\n  }\n\n  @Test\n  public void testManagedAsyncThread() {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/test/managed-async\")\n        .request()\n        .get();\n    String threadName = response.readEntity(String.class);\n    assertThat(threadName).startsWith(\"virtual-thread-\");\n  }\n\n  @Test\n  public void testConcurrencyLimit() throws InterruptedException, TimeoutException {\n    final BlockingQueue<Response> responses = new LinkedBlockingQueue<>();\n    final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\n    for (int i = 0; i < 3; i++) {\n      executor.submit(() -> responses.offer(resources.getJerseyTest().target(\"/v1/test/await\").request().get()));\n    }\n    final Response rejectedResponse = responses.poll(10, TimeUnit.SECONDS);\n    assertThat(rejectedResponse).isNotNull().extracting(Response::getStatus).isEqualTo(500);\n\n    assertThat(responses.isEmpty()).isTrue();\n    assertThat(testController.release()).isEqualTo(2);\n    assertThat(responses.poll(1, TimeUnit.SECONDS)).isNotNull().extracting(Response::getStatus).isEqualTo(200);\n    assertThat(responses.poll(1, TimeUnit.SECONDS)).isNotNull().extracting(Response::getStatus).isEqualTo(200);\n  }\n\n  @Test\n  public void testUnmanagedThread() {\n    final Response response = resources.getJerseyTest()\n        .target(\"/v1/test/unmanaged\")\n        .request()\n        .get();\n    String threadName = response.readEntity(String.class);\n    assertThat(threadName).doesNotContain(\"virtual-thread-\");\n  }\n\n  @Path(\"/v1/test\")\n  public static class TestController {\n    private List<CountDownLatch> latches = new ArrayList<>();\n\n    @GET\n    @Path(\"/managed-async\")\n    @ManagedAsync\n    public Response managedAsync() {\n      return Response.ok().entity(Thread.currentThread().getName()).build();\n    }\n\n    @GET\n    @Path(\"/await\")\n    @ManagedAsync\n    public Response await() throws InterruptedException {\n      final CountDownLatch latch = new CountDownLatch(1);\n      synchronized (this) {\n        latches.add(latch);\n      }\n      latch.await();\n      return Response.ok().build();\n    }\n\n    @GET\n    @Path(\"/unmanaged\")\n    public Response unmanaged() {\n      return Response.ok().entity(Thread.currentThread().getName()).build();\n    }\n\n    synchronized int release() {\n      final Iterator<CountDownLatch> iterator = latches.iterator();\n      int count;\n      for (count = 0; iterator.hasNext(); count++) {\n        iterator.next().countDown();\n        iterator.remove();\n      }\n      return count;\n    }\n\n  }\n\n  public static class TestPrincipal implements Principal {\n\n    private final String name;\n\n    private TestPrincipal(String name) {\n      this.name = name;\n    }\n\n    @Override\n    public String getName() {\n      return name;\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/VirtualThreadPinEventMonitorTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.textsecuregcm.util;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Set;\nimport java.util.concurrent.BlockingQueue;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.LinkedBlockingQueue;\nimport java.util.concurrent.TimeUnit;\nimport java.util.stream.IntStream;\nimport jdk.jfr.consumer.RecordedEvent;\nimport org.apache.commons.lang3.tuple.Pair;\nimport org.junit.jupiter.api.Disabled;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.signal.libsignal.protocol.InvalidKeyException;\nimport org.signal.libsignal.protocol.ecc.ECKeyPair;\nimport org.signal.libsignal.protocol.ecc.ECPublicKey;\n\n\npublic class VirtualThreadPinEventMonitorTest {\n\n  private static void nativeMethodCall() {\n    try {\n      new ECPublicKey(ECKeyPair.generate().getPublicKey().serialize());\n    } catch (InvalidKeyException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  @Test\n  @Disabled(\"flaky: no way to ensure the sequencing between the start of the recording stream and emitting the event, event detection is timing based\")\n  public void testPinEventProduced() throws InterruptedException, ExecutionException {\n    final BlockingQueue<RecordedEvent> bq = new LinkedBlockingQueue<>();\n    final ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor();\n    VirtualThreadPinEventMonitor eventMonitor = queueingLogger(exec, Set.of(), bq);\n    eventMonitor.start();\n    // give start a moment to begin the event stream thread\n    Thread.sleep(100);\n\n    final List<? extends Future<?>> futures = IntStream\n        .range(0, 100)\n        .mapToObj(ig -> exec.submit(() -> IntStream\n            .range(0, 100)\n            .forEach(i -> nativeMethodCall())))\n        .toList();\n    for (final Future<?> f : futures) {\n      f.get();\n    }\n    Thread.sleep(1000);\n    eventMonitor.stop();\n    assertThat(bq.isEmpty()).isFalse();\n    exec.shutdown();\n    exec.awaitTermination(1, TimeUnit.MILLISECONDS);\n  }\n\n\n  private static VirtualThreadPinEventMonitor queueingLogger(\n      final ExecutorService exec,\n      final Set<String> allowedMethods,\n      final BlockingQueue<RecordedEvent> bq) {\n    return new VirtualThreadPinEventMonitor(exec,\n        Duration.ofNanos(0),\n        event -> {\n          try {\n            bq.put(event);\n          } catch (InterruptedException e) {\n            throw new RuntimeException(e);\n          }\n        });\n\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/WeightedRandomSelectTest.java",
    "content": "package org.whispersystems.textsecuregcm.util;\n\nimport org.junit.jupiter.api.Test;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\nimport static org.assertj.core.api.Assertions.assertThat;\n\npublic class WeightedRandomSelectTest {\n\n  @Test\n  public void test5050() {\n    final WeightedRandomSelect<String> selector = new WeightedRandomSelect<>(\n        List.of(new Pair<>(\"a\", 1L), new Pair<>(\"b\", 1L)));\n    final Map<String, Long> counts = Stream.generate(selector::select)\n        .limit(1000)\n        .collect(Collectors.groupingBy(s -> s, Collectors.counting()));\n    assertThat(counts.get(\"a\")).isGreaterThan(1);\n    assertThat(counts.get(\"b\")).isGreaterThan(1);\n  }\n\n  @Test\n  public void testAlways() {\n    final WeightedRandomSelect<String> selector = new WeightedRandomSelect<>(\n        List.of(new Pair<>(\"a\", 1L), new Pair<>(\"b\", 0L)));\n    final Map<String, Long> counts = Stream.generate(selector::select)\n        .limit(1000)\n        .collect(Collectors.groupingBy(s -> s, Collectors.counting()));\n    assertThat(counts.get(\"a\")).isEqualTo(1000);\n    assertThat(counts).doesNotContainKey(\"b\");\n  }\n\n  @Test\n  public void testThree() {\n    final WeightedRandomSelect<String> selector = new WeightedRandomSelect<>(\n        List.of(new Pair<>(\"a\", 33L), new Pair<>(\"b\", 33L), new Pair<>(\"c\", 33L)));\n    final Map<String, Long> counts = Stream.generate(selector::select)\n        .limit(1000)\n        .collect(Collectors.groupingBy(s -> s, Collectors.counting()));\n    assertThat(counts.get(\"a\")).isGreaterThan(1);\n    assertThat(counts.get(\"b\")).isGreaterThan(1);\n    assertThat(counts.get(\"c\")).isGreaterThan(1);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/jetty/TestResource.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.jetty;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.File;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.net.MalformedURLException;\nimport java.net.URI;\nimport java.nio.channels.ReadableByteChannel;\nimport java.util.Base64;\nimport org.eclipse.jetty.util.resource.Resource;\n\npublic class TestResource extends Resource {\n\n  private final String name;\n  private final byte[] data;\n\n  private TestResource(String name, byte[] data) {\n    this.name = name;\n    this.data = data;\n  }\n\n  public static Resource fromBase64Mime(String name, String base64) {\n    return new TestResource(name, Base64.getMimeDecoder().decode(base64));\n  }\n\n  @Override\n  public boolean isContainedIn(final Resource r) throws MalformedURLException {\n    return false;\n  }\n\n  @Override\n  public void close() {\n\n  }\n\n  @Override\n  public boolean exists() {\n    return true;\n  }\n\n  @Override\n  public boolean isDirectory() {\n    return false;\n  }\n\n  @Override\n  public long lastModified() {\n    return 0;\n  }\n\n  @Override\n  public long length() {\n    return 0;\n  }\n\n  @Override\n  public URI getURI() {\n    return null;\n  }\n\n  @Override\n  public File getFile() throws IOException {\n    return null;\n  }\n\n  @Override\n  public String getName() {\n    return name;\n  }\n\n  @Override\n  public InputStream getInputStream() throws IOException {\n    return new ByteArrayInputStream(data);\n  }\n\n  @Override\n  public ReadableByteChannel getReadableByteChannel() throws IOException {\n    return null;\n  }\n\n  @Override\n  public boolean delete() throws SecurityException {\n    return false;\n  }\n\n  @Override\n  public boolean renameTo(final Resource dest) throws SecurityException {\n    return false;\n  }\n\n  @Override\n  public String[] list() {\n    return new String[]{name};\n  }\n\n  @Override\n  public Resource addPath(final String path) throws IOException, MalformedURLException {\n    return this;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/logging/LoggingUnhandledExceptionMapperTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.matches;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.reset;\nimport static org.mockito.Mockito.spy;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.jersey.DropwizardResourceConfig;\nimport io.dropwizard.jersey.jackson.JacksonMessageBodyProvider;\nimport io.dropwizard.testing.junit5.DropwizardExtensionsSupport;\nimport io.dropwizard.testing.junit5.ResourceExtension;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.core.Response;\nimport java.io.IOException;\nimport java.nio.ByteBuffer;\nimport java.time.Duration;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionException;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Consumer;\nimport java.util.stream.Stream;\nimport org.eclipse.jetty.websocket.api.RemoteEndpoint;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.UpgradeRequest;\nimport org.eclipse.jetty.websocket.api.WriteCallback;\nimport org.glassfish.jersey.server.ApplicationHandler;\nimport org.glassfish.jersey.server.ResourceConfig;\nimport org.glassfish.jersey.server.ServerProperties;\nimport org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.slf4j.Logger;\nimport org.whispersystems.textsecuregcm.filters.RemoteAddressFilter;\nimport org.whispersystems.textsecuregcm.mappers.CompletionExceptionMapper;\nimport org.whispersystems.textsecuregcm.tests.util.TestPrincipal;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport org.whispersystems.websocket.WebSocketResourceProvider;\nimport org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider;\nimport org.whispersystems.websocket.logging.WebsocketRequestLog;\nimport org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;\nimport org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider;\n\n@ExtendWith(DropwizardExtensionsSupport.class)\nclass LoggingUnhandledExceptionMapperTest {\n\n  private static final Logger logger = mock(Logger.class);\n\n  private static final LoggingUnhandledExceptionMapper exceptionMapper = spy(\n      new LoggingUnhandledExceptionMapper(logger));\n\n  private static final ResourceExtension resources = ResourceExtension.builder()\n      .addProperty(ServerProperties.UNWRAP_COMPLETION_STAGE_IN_WRITER_ENABLE, Boolean.TRUE)\n      .addProvider(new CompletionExceptionMapper())\n      .addProvider(exceptionMapper)\n      .setTestContainerFactory(new GrizzlyWebTestContainerFactory())\n      .addResource(new TestController())\n      .build();\n\n  static ScheduledExecutorService scheduledExecutorService;\n\n  static Stream<Arguments> testExceptionMapper() {\n    return Stream.of(\n        Arguments.of(false, \"/v1/test/no-exception\", \"/v1/test/no-exception\", \"Signal-Android/5.1.2 Android/30\", null),\n        Arguments.of(true, \"/v1/test/unhandled-runtime-exception\", \"/v1/test/unhandled-runtime-exception\",\n            \"Signal-Android/5.1.2 Android/30\", \"ANDROID 5.1.2\"),\n        Arguments.of(true, \"/v1/test/unhandled-runtime-exception/1/and/two\",\n            \"/v1/test/unhandled-runtime-exception/\\\\{parameter1\\\\}/and/\\\\{parameter2\\\\}\", \"Signal-iOS/5.10.2 iOS/14.1\",\n            \"IOS 5.10.2\"),\n        Arguments.of(true, \"/v1/test/unhandled-runtime-exception\", \"/v1/test/unhandled-runtime-exception\",\n            \"Some literal user-agent\", \"Some literal user-agent\"),\n        Arguments.of(true, \"/v1/test/unhandled-runtime-exception-async\", \"/v1/test/unhandled-runtime-exception-async\",\n            \"Some literal user-agent\", \"Some literal user-agent\"),\n        Arguments.of(true, \"/v1/test/unhandled-runtime-exception-async-completion\",\n            \"/v1/test/unhandled-runtime-exception-async-completion\",\n            \"Some literal user-agent\", \"Some literal user-agent\")\n    );\n  }\n\n  @BeforeEach\n  void setup() {\n    scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();\n  }\n\n  @AfterEach\n  void teardown() {\n    scheduledExecutorService.shutdown();\n    reset(exceptionMapper, logger);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testExceptionMapper(final boolean expectException, final String targetPath, final String loggedPath,\n      final String userAgentHeader, final String userAgentLog) {\n\n    resources.getJerseyTest()\n        .target(targetPath)\n        .request()\n        .header(HttpHeaders.USER_AGENT, userAgentHeader)\n        .get();\n\n    if (expectException) {\n      verify(exceptionMapper, times(1)).toResponse(any(Exception.class));\n      verify(logger, times(1))\n          .error(matches(String.format(\".* at GET %s \\\\(%s\\\\)\", loggedPath, userAgentLog)), any(Exception.class));\n\n    } else {\n      verifyNoInteractions(exceptionMapper);\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource(\"testExceptionMapper\")\n  void testWebsocketExceptionMapper(final boolean expectException, final String targetPath, final String loggedPath,\n      final String userAgentHeader, final String userAgentLog) throws Exception {\n\n    final CompletableFuture<ByteBuffer> responseFuture = new CompletableFuture<>();\n\n    Session session = mock(Session.class);\n    WebSocketResourceProvider<TestPrincipal> provider = createWebsocketProvider(userAgentHeader, session,\n        responseFuture::complete);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory()\n        .createRequest(Optional.of(111L), \"GET\", targetPath, new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    responseFuture.get(1, TimeUnit.SECONDS);\n\n    if (expectException) {\n      verify(exceptionMapper, times(1)).toResponse(any(Exception.class));\n      verify(logger, times(1))\n          .error(matches(String.format(\".* at GET %s \\\\(%s\\\\)\", loggedPath, userAgentLog)), any(Exception.class));\n\n    } else {\n      verifyNoInteractions(exceptionMapper);\n    }\n\n  }\n\n  private WebSocketResourceProvider<TestPrincipal> createWebsocketProvider(final String userAgentHeader,\n      final Session session, final Consumer<ByteBuffer> responseHandler) throws IOException {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(exceptionMapper);\n    resourceConfig.register(new TestController());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(SystemMapper.jsonMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        RemoteAddressFilter.REMOTE_ADDRESS_ATTRIBUTE_NAME, 1234, applicationHandler, requestLog,\n        TestPrincipal.authenticatedTestPrincipal(\"foo\"),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    doAnswer(answer -> {\n      responseHandler.accept(answer.getArgument(0, ByteBuffer.class));\n      return null;\n    }).when(remoteEndpoint).sendBytes(any(), any(WriteCallback.class));\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n    when(request.getHeader(HttpHeaders.USER_AGENT)).thenReturn(userAgentHeader);\n    when(request.getHeaders()).thenReturn(Map.of(HttpHeaders.USER_AGENT, List.of(userAgentHeader)));\n\n    return provider;\n  }\n\n  @Path(\"/v1/test\")\n  public static class TestController {\n\n    @GET\n    @Path(\"/no-exception\")\n    public Response testNoException() {\n      return Response.ok().build();\n    }\n\n    @GET\n    @Path(\"/unhandled-runtime-exception\")\n    public Response testUnhandledException() {\n      throw new RuntimeException();\n    }\n\n    @GET\n    @Path(\"/unhandled-runtime-exception-async\")\n    public CompletableFuture<Response> testUnhandledExceptionAsync() {\n      final CompletableFuture<Response> responseFuture = new CompletableFuture<>();\n\n      scheduledExecutorService.schedule(() -> responseFuture.completeExceptionally(new RuntimeException(\"async\")),\n          50, TimeUnit.MILLISECONDS);\n\n      return responseFuture;\n    }\n\n    @GET\n    @Path(\"/unhandled-runtime-exception-async-completion\")\n    public CompletableFuture<Response> testUnhandledCompletionExceptionAsync() {\n      final CompletableFuture<Response> responseFuture = new CompletableFuture<>();\n\n      scheduledExecutorService.schedule(\n          () -> responseFuture.completeExceptionally(new CompletionException(new RuntimeException(\"async\"))),\n          50, TimeUnit.MILLISECONDS);\n\n      return responseFuture;\n    }\n\n    @GET\n    @Path(\"/unhandled-runtime-exception/{parameter1}/and/{parameter2}\")\n    public Response testUnhandledExceptionWithPathParameter(@PathParam(\"parameter1\") String parameter1,\n        @PathParam(\"parameter2\") String parameter2) {\n      throw new RuntimeException();\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/logging/UriInfoUtilTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.logging;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Arrays;\nimport org.glassfish.jersey.server.ExtendedUriInfo;\nimport org.glassfish.jersey.uri.UriTemplate;\nimport org.junit.jupiter.api.Test;\n\nclass UriInfoUtilTest {\n\n  @Test\n  void testGetPathTemplate() {\n    final UriTemplate firstComponent = new UriTemplate(\"/first\");\n    final UriTemplate secondComponent = new UriTemplate(\"/second\");\n    final UriTemplate thirdComponent = new UriTemplate(\"/{param}/{moreDifferentParam}\");\n\n    final ExtendedUriInfo uriInfo = mock(ExtendedUriInfo.class);\n    when(uriInfo.getMatchedTemplates()).thenReturn(Arrays.asList(thirdComponent, secondComponent, firstComponent));\n\n    assertEquals(\"/first/second/{param}/{moreDifferentParam}\", UriInfoUtil.getPathTemplate(uriInfo));\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/redis/BaseRedisCommandsHandler.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.whispersystems.textsecuregcm.util.redis.RedisLuaScriptSandbox.tail;\n\nimport java.util.List;\nimport java.util.Locale;\nimport java.util.Map;\n\n/**\n * This class is to be extended with implementations of Redis commands as needed.\n */\npublic class BaseRedisCommandsHandler implements RedisCommandsHandler {\n\n  @Override\n  public Object redisCommand(final String command, final List<Object> args) {\n    return switch (command.toUpperCase(Locale.ROOT)) {\n      case \"SET\" -> {\n        assertTrue(args.size() > 2);\n        yield set(args.get(0).toString(), args.get(1).toString(), tail(args, 2));\n      }\n      case \"GET\" -> {\n        assertEquals(1, args.size());\n        yield get(args.get(0).toString());\n      }\n      case \"DEL\" -> {\n        assertTrue(args.size() >= 1);\n        yield del(args.stream().map(Object::toString).toList());\n      }\n      case \"HSET\" -> {\n        assertTrue(args.size() > 1);\n        assertTrue(args.size() % 2 == 1);\n        yield hset(args.get(0).toString(), tail(args, 1));\n      }\n      case \"HGET\" -> {\n        assertEquals(2, args.size());\n        yield hget(args.get(0).toString(), args.get(1).toString());\n      }\n      case \"HMGET\" -> {\n        assertTrue(args.size() > 1);\n        yield hmget(args.get(0).toString(), tail(args, 1));\n      }\n      case \"PEXPIRE\" -> {\n        assertEquals(2, args.size());\n        yield pexpire(args.get(0).toString(), Double.valueOf(args.get(1).toString()).longValue(), tail(args, 2));\n      }\n      case \"TYPE\" -> {\n        assertEquals(1, args.size());\n        yield type(args.get(0).toString());\n      }\n      case \"RPUSH\" -> {\n        assertTrue(args.size() > 1);\n        yield push(false, args.get(0).toString(), tail(args, 1));\n      }\n      case \"LPUSH\" -> {\n        assertTrue(args.size() > 1);\n        yield push(true, args.get(0).toString(), tail(args, 1));\n      }\n      case \"RPOP\" -> {\n        assertEquals(2, args.size());\n        yield pop(false, args.get(0).toString(), Double.valueOf(args.get(1).toString()).intValue());\n      }\n      case \"LPOP\" -> {\n        assertEquals(2, args.size());\n        yield pop(true, args.get(0).toString(), Double.valueOf(args.get(1).toString()).intValue());\n      }\n\n      default -> other(command, args);\n    };\n  }\n\n  public Object[] pop(final boolean left, final String key, final int count) {\n    return new Object[count];\n  }\n\n  public Object push(final boolean left, final String key, final List<Object> values) {\n    return 0;\n  }\n\n  public Object type(final String key) {\n    return Map.of(\"ok\", \"none\");\n  }\n\n  public Object pexpire(final String key, final long ttlMillis, final List<Object> args) {\n    return 0;\n  }\n\n  public Object hset(final String key, final List<Object> fieldsAndValues) {\n    return \"OK\";\n  }\n\n  public Object hget(final String key, final String field) {\n    return null;\n  }\n\n  public Object[] hmget(final String key, final List<Object> fields) {\n    return new Object[fields.size()];\n  }\n\n  public Object set(final String key, final String value, final List<Object> tail) {\n    return \"OK\";\n  }\n\n  public String get(final String key) {\n    return null;\n  }\n\n  public int del(final List<String> keys) {\n    return 0;\n  }\n\n  public Object other(final String command, final List<Object> args) {\n    return \"OK\";\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/redis/RedisCommandsHandler.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.redis;\n\nimport java.util.List;\n\n@FunctionalInterface\npublic interface RedisCommandsHandler {\n\n  Object redisCommand(String command, List<Object> args);\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/redis/RedisLuaScriptSandbox.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.redis;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.google.common.io.Resources;\nimport io.lettuce.core.ScriptOutputType;\nimport java.io.IOException;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Collections;\nimport java.util.List;\nimport java.util.Map;\nimport org.whispersystems.textsecuregcm.util.SystemMapper;\nimport party.iroiro.luajava.Lua;\nimport party.iroiro.luajava.lua51.Lua51;\nimport party.iroiro.luajava.value.ImmutableLuaValue;\n\npublic class RedisLuaScriptSandbox {\n\n  private static final String PREFIX = \"\"\"\n      function redis_call(...)\n        -- variable name needs to match the one used in the `L.setGlobal()` call\n        -- method name needs to match method name of the Java class \n        local result = proxy:redisCall(arg)\n        if type(result) == \"userdata\" then\n          return java.luaify(result)\n        else\n          return result\n        end\n      end\n      \n      function json_encode(obj)\n        return mapper:encode(obj)\n      end\n      \n      function json_decode(json)\n        return java.luaify(mapper:decode(json))\n      end\n      \n      local redis = { call = redis_call }\n      local cjson = { encode = json_encode, decode = json_decode }\n      \n      \"\"\";\n\n  private final String luaScript;\n\n  private final ScriptOutputType scriptOutputType;\n\n\n  public static RedisLuaScriptSandbox fromResource(\n      final String resource,\n      final ScriptOutputType scriptOutputType) {\n    try {\n      final String src = Resources.toString(Resources.getResource(resource), StandardCharsets.UTF_8);\n      return new RedisLuaScriptSandbox(src, scriptOutputType);\n    } catch (IOException e) {\n      throw new RuntimeException(e);\n    }\n  }\n\n  public RedisLuaScriptSandbox(final String luaScript, final ScriptOutputType scriptOutputType) {\n    this.luaScript = luaScript;\n    this.scriptOutputType = scriptOutputType;\n  }\n\n  public Object execute(\n      final List<String> keys,\n      final List<String> args,\n      final RedisCommandsHandler redisCallsHandler) {\n\n    try (final Lua lua = new Lua51()) {\n      lua.openLibraries();\n      final RedisLuaProxy proxy = new RedisLuaProxy(redisCallsHandler);\n      lua.push(MapperLuaProxy.INSTANCE, Lua.Conversion.FULL);\n      lua.setGlobal(\"mapper\");\n      lua.push(proxy, Lua.Conversion.FULL);\n      lua.setGlobal(\"proxy\");\n      lua.push(keys, Lua.Conversion.FULL);\n      lua.setGlobal(\"KEYS\");\n      lua.push(args, Lua.Conversion.FULL);\n      lua.setGlobal(\"ARGV\");\n      final Lua.LuaError executionResult = lua.run(PREFIX + luaScript);\n      assertEquals(\"OK\", executionResult.name(), \"Runtime error during Lua script execution\");\n      return adaptOutputResult(lua.get());\n    }\n  }\n\n  protected Object adaptOutputResult(final Object luaObject) {\n    if (luaObject instanceof ImmutableLuaValue<?> luaValue) {\n      final Object javaValue = luaValue.toJavaObject();\n      // validate expected script output type\n      switch (scriptOutputType) {\n        case INTEGER -> assertTrue(javaValue instanceof Double); // lua number is always Double\n        case STATUS -> assertTrue(javaValue instanceof String);\n        case BOOLEAN -> assertTrue(javaValue instanceof Boolean);\n      };\n      if (javaValue instanceof Double d) {\n        return d.longValue();\n      }\n      if (javaValue instanceof String s) {\n        return s;\n      }\n      if (javaValue instanceof Boolean b) {\n        return b;\n      }\n      if (javaValue == null) {\n        return null;\n      }\n      throw new IllegalStateException(\"unexpected script result java type: \" + javaValue.getClass().getName());\n    }\n    throw new IllegalStateException(\"unexpected script result lua type: \" + luaObject.getClass().getName());\n  }\n\n  public static <T> List<T> tail(final List<T> list, final int fromIdx) {\n    return fromIdx < list.size() ? list.subList(fromIdx, list.size()) : Collections.emptyList();\n  }\n\n  public static final class MapperLuaProxy {\n\n    public static final MapperLuaProxy INSTANCE = new MapperLuaProxy();\n\n    public String encode(final Map<Object, Object> obj) {\n      try {\n        return SystemMapper.jsonMapper().writeValueAsString(obj);\n      } catch (JsonProcessingException e) {\n        throw new RuntimeException(e);\n      }\n    }\n\n    public Map<Object, Object> decode(final Object json) {\n      try {\n        //noinspection unchecked\n        return SystemMapper.jsonMapper().readValue(json.toString(), Map.class);\n      } catch (JsonProcessingException e) {\n        throw new RuntimeException(e);\n      }\n    }\n  }\n\n  /**\n   * Instances of this class are passed to the Lua scripting engine\n   * and serve as a stubs for the calls to `redis.call()`.\n   *\n   * @see #PREFIX\n   */\n  public static final class RedisLuaProxy {\n\n    private final RedisCommandsHandler handler;\n\n    public RedisLuaProxy(final RedisCommandsHandler handler) {\n      this.handler = handler;\n    }\n\n    /**\n     * Method name needs to match the one from the {@link #PREFIX} code.\n     * The method is getting called from the Lua scripting engine.\n     */\n    @SuppressWarnings(\"unused\")\n    public Object redisCall(final List<Object> args) {\n      assertFalse(args.isEmpty(), \"`redis.call()` in Lua script invoked without arguments\");\n      assertTrue(args.get(0) instanceof String, \"first argument to `redis.call()` must be of type `String`\");\n      return handler.redisCommand((String) args.get(0), tail(args, 1));\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/redis/SimpleCacheCommandsHandler.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.redis;\n\nimport java.time.Clock;\nimport java.util.Iterator;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.ConcurrentHashMap;\nimport javax.annotation.Nullable;\n\npublic class SimpleCacheCommandsHandler extends BaseRedisCommandsHandler {\n\n  public record Entry(Object value, long expirationEpochMillis) {\n  }\n\n  private final Map<String, Entry> cache = new ConcurrentHashMap<>();\n\n  private final Clock clock;\n\n\n  public SimpleCacheCommandsHandler(final Clock clock) {\n    this.clock = clock;\n  }\n\n  @Override\n  public Object set(final String key, final String value, final List<Object> tail) {\n    cache.put(key, new Entry(value, resolveExpirationEpochMillis(tail)));\n    return \"OK\";\n  }\n\n  @Override\n  public String get(final String key) {\n    return getIfNotExpired(key, String.class);\n  }\n\n  @Override\n  public int del(final List<String> key) {\n    return key.stream()\n        .mapToInt(k -> cache.remove(k) != null ? 1 : 0)\n        .sum();\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  @Override\n  public Object hset(final String key, final List<Object> fieldsAndValues) {\n    Map<Object, Object> map = getIfNotExpired(key, Map.class);\n    if (map == null) {\n      map = new ConcurrentHashMap<>();\n      cache.put(key, new Entry(map, Long.MAX_VALUE));\n    }\n    final Iterator<Object> iter = fieldsAndValues.iterator();\n    while (iter.hasNext()) {\n      final Object k = iter.next();\n      final Object v = iter.next();\n      map.put(k, v);\n    }\n    return \"OK\";\n  }\n\n  @Override\n  public Object hget(final String key, final String field) {\n    final Map<?, ?> map = getIfNotExpired(key, Map.class);\n    return map == null ? null : map.get(field);\n  }\n\n  @Override\n  public Object[] hmget(final String key, final List<Object> fields) {\n    final Object[] res = new Object[fields.size()];\n    for (int i = 0; i < fields.size(); i++) {\n      res[i] = hget(key, fields.get(i).toString());\n    }\n    return res;\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  @Override\n  public Object push(final boolean left, final String key, final List<Object> values) {\n    LinkedList<Object> list = getIfNotExpired(key, LinkedList.class);\n    if (list == null) {\n      list = new LinkedList<>();\n      cache.put(key, new Entry(list, Long.MAX_VALUE));\n    }\n    for (Object v: values) {\n      if (left) {\n        list.addFirst(v.toString());\n      } else {\n        list.addLast(v.toString());\n      }\n    }\n    return list.size();\n  }\n\n  @SuppressWarnings(\"unchecked\")\n  @Override\n  public Object[] pop(final boolean left, final String key, final int count) {\n    final Object[] result = new String[count];\n    final LinkedList<Object> list = getIfNotExpired(key, LinkedList.class);\n    if (list == null) {\n      return result;\n    }\n    for (int i = 0; i < Math.min(count, list.size()); i++) {\n      result[i] = left ? list.removeFirst() : list.removeLast();\n    }\n    return result;\n  }\n\n  @Override\n  public Object pexpire(final String key, final long ttlMillis, final List<Object> args) {\n    final Entry e = cache.get(key);\n    if (e == null) {\n      return 0;\n    }\n    final Entry updated = new Entry(e.value(), clock.millis() + ttlMillis);\n    cache.put(key, updated);\n    return 1;\n  }\n\n  @Override\n  public Object type(final String key) {\n    final Object o = getIfNotExpired(key, Object.class);\n    final String type;\n    if (o == null) {\n      type = \"none\";\n    } else if (o.getClass() == String.class) {\n      type = \"string\";\n    } else if (Map.class.isAssignableFrom(o.getClass())) {\n      type = \"hash\";\n    } else if (List.class.isAssignableFrom(o.getClass())) {\n      type = \"list\";\n    } else {\n      throw new IllegalArgumentException(\"Unsupported value type: \" + o.getClass());\n    }\n    return Map.of(\"ok\", type);\n  }\n\n  @Nullable\n  protected <T> T getIfNotExpired(final String key, final Class<T> expectedType) {\n    final Entry entry = cache.get(key);\n    if (entry == null) {\n      return null;\n    }\n    if (entry.expirationEpochMillis() < clock.millis()) {\n      del(List.of(key));\n      return null;\n    }\n    return expectedType.cast(entry.value());\n  }\n\n  protected long resolveExpirationEpochMillis(final List<Object> args) {\n    for (int i = 0; i < args.size() - 1; i++) {\n      final long currentTimeMillis = clock.millis();\n      final String param = args.get(i).toString();\n      final String value = args.get(i + 1).toString();\n      switch (param) {\n        case \"EX\" -> {\n          return currentTimeMillis + Double.valueOf(value).longValue() * 1000;\n        }\n        case \"PX\" -> {\n          return currentTimeMillis + Double.valueOf(value).longValue();\n        }\n        case \"EXAT\" -> {\n          return Double.valueOf(value).longValue() * 1000;\n        }\n        case \"PXAT\" -> {\n          return Double.valueOf(value).longValue();\n        }\n      }\n    }\n    return Long.MAX_VALUE;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/util/ua/UserAgentUtilTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util.ua;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\n\nimport com.vdurmont.semver4j.Semver;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport javax.annotation.Nullable;\n\nclass UserAgentUtilTest {\n\n  @ParameterizedTest\n  @MethodSource(\"argumentsForTestParseStandardUserAgentString\")\n  void testParseStandardUserAgentString(final String userAgentString, @Nullable final UserAgent expectedUserAgent)\n      throws UnrecognizedUserAgentException {\n\n    if (expectedUserAgent != null) {\n      assertEquals(expectedUserAgent, UserAgentUtil.parseUserAgentString(userAgentString));\n    } else {\n      assertThrows(UnrecognizedUserAgentException.class, () -> UserAgentUtil.parseUserAgentString(userAgentString));\n    }\n  }\n\n  private static Stream<Arguments> argumentsForTestParseStandardUserAgentString() {\n    return Stream.of(\n        Arguments.of(\"This is obviously not a reasonable User-Agent string.\", null),\n        Arguments.of(\"Signal-Android/4.68.3 Android/25\",\n            new UserAgent(ClientPlatform.ANDROID, new Semver(\"4.68.3\"), \"Android/25\")),\n        Arguments.of(\"Signal-Android/4.68.3\", new UserAgent(ClientPlatform.ANDROID, new Semver(\"4.68.3\"), null)),\n        Arguments.of(\"Signal-Desktop/1.2.3 Linux\", new UserAgent(ClientPlatform.DESKTOP, new Semver(\"1.2.3\"), \"Linux\")),\n        Arguments.of(\"Signal-Desktop/1.2.3 macOS\", new UserAgent(ClientPlatform.DESKTOP, new Semver(\"1.2.3\"), \"macOS\")),\n        Arguments.of(\"Signal-Desktop/1.2.3 Windows\",\n            new UserAgent(ClientPlatform.DESKTOP, new Semver(\"1.2.3\"), \"Windows\")),\n        Arguments.of(\"Signal-Desktop/1.2.3\", new UserAgent(ClientPlatform.DESKTOP, new Semver(\"1.2.3\"), null)),\n        Arguments.of(\"Signal-Desktop/1.32.0-beta.3\",\n            new UserAgent(ClientPlatform.DESKTOP, new Semver(\"1.32.0-beta.3\"), null)),\n        Arguments.of(\"Signal-iOS/3.9.0 (iPhone; iOS 12.2; Scale/3.00)\",\n            new UserAgent(ClientPlatform.IOS, new Semver(\"3.9.0\"), \"(iPhone; iOS 12.2; Scale/3.00)\")),\n        Arguments.of(\"Signal-iOS/3.9.0 iOS/14.2\", new UserAgent(ClientPlatform.IOS, new Semver(\"3.9.0\"), \"iOS/14.2\")),\n        Arguments.of(\"Signal-iOS/3.9.0\", new UserAgent(ClientPlatform.IOS, new Semver(\"3.9.0\"), null)),\n        Arguments.of(\"Signal-Android/7.11.23-nightly-1982-06-28-07-07-07 tonic/0.31\",\n            new UserAgent(ClientPlatform.ANDROID, new Semver(\"7.11.23-nightly-1982-06-28-07-07-07\"), \"tonic/0.31\")),\n        Arguments.of(\"Signal-Android/7.11.23-nightly-1982-06-28-07-07-07 Android/42 tonic/0.31\",\n            new UserAgent(ClientPlatform.ANDROID, new Semver(\"7.11.23-nightly-1982-06-28-07-07-07\"), \"Android/42 tonic/0.31\")),\n        Arguments.of(\"Signal-Android/7.6.2 Android/34 libsignal/0.46.0\",\n            new UserAgent(ClientPlatform.ANDROID, new Semver(\"7.6.2\"), \"Android/34 libsignal/0.46.0\")));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/websocket/AuthenticatedConnectListenerTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doThrow;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.UUID;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.auth.DisconnectionRequestManager;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.websocket.WebSocketClient;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\nclass AuthenticatedConnectListenerTest {\n\n  private AccountsManager accountsManager;\n  private DisconnectionRequestManager disconnectionRequestManager;\n\n  private WebSocketConnection authenticatedWebSocketConnection;\n  private AuthenticatedConnectListener authenticatedConnectListener;\n\n  private Account authenticatedAccount;\n  private WebSocketClient webSocketClient;\n  private WebSocketSessionContext webSocketSessionContext;\n\n  private static final UUID ACCOUNT_IDENTIFIER = UUID.randomUUID();\n  private static final byte DEVICE_ID = Device.PRIMARY_ID;\n\n  @BeforeEach\n  void setUpBeforeEach() {\n    accountsManager = mock(AccountsManager.class);\n    disconnectionRequestManager = mock(DisconnectionRequestManager.class);\n\n    authenticatedWebSocketConnection = mock(WebSocketConnection.class);\n\n    authenticatedConnectListener = new AuthenticatedConnectListener(accountsManager,\n        disconnectionRequestManager,\n        () -> mock(AsnInfoProvider.class),\n        mock(ClientReleaseManager.class),\n        (_, _, _) -> authenticatedWebSocketConnection);\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n\n    authenticatedAccount = mock(Account.class);\n    when(authenticatedAccount.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTIFIER);\n    when(authenticatedAccount.getDevice(DEVICE_ID)).thenReturn(Optional.of(device));\n\n    webSocketClient = mock(WebSocketClient.class);\n\n    webSocketSessionContext = mock(WebSocketSessionContext.class);\n    when(webSocketSessionContext.getClient()).thenReturn(webSocketClient);\n  }\n\n  @Test\n  void onWebSocketConnectAuthenticated() {\n    when(webSocketSessionContext.getAuthenticated()).thenReturn(new AuthenticatedDevice(ACCOUNT_IDENTIFIER, DEVICE_ID, Instant.now()));\n    when(webSocketSessionContext.getAuthenticated(AuthenticatedDevice.class))\n        .thenReturn(new AuthenticatedDevice(ACCOUNT_IDENTIFIER, DEVICE_ID, Instant.now()));\n\n    when(accountsManager.getByAccountIdentifier(ACCOUNT_IDENTIFIER)).thenReturn(Optional.of(authenticatedAccount));\n\n    authenticatedConnectListener.onWebSocketConnect(webSocketSessionContext);\n\n    verify(disconnectionRequestManager).addListener(ACCOUNT_IDENTIFIER, DEVICE_ID, authenticatedWebSocketConnection);\n    // We expect one call from AuthenticatedConnectListener itself and one from OpenWebSocketCounter\n    verify(webSocketSessionContext, times(2)).addWebsocketClosedListener(any());\n    verify(authenticatedWebSocketConnection).start();\n  }\n\n  @Test\n  void onWebSocketConnectAuthenticatedAccountNotFound() {\n    when(webSocketSessionContext.getAuthenticated()).thenReturn(new AuthenticatedDevice(ACCOUNT_IDENTIFIER, DEVICE_ID, Instant.now()));\n    when(webSocketSessionContext.getAuthenticated(AuthenticatedDevice.class))\n        .thenReturn(new AuthenticatedDevice(ACCOUNT_IDENTIFIER, DEVICE_ID, Instant.now()));\n\n    when(accountsManager.getByAccountIdentifier(ACCOUNT_IDENTIFIER)).thenReturn(Optional.empty());\n\n    authenticatedConnectListener.onWebSocketConnect(webSocketSessionContext);\n\n    verify(webSocketClient).close(eq(1011), anyString());\n\n    verify(disconnectionRequestManager, never()).addListener(any(), anyByte(), any());\n    // We expect one call from OpenWebSocketCounter, but none from AuthenticatedConnectListener itself\n    verify(webSocketSessionContext, times(1)).addWebsocketClosedListener(any());\n    verify(authenticatedWebSocketConnection, never()).start();\n  }\n\n  @Test\n  void onWebSocketConnectAuthenticatedStartException() {\n    when(webSocketSessionContext.getAuthenticated()).thenReturn(new AuthenticatedDevice(ACCOUNT_IDENTIFIER, DEVICE_ID, Instant.now()));\n    when(webSocketSessionContext.getAuthenticated(AuthenticatedDevice.class))\n        .thenReturn(new AuthenticatedDevice(ACCOUNT_IDENTIFIER, DEVICE_ID, Instant.now()));\n\n    when(accountsManager.getByAccountIdentifier(ACCOUNT_IDENTIFIER)).thenReturn(Optional.of(authenticatedAccount));\n    doThrow(new RuntimeException()).when(authenticatedWebSocketConnection).start();\n\n    authenticatedConnectListener.onWebSocketConnect(webSocketSessionContext);\n\n    verify(disconnectionRequestManager).addListener(ACCOUNT_IDENTIFIER, DEVICE_ID, authenticatedWebSocketConnection);\n    // We expect one call from AuthenticatedConnectListener itself and one from OpenWebSocketCounter\n    verify(webSocketSessionContext, times(2)).addWebsocketClosedListener(any());\n    verify(authenticatedWebSocketConnection).start();\n\n    verify(webSocketClient).close(eq(1011), anyString());\n  }\n\n  @Test\n  void onWebSocketConnectUnauthenticated() {\n    authenticatedConnectListener.onWebSocketConnect(webSocketSessionContext);\n\n    verify(disconnectionRequestManager, never()).addListener(any(), anyByte(), any());\n    // We expect one call from OpenWebSocketCounter, but none from AuthenticatedConnectListener itself\n    verify(webSocketSessionContext, times(1)).addWebsocketClosedListener(any());\n    verify(authenticatedWebSocketConnection, never()).start();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/websocket/ProvisioningConnectListenerTest.java",
    "content": "package org.whispersystems.textsecuregcm.websocket;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyInt;\nimport static org.mockito.ArgumentMatchers.anyLong;\nimport static org.mockito.ArgumentMatchers.anyString;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doReturn;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\n\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport java.time.Duration;\nimport java.util.Optional;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.ScheduledFuture;\nimport java.util.concurrent.TimeUnit;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.asn.AsnInfoProvider;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.push.ProvisioningManager;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.websocket.WebSocketClient;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\nclass ProvisioningConnectListenerTest {\n\n  private ProvisioningManager provisioningManager;\n  private ProvisioningConnectListener provisioningConnectListener;\n  private ScheduledExecutorService scheduledExecutorService;\n\n  private static Duration TIMEOUT = Duration.ofSeconds(5);\n\n  @BeforeEach\n  void setUp() {\n    provisioningManager = mock(ProvisioningManager.class);\n    scheduledExecutorService = mock(ScheduledExecutorService.class);\n    provisioningConnectListener =\n        new ProvisioningConnectListener(provisioningManager, () -> mock(AsnInfoProvider.class), mock(ClientReleaseManager.class), scheduledExecutorService, TIMEOUT);\n  }\n\n  @Test\n  void onWebSocketConnect() {\n    final WebSocketClient webSocketClient = mock(WebSocketClient.class);\n    final WebSocketSessionContext context = new WebSocketSessionContext(webSocketClient);\n    final ScheduledFuture<?> scheduledFuture = mock(ScheduledFuture.class);\n    doReturn(scheduledFuture).when(scheduledExecutorService).schedule(any(Runnable.class), anyLong(), any());\n\n    provisioningConnectListener.onWebSocketConnect(context);\n    context.notifyClosed(1000, \"Test\");\n\n    final ArgumentCaptor<String> addListenerProvisioningAddressCaptor = ArgumentCaptor.forClass(String.class);\n    final ArgumentCaptor<String> removeListenerProvisioningAddressCaptor = ArgumentCaptor.forClass(String.class);\n\n    @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Optional<byte[]>> sendAddressCaptor =\n        ArgumentCaptor.forClass(Optional.class);\n\n    verify(provisioningManager).addListener(addListenerProvisioningAddressCaptor.capture(), any());\n    verify(provisioningManager).removeListener(removeListenerProvisioningAddressCaptor.capture());\n    verify(webSocketClient).sendRequest(eq(\"PUT\"), eq(\"/v1/address\"), any(), sendAddressCaptor.capture());\n\n    final String sentProvisioningAddress = sendAddressCaptor.getValue()\n        .map(provisioningAddressBytes -> {\n          try {\n            return MessageProtos.ProvisioningAddress.parseFrom(provisioningAddressBytes);\n          } catch (final InvalidProtocolBufferException e) {\n            throw new RuntimeException(e);\n          }\n        })\n        .map(MessageProtos.ProvisioningAddress::getAddress)\n        .orElseThrow();\n\n    assertEquals(addListenerProvisioningAddressCaptor.getValue(), removeListenerProvisioningAddressCaptor.getValue());\n    assertEquals(addListenerProvisioningAddressCaptor.getValue(), sentProvisioningAddress);\n  }\n\n  @Test\n  void schedulesTimeout() {\n    final WebSocketClient webSocketClient = mock(WebSocketClient.class);\n    final WebSocketSessionContext context = new WebSocketSessionContext(webSocketClient);\n\n    final ScheduledFuture<?> scheduledFuture = mock(ScheduledFuture.class);\n    doReturn(scheduledFuture).when(scheduledExecutorService).schedule(any(Runnable.class), anyLong(), any());\n\n    final ArgumentCaptor<Runnable> scheduleCaptor = ArgumentCaptor.forClass(Runnable.class);\n    provisioningConnectListener.onWebSocketConnect(context);\n    verify(scheduledExecutorService).schedule(scheduleCaptor.capture(), eq(TIMEOUT.getSeconds()), eq(TimeUnit.SECONDS));\n\n    verify(webSocketClient, never()).close(anyInt(), any());\n    scheduleCaptor.getValue().run();\n    verify(webSocketClient, times(1)).close(eq(1000), anyString());\n  }\n\n  @Test\n  void cancelsTimeout() {\n    final WebSocketClient webSocketClient = mock(WebSocketClient.class);\n    final WebSocketSessionContext context = new WebSocketSessionContext(webSocketClient);\n\n    final ScheduledFuture<?> scheduledFuture = mock(ScheduledFuture.class);\n    doReturn(scheduledFuture).when(scheduledExecutorService).schedule(any(Runnable.class), anyLong(), any());\n\n    provisioningConnectListener.onWebSocketConnect(context);\n    verify(scheduledExecutorService).schedule(any(Runnable.class), eq(TIMEOUT.getSeconds()), eq(TimeUnit.SECONDS));\n\n    context.notifyClosed(1000, \"Test\");\n\n    verify(scheduledFuture).cancel(false);\n    verify(webSocketClient, never()).close(anyInt(), any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketAccountAuthenticatorTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertThrows;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.common.net.HttpHeaders;\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport io.dropwizard.auth.basic.BasicCredentials;\nimport java.time.Instant;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport javax.annotation.Nullable;\nimport org.eclipse.jetty.websocket.api.UpgradeRequest;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.auth.AccountAuthenticator;\nimport org.whispersystems.textsecuregcm.auth.AuthenticatedDevice;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.util.HeaderUtils;\nimport org.whispersystems.websocket.auth.InvalidCredentialsException;\n\nclass WebSocketAccountAuthenticatorTest {\n\n  private static final String VALID_USER = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"NZ\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n  private static final String VALID_PASSWORD = \"valid\";\n\n  private static final String INVALID_USER = PhoneNumberUtil.getInstance().format(\n      PhoneNumberUtil.getInstance().getExampleNumber(\"AU\"), PhoneNumberUtil.PhoneNumberFormat.E164);\n\n  private static final String INVALID_PASSWORD = \"invalid\";\n\n  private AccountAuthenticator accountAuthenticator;\n\n  private UpgradeRequest upgradeRequest;\n\n  @BeforeEach\n  void setUp() {\n    accountAuthenticator = mock(AccountAuthenticator.class);\n\n    when(accountAuthenticator.authenticate(eq(new BasicCredentials(VALID_USER, VALID_PASSWORD))))\n        .thenReturn(Optional.of(new AuthenticatedDevice(UUID.randomUUID(), Device.PRIMARY_ID, Instant.now())));\n\n    when(accountAuthenticator.authenticate(eq(new BasicCredentials(INVALID_USER, INVALID_PASSWORD))))\n        .thenReturn(Optional.empty());\n\n    upgradeRequest = mock(UpgradeRequest.class);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void testAuthenticate(\n      @Nullable final String authorizationHeaderValue,\n      final boolean expectAccount,\n      final boolean expectInvalid) throws Exception {\n\n    if (authorizationHeaderValue != null) {\n      when(upgradeRequest.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(authorizationHeaderValue);\n    }\n\n    final WebSocketAccountAuthenticator webSocketAuthenticator = new WebSocketAccountAuthenticator(accountAuthenticator);\n\n    if (expectInvalid) {\n      assertThrows(InvalidCredentialsException.class, () -> webSocketAuthenticator.authenticate(upgradeRequest));\n    } else {\n      assertEquals(expectAccount, webSocketAuthenticator.authenticate(upgradeRequest).isPresent());\n    }\n  }\n\n  private static Stream<Arguments> testAuthenticate() {\n    final String headerWithValidAuth =\n        HeaderUtils.basicAuthHeader(VALID_USER, VALID_PASSWORD);\n    final String headerWithInvalidAuth =\n        HeaderUtils.basicAuthHeader(INVALID_USER, INVALID_PASSWORD);\n    return Stream.of(\n        Arguments.of(headerWithValidAuth, true, false),\n        Arguments.of(headerWithInvalidAuth, false, true),\n        Arguments.of(\"invalid header value\", false, true),\n        // if `Authorization` header is not set, we expect no account and anonymous credentials\n        Arguments.of(null, false, false)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionIntegrationTest.java",
    "content": "/*\n * Copyright 2013-2022 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.junit.jupiter.api.Assertions.fail;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyList;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.atMost;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport java.io.IOException;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.apache.commons.lang3.RandomStringUtils;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.junit.jupiter.api.extension.RegisterExtension;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.CsvSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.configuration.dynamic.DynamicConfiguration;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos;\nimport org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.push.ReceiptSender;\nimport org.whispersystems.textsecuregcm.push.RedisMessageAvailabilityManager;\nimport org.whispersystems.textsecuregcm.redis.RedisClusterExtension;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.DynamicConfigurationManager;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtension;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbExtensionSchema.Tables;\nimport org.whispersystems.textsecuregcm.storage.MessagesCache;\nimport org.whispersystems.textsecuregcm.storage.MessagesDynamoDb;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.textsecuregcm.storage.ReportMessageManager;\nimport org.whispersystems.websocket.WebSocketClient;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport reactor.core.publisher.Mono;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\n\n@Timeout(value = 30, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass WebSocketConnectionIntegrationTest {\n\n  @RegisterExtension\n  static final DynamoDbExtension DYNAMO_DB_EXTENSION = new DynamoDbExtension(Tables.MESSAGES);\n\n  @RegisterExtension\n  static final RedisClusterExtension REDIS_CLUSTER_EXTENSION = RedisClusterExtension.builder().build();\n\n  private ExecutorService sharedExecutorService;\n  private MessagesDynamoDb messagesDynamoDb;\n  private MessagesCache messagesCache;\n  private RedisMessageAvailabilityManager redisMessageAvailabilityManager;\n  private ReportMessageManager reportMessageManager;\n  private Account account;\n  private Device device;\n  private WebSocketClient webSocketClient;\n  private Scheduler messageDeliveryScheduler;\n  private ClientReleaseManager clientReleaseManager;\n\n  private long serialTimestamp = System.currentTimeMillis();\n\n  @BeforeEach\n  void setUp() throws Exception {\n    sharedExecutorService = Executors.newSingleThreadExecutor();\n    messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n\n    @SuppressWarnings(\"unchecked\") final DynamicConfigurationManager<DynamicConfiguration> dynamicConfigurationManager =\n        mock(DynamicConfigurationManager.class);\n\n    when(dynamicConfigurationManager.getConfiguration()).thenReturn(new DynamicConfiguration());\n\n    messagesCache = new MessagesCache(REDIS_CLUSTER_EXTENSION.getRedisCluster(),\n        messageDeliveryScheduler, sharedExecutorService, mock(ScheduledExecutorService.class), Clock.systemUTC(), mock(ExperimentEnrollmentManager.class));\n    messagesDynamoDb = new MessagesDynamoDb(DYNAMO_DB_EXTENSION.getDynamoDbClient(),\n        DYNAMO_DB_EXTENSION.getDynamoDbAsyncClient(), Tables.MESSAGES.tableName(), Duration.ofDays(7),\n        sharedExecutorService, mock(ExperimentEnrollmentManager.class));\n    redisMessageAvailabilityManager = new RedisMessageAvailabilityManager(REDIS_CLUSTER_EXTENSION.getRedisCluster(), sharedExecutorService, sharedExecutorService);\n    reportMessageManager = mock(ReportMessageManager.class);\n    account = mock(Account.class);\n    device = mock(Device.class);\n    webSocketClient = mock(WebSocketClient.class);\n    clientReleaseManager = mock(ClientReleaseManager.class);\n\n    when(account.getNumber()).thenReturn(\"+18005551234\");\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID());\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    redisMessageAvailabilityManager.start();\n  }\n\n  @AfterEach\n  void tearDown() throws Exception {\n    redisMessageAvailabilityManager.stop();\n\n    sharedExecutorService.shutdown();\n    final Mono<Void> schedulerShutdownMono = messageDeliveryScheduler.disposeGracefully();\n\n    //noinspection ResultOfMethodCallIgnored\n    sharedExecutorService.awaitTermination(2, TimeUnit.SECONDS);\n    schedulerShutdownMono.timeout(Duration.ofSeconds(2))\n        .onErrorResume(TimeoutException.class, _ -> Mono.fromRunnable(() -> messageDeliveryScheduler.dispose()))\n        .block();\n  }\n\n  @ParameterizedTest\n  @CsvSource({\n      \"207, 173\",\n      \"323, 0\",\n      \"0, 221\",\n  })\n  void testProcessStoredMessages(final int persistedMessageCount, final int cachedMessageCount) {\n    final WebSocketConnection webSocketConnection = new WebSocketConnection(\n        mock(ReceiptSender.class),\n        new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, reportMessageManager, sharedExecutorService, Clock.systemUTC()),\n        new MessageMetrics(),\n        mock(PushNotificationManager.class),\n        mock(PushNotificationScheduler.class),\n        account,\n        device,\n        webSocketClient,\n        messageDeliveryScheduler,\n        clientReleaseManager,\n        mock(MessageDeliveryLoopMonitor.class),\n        mock(ExperimentEnrollmentManager.class)\n    );\n\n    final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(15), () -> {\n\n      {\n        final List<MessageProtos.Envelope> persistedMessages = new ArrayList<>(persistedMessageCount);\n\n        for (int i = 0; i < persistedMessageCount; i++) {\n          final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID());\n\n          persistedMessages.add(envelope);\n          expectedMessages.add(envelope);\n        }\n\n        messagesDynamoDb.store(persistedMessages, account.getIdentifier(IdentityType.ACI), device);\n      }\n\n      for (int i = 0; i < cachedMessageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid);\n\n        messagesCache.insert(messageGuid, account.getIdentifier(IdentityType.ACI), device.getId(), envelope).join();\n        expectedMessages.add(envelope);\n      }\n\n      final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n\n      when(successResponse.getStatus()).thenReturn(200);\n      when(webSocketClient.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), any()))\n          .thenReturn(CompletableFuture.completedFuture(successResponse));\n\n      webSocketConnection.start();\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Optional<byte[]>> messageBodyCaptor =\n          ArgumentCaptor.forClass(Optional.class);\n\n      verify(webSocketClient, timeout(10_000))\n          .sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(), eq(Optional.empty()));\n\n      verify(webSocketClient, times(persistedMessageCount + cachedMessageCount))\n          .sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), messageBodyCaptor.capture());\n\n      final List<MessageProtos.Envelope> sentMessages = new ArrayList<>();\n\n      for (final Optional<byte[]> maybeMessageBody : messageBodyCaptor.getAllValues()) {\n        maybeMessageBody.ifPresent(messageBytes -> {\n          try {\n            sentMessages.add(MessageProtos.Envelope.parseFrom(messageBytes));\n          } catch (final InvalidProtocolBufferException e) {\n            fail(\"Could not parse sent message\");\n          }\n        });\n      }\n\n      assertEquals(expectedMessages, sentMessages);\n    });\n  }\n\n  @Test\n  void testProcessStoredMessagesMultipleSegments() {\n    final WebSocketConnection webSocketConnection = new WebSocketConnection(\n        mock(ReceiptSender.class),\n        new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, reportMessageManager, sharedExecutorService, Clock.systemUTC()),\n        new MessageMetrics(),\n        mock(PushNotificationManager.class),\n        mock(PushNotificationScheduler.class),\n        account,\n        device,\n        webSocketClient,\n        messageDeliveryScheduler,\n        clientReleaseManager,\n        mock(MessageDeliveryLoopMonitor.class),\n        mock(ExperimentEnrollmentManager.class)\n    );\n\n    final int persistedMessageCount = 77;\n    final int cachedMessageCount = 104;\n\n    final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(15), () -> {\n\n      {\n        final List<MessageProtos.Envelope> persistedMessages = new ArrayList<>(persistedMessageCount);\n\n        for (int i = 0; i < persistedMessageCount; i++) {\n          final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID());\n\n          persistedMessages.add(envelope);\n          expectedMessages.add(envelope);\n        }\n\n        messagesDynamoDb.store(persistedMessages, account.getIdentifier(IdentityType.ACI), device);\n      }\n\n      for (int i = 0; i < cachedMessageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid);\n\n        messagesCache.insert(messageGuid, account.getIdentifier(IdentityType.ACI), device.getId(), envelope).join();\n        expectedMessages.add(envelope);\n      }\n\n      final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n\n      final AtomicInteger remainingMessages = new AtomicInteger(persistedMessageCount + cachedMessageCount);\n      final int additionalMessageCount = 67;\n\n      when(successResponse.getStatus()).thenReturn(200);\n      when(webSocketClient.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), any()))\n          .thenAnswer(_ -> {\n            if (remainingMessages.addAndGet(-1) == 60) {\n              sharedExecutorService.submit(() -> {\n                for (int i = 0; i < additionalMessageCount; i++) {\n                  final UUID messageGuid = UUID.randomUUID();\n                  final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid);\n\n                  messagesCache.insert(messageGuid, account.getIdentifier(IdentityType.ACI), device.getId(), envelope).join();\n                  expectedMessages.add(envelope);\n                }\n              });\n            }\n\n            return CompletableFuture.completedFuture(successResponse);\n          });\n\n      webSocketConnection.start();\n\n      @SuppressWarnings(\"unchecked\") final ArgumentCaptor<Optional<byte[]>> messageBodyCaptor =\n          ArgumentCaptor.forClass(Optional.class);\n\n      verify(webSocketClient, timeout(10_000))\n          .sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(), eq(Optional.empty()));\n\n      verify(webSocketClient, timeout(10_000).times(persistedMessageCount + cachedMessageCount + additionalMessageCount))\n          .sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), messageBodyCaptor.capture());\n\n      final List<MessageProtos.Envelope> sentMessages = new ArrayList<>();\n\n      for (final Optional<byte[]> maybeMessageBody : messageBodyCaptor.getAllValues()) {\n        maybeMessageBody.ifPresent(messageBytes -> {\n          try {\n            sentMessages.add(MessageProtos.Envelope.parseFrom(messageBytes));\n          } catch (final InvalidProtocolBufferException e) {\n            fail(\"Could not parse sent message\");\n          }\n        });\n      }\n\n      assertEquals(expectedMessages, sentMessages);\n    });\n  }\n\n  @Test\n  void testProcessStoredMessagesClientClosed() {\n    final WebSocketConnection webSocketConnection = new WebSocketConnection(\n        mock(ReceiptSender.class),\n        new MessagesManager(messagesDynamoDb, messagesCache, redisMessageAvailabilityManager, reportMessageManager, sharedExecutorService, Clock.systemUTC()),\n        new MessageMetrics(),\n        mock(PushNotificationManager.class),\n        mock(PushNotificationScheduler.class),\n        account,\n        device,\n        webSocketClient,\n        messageDeliveryScheduler,\n        clientReleaseManager,\n        mock(MessageDeliveryLoopMonitor.class),\n        mock(ExperimentEnrollmentManager.class)\n    );\n\n    final int persistedMessageCount = 207;\n    final int cachedMessageCount = 173;\n\n    final List<MessageProtos.Envelope> expectedMessages = new ArrayList<>(persistedMessageCount + cachedMessageCount);\n\n    assertTimeoutPreemptively(Duration.ofSeconds(15), () -> {\n\n      {\n        final List<MessageProtos.Envelope> persistedMessages = new ArrayList<>(persistedMessageCount);\n\n        for (int i = 0; i < persistedMessageCount; i++) {\n          final MessageProtos.Envelope envelope = generateRandomMessage(UUID.randomUUID());\n          persistedMessages.add(envelope);\n          expectedMessages.add(envelope);\n        }\n\n        messagesDynamoDb.store(persistedMessages, account.getIdentifier(IdentityType.ACI), device);\n      }\n\n      for (int i = 0; i < cachedMessageCount; i++) {\n        final UUID messageGuid = UUID.randomUUID();\n        final MessageProtos.Envelope envelope = generateRandomMessage(messageGuid);\n        messagesCache.insert(messageGuid, account.getIdentifier(IdentityType.ACI), device.getId(), envelope).join();\n\n        expectedMessages.add(envelope);\n      }\n\n      when(webSocketClient.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), any()))\n          .thenReturn(CompletableFuture.failedFuture(new IOException(\"Connection closed\")));\n\n      webSocketConnection.start();\n\n      //noinspection unchecked\n      final ArgumentCaptor<Optional<byte[]>> messageBodyCaptor = ArgumentCaptor.forClass(Optional.class);\n\n      verify(webSocketClient, atMost(persistedMessageCount + cachedMessageCount)).sendRequest(eq(\"PUT\"),\n          eq(\"/api/v1/message\"), anyList(), messageBodyCaptor.capture());\n      verify(webSocketClient, never()).sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(),\n          eq(Optional.empty()));\n\n      final List<MessageProtos.Envelope> sentMessages = messageBodyCaptor.getAllValues().stream()\n          .map(Optional::orElseThrow)\n          .map(messageBytes -> {\n            try {\n              return Envelope.parseFrom(messageBytes);\n            } catch (InvalidProtocolBufferException e) {\n              throw new RuntimeException(e);\n            }\n          }).toList();\n\n      assertTrue(expectedMessages.containsAll(sentMessages));\n    });\n  }\n\n  private MessageProtos.Envelope generateRandomMessage(final UUID messageGuid) {\n    final long timestamp = serialTimestamp++;\n\n    return MessageProtos.Envelope.newBuilder()\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(timestamp)\n        .setContent(ByteString.copyFromUtf8(RandomStringUtils.secure().nextAlphanumeric(256)))\n        .setType(MessageProtos.Envelope.Type.CIPHERTEXT)\n        .setServerGuid(messageGuid.toString())\n        .setDestinationServiceId(UUID.randomUUID().toString())\n        .build();\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/websocket/WebSocketConnectionTest.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.websocket;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertTrue;\nimport static org.mockito.ArgumentMatchers.anyList;\nimport static org.mockito.ArgumentMatchers.argThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.any;\nimport static org.mockito.Mockito.anyInt;\nimport static org.mockito.Mockito.anyString;\nimport static org.mockito.Mockito.inOrder;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.timeout;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\nimport static org.whispersystems.textsecuregcm.entities.MessageProtos.Envelope;\n\nimport com.google.protobuf.ByteString;\nimport io.lettuce.core.RedisCommandTimeoutException;\nimport io.lettuce.core.RedisException;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport org.junit.jupiter.api.AfterAll;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeAll;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.InOrder;\nimport org.whispersystems.textsecuregcm.experiment.ExperimentEnrollmentManager;\nimport org.whispersystems.textsecuregcm.identity.AciServiceIdentifier;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.limits.MessageDeliveryLoopMonitor;\nimport org.whispersystems.textsecuregcm.metrics.MessageMetrics;\nimport org.whispersystems.textsecuregcm.push.PushNotificationManager;\nimport org.whispersystems.textsecuregcm.push.PushNotificationScheduler;\nimport org.whispersystems.textsecuregcm.push.ReceiptSender;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.ClientReleaseManager;\nimport org.whispersystems.textsecuregcm.storage.ConflictingMessageConsumerException;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessageStream;\nimport org.whispersystems.textsecuregcm.storage.MessageStreamEntry;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport org.whispersystems.websocket.WebSocketClient;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport reactor.adapter.JdkFlowAdapter;\nimport reactor.core.publisher.Flux;\nimport reactor.core.publisher.Hooks;\nimport reactor.core.scheduler.Scheduler;\nimport reactor.core.scheduler.Schedulers;\nimport reactor.test.StepVerifier;\nimport reactor.test.publisher.TestPublisher;\n\nclass WebSocketConnectionTest {\n\n  private Account account;\n  private Device device;\n  private MessagesManager messagesManager;\n  private ReceiptSender receiptSender;\n  private Scheduler messageDeliveryScheduler;\n  private ClientReleaseManager clientReleaseManager;\n\n  private static final int SOURCE_DEVICE_ID = 1;\n\n  private static final AtomicInteger ON_ERROR_DROPPED_COUNTER = new AtomicInteger();\n\n  @BeforeAll\n  static void setUpBeforeAll() {\n    Hooks.onErrorDropped(_ -> ON_ERROR_DROPPED_COUNTER.incrementAndGet());\n  }\n\n  @BeforeEach\n  void setUp() {\n    account = mock(Account.class);\n    device = mock(Device.class);\n    messagesManager = mock(MessagesManager.class);\n    receiptSender = mock(ReceiptSender.class);\n    messageDeliveryScheduler = Schedulers.newBoundedElastic(10, 10_000, \"messageDelivery\");\n    clientReleaseManager = mock(ClientReleaseManager.class);\n\n    ON_ERROR_DROPPED_COUNTER.set(0);\n  }\n\n  @AfterEach\n  void tearDown() {\n    StepVerifier.resetDefaultTimeout();\n    messageDeliveryScheduler.dispose();\n\n    assertEquals(0, ON_ERROR_DROPPED_COUNTER.get(),\n        \"Errors dropped during test\");\n  }\n\n  @AfterAll\n  static void tearDownAfterAll() {\n    Hooks.resetOnErrorDropped();\n  }\n\n  private WebSocketConnection buildWebSocketConnection(final WebSocketClient client) {\n    return new WebSocketConnection(receiptSender,\n        messagesManager,\n        new MessageMetrics(),\n        mock(PushNotificationManager.class),\n        mock(PushNotificationScheduler.class),\n        account,\n        device,\n        client,\n        Schedulers.immediate(),\n        clientReleaseManager,\n        mock(MessageDeliveryLoopMonitor.class),\n        mock(ExperimentEnrollmentManager.class));\n  }\n\n  @Test\n  void testSendMessages() {\n\n    final UUID destinationAccountIdentifier = UUID.randomUUID();\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(destinationAccountIdentifier);\n\n    final byte deviceId = 2;\n    when(device.getId()).thenReturn(deviceId);\n\n    final Envelope successfulMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 1, \"Success\");\n    final Envelope secondSuccessfulMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 2, \"Second success\");\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(Flux.just(\n            new MessageStreamEntry.Envelope(successfulMessage),\n            new MessageStreamEntry.QueueEmpty(),\n            new MessageStreamEntry.Envelope(secondSuccessfulMessage))));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(account.getIdentifier(IdentityType.ACI), device))\n        .thenReturn(messageStream);\n\n    when(messagesManager.mayHaveMessages(any(), any())).thenReturn(CompletableFuture.completedFuture(false));\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n\n    when(client.isOpen()).thenReturn(true);\n\n    when(client.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(successResponse));\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n    webSocketConnection.start();\n\n    verify(client).sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(successfulMessage))));\n\n    verify(client).sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(secondSuccessfulMessage))));\n\n    verify(messageStream).acknowledgeMessage(successfulMessage);\n    verify(messageStream).acknowledgeMessage(secondSuccessfulMessage);\n\n    verify(receiptSender)\n        .sendReceipt(new AciServiceIdentifier(destinationAccountIdentifier),\n            deviceId,\n            AciServiceIdentifier.valueOf(successfulMessage.getSourceServiceId()),\n            successfulMessage.getClientTimestamp());\n\n    verify(receiptSender)\n        .sendReceipt(new AciServiceIdentifier(destinationAccountIdentifier),\n            deviceId,\n            AciServiceIdentifier.valueOf(secondSuccessfulMessage.getSourceServiceId()),\n            secondSuccessfulMessage.getClientTimestamp());\n\n    webSocketConnection.stop();\n\n    verify(client).sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(), eq(Optional.empty()));\n    verify(client).close(eq(1000), anyString());\n  }\n\n  @Test\n  void testSendMessagesWithError() {\n\n    final UUID destinationAccountIdentifier = UUID.randomUUID();\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(destinationAccountIdentifier);\n\n    final byte deviceId = 2;\n    when(device.getId()).thenReturn(deviceId);\n\n    final Envelope successfulMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 1, \"Success\");\n    final Envelope failedMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 2, \"Failed\");\n    final Envelope secondSuccessfulMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 3, \"Second success\");\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(Flux.just(\n            new MessageStreamEntry.Envelope(successfulMessage),\n            new MessageStreamEntry.Envelope(failedMessage),\n            new MessageStreamEntry.QueueEmpty(),\n            new MessageStreamEntry.Envelope(secondSuccessfulMessage))));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(account.getIdentifier(IdentityType.ACI), device))\n        .thenReturn(messageStream);\n\n    when(messagesManager.mayHaveMessages(any(), any())).thenReturn(CompletableFuture.completedFuture(false));\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n\n    when(client.isOpen()).thenReturn(true);\n\n    when(client.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(successResponse));\n\n    when(client.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), any(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(failedMessage)))))\n        .thenReturn(CompletableFuture.failedFuture(new RedisCommandTimeoutException()));\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n    webSocketConnection.start();\n\n    verify(client).sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(successfulMessage))));\n\n    verify(client).sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(failedMessage))));\n\n    verify(client, never()).sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(secondSuccessfulMessage))));\n\n    verify(messageStream).acknowledgeMessage(successfulMessage);\n    verify(messageStream, never()).acknowledgeMessage(secondSuccessfulMessage);\n\n    verify(receiptSender)\n        .sendReceipt(new AciServiceIdentifier(destinationAccountIdentifier),\n            deviceId,\n            AciServiceIdentifier.valueOf(successfulMessage.getSourceServiceId()),\n            successfulMessage.getClientTimestamp());\n\n    verify(receiptSender, never())\n        .sendReceipt(new AciServiceIdentifier(destinationAccountIdentifier),\n            deviceId,\n            AciServiceIdentifier.valueOf(failedMessage.getSourceServiceId()),\n            failedMessage.getClientTimestamp());\n\n    verify(receiptSender, never())\n        .sendReceipt(new AciServiceIdentifier(destinationAccountIdentifier),\n            deviceId,\n            AciServiceIdentifier.valueOf(secondSuccessfulMessage.getSourceServiceId()),\n            secondSuccessfulMessage.getClientTimestamp());\n\n    verify(client, timeout(500)).close(eq(1011), anyString());\n    verify(client, never()).sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(), eq(Optional.empty()));\n  }\n\n  @Test\n  void testQueueEmptySignalOrder() {\n\n    final UUID destinationAccountIdentifier = UUID.randomUUID();\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(destinationAccountIdentifier);\n\n    final byte deviceId = 2;\n    when(device.getId()).thenReturn(deviceId);\n\n    final Envelope initialMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 1, \"Initial message\");\n    final Envelope afterQueueDrainMessage = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 2, \"After queue drained\");\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(Flux.just(\n            new MessageStreamEntry.Envelope(initialMessage),\n            new MessageStreamEntry.QueueEmpty(),\n            new MessageStreamEntry.Envelope(afterQueueDrainMessage))));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(account.getIdentifier(IdentityType.ACI), device))\n        .thenReturn(messageStream);\n\n    when(messagesManager.mayHaveMessages(any(), any())).thenReturn(CompletableFuture.completedFuture(false));\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n\n    when(client.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), any(), any()))\n        .thenAnswer(_ -> CompletableFuture.supplyAsync(() -> successResponse,\n            CompletableFuture.delayedExecutor(100, TimeUnit.MILLISECONDS)));\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n    webSocketConnection.start();\n\n    final InOrder inOrder = inOrder(client, messageStream);\n\n    // Sending the initial message will succeed after a delay, at which point we'll acknowledge the message. Make sure\n    // we wait for that process to complete before sending the \"queue empty\" signal\n    inOrder.verify(messageStream, timeout(1_000)).acknowledgeMessage(initialMessage);\n    inOrder.verify(client, timeout(1_000)).sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(), eq(Optional.empty()));\n\n    webSocketConnection.stop();\n    verify(client).close(eq(1000), anyString());\n  }\n\n  @Test\n  void testConflictingConsumerSignalOrder() {\n\n    final UUID destinationAccountIdentifier = UUID.randomUUID();\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(destinationAccountIdentifier);\n\n    final byte deviceId = 2;\n    when(device.getId()).thenReturn(deviceId);\n\n    final Envelope message = createMessage(UUID.randomUUID(), destinationAccountIdentifier, 1, \"Initial message\");\n\n    final MessageStream messageStream = mock(MessageStream.class);\n    final TestPublisher<MessageStreamEntry> testPublisher = TestPublisher.createCold();\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(testPublisher));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(account.getIdentifier(IdentityType.ACI), device))\n        .thenReturn(messageStream);\n\n    when(messagesManager.mayHaveMessages(any(), any())).thenReturn(CompletableFuture.completedFuture(false));\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n\n    when(client.sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), any(), any()))\n        .thenReturn(new CompletableFuture<>());\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n    webSocketConnection.start();\n\n    testPublisher.next(new MessageStreamEntry.Envelope(message));\n    testPublisher.error(new ConflictingMessageConsumerException());\n\n    final InOrder inOrder = inOrder(client, messageStream);\n\n    // A \"conflicting consumer\" should close the socket as soon as possible (i.e. even if messages are still getting\n    // processed)\n    inOrder.verify(client).sendRequest(eq(\"PUT\"), eq(\"/api/v1/message\"), anyList(), argThat(body ->\n        body.isPresent() && Arrays.equals(body.get(), WebSocketConnection.serializeMessage(message))));\n\n    verify(client).close(eq(4409), anyString());\n  }\n\n  @Test\n  void testSendMessagesEmptyQueue() {\n    final UUID accountUuid = UUID.randomUUID();\n\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountUuid);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(Flux.just(new MessageStreamEntry.QueueEmpty())));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(accountUuid, device)).thenReturn(messageStream);\n\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n    when(client.isOpen()).thenReturn(true);\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n\n    webSocketConnection.start();\n\n    verify(client, timeout(1_000)).sendRequest(eq(\"PUT\"), eq(\"/api/v1/queue/empty\"), anyList(), eq(Optional.empty()));\n  }\n\n  @Test\n  void testSendMessagesConflictingConsumer() {\n    final UUID accountUuid = UUID.randomUUID();\n\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountUuid);\n    when(device.getId()).thenReturn(Device.PRIMARY_ID);\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(Flux.error(new ConflictingMessageConsumerException())));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(accountUuid, device)).thenReturn(messageStream);\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n    when(client.isOpen()).thenReturn(true);\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n    webSocketConnection.start();\n\n    verify(client, timeout(1_000)).close(eq(4409), anyString());\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void testSendMessagesRetrievalException(final boolean clientOpen) {\n    final UUID accountUuid = UUID.randomUUID();\n\n    when(device.getId()).thenReturn((byte) 2);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountUuid);\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(Flux.error(new RedisException(\"OH NO\"))));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(accountUuid, device)).thenReturn(messageStream);\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n    when(client.isOpen()).thenReturn(clientOpen);\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n    webSocketConnection.start();\n\n    if (clientOpen) {\n      verify(client).close(eq(1011), anyString());\n    } else {\n      verify(client, never()).close(anyInt(), any());\n    }\n  }\n\n  @Test\n  void testReactivePublisherLimitRate() {\n    final UUID accountUuid = UUID.randomUUID();\n\n    final byte deviceId = 2;\n    when(device.getId()).thenReturn(deviceId);\n\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountUuid);\n\n    final int totalMessages = 1000;\n\n    final TestPublisher<MessageStreamEntry> testPublisher = TestPublisher.createCold();\n    final Flux<MessageStreamEntry> flux = Flux.from(testPublisher);\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(flux));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(accountUuid, device)).thenReturn(messageStream);\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n    when(client.isOpen()).thenReturn(true);\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n    when(client.sendRequest(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(successResponse));\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n\n    webSocketConnection.start();\n\n    StepVerifier.setDefaultTimeout(Duration.ofSeconds(5));\n\n    StepVerifier.create(flux, 0)\n        .expectSubscription()\n        .thenRequest(totalMessages * 2)\n        .then(() -> {\n          for (long i = 0; i < totalMessages; i++) {\n            testPublisher.next(new MessageStreamEntry.Envelope(createMessage(UUID.randomUUID(), accountUuid, 1111 * i + 1, \"message \" + i)));\n          }\n          testPublisher.complete();\n        })\n        .expectNextCount(totalMessages)\n        .expectComplete()\n        .log()\n        .verify();\n\n    testPublisher.assertMaxRequested(WebSocketConnection.MESSAGE_PUBLISHER_LIMIT_RATE);\n  }\n\n  @Test\n  void testReactivePublisherDisposedWhenConnectionStopped() {\n    final UUID accountUuid = UUID.randomUUID();\n\n    final byte deviceId = 2;\n    when(device.getId()).thenReturn(deviceId);\n\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountUuid);\n\n    final AtomicBoolean canceled = new AtomicBoolean();\n\n    final Flux<MessageStreamEntry> flux = Flux.create(s -> {\n      s.onRequest(n -> {\n        // the subscriber should request more than 1 message, but we will only send one, so that\n        // we are sure the subscriber is waiting for more when we stop the connection\n        assert n > 1;\n        s.next(new MessageStreamEntry.Envelope(createMessage(UUID.randomUUID(), UUID.randomUUID(), 1111, \"first\")));\n      });\n\n      s.onCancel(() -> canceled.set(true));\n    });\n\n    final MessageStream messageStream = mock(MessageStream.class);\n\n    when(messageStream.getMessages())\n        .thenReturn(JdkFlowAdapter.publisherToFlowPublisher(flux));\n\n    when(messageStream.acknowledgeMessage(any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    when(messagesManager.getMessages(accountUuid, device)).thenReturn(messageStream);\n    when(messagesManager.mayHaveMessages(any(), any())).thenReturn(CompletableFuture.completedFuture(false));\n\n    final WebSocketResponseMessage successResponse = mock(WebSocketResponseMessage.class);\n    when(successResponse.getStatus()).thenReturn(200);\n\n    final WebSocketClient client = mock(WebSocketClient.class);\n    when(client.isOpen()).thenReturn(true);\n    when(client.sendRequest(any(), any(), any(), any())).thenReturn(CompletableFuture.completedFuture(successResponse));\n\n    final WebSocketConnection webSocketConnection = buildWebSocketConnection(client);\n\n    webSocketConnection.start();\n\n    verify(client).sendRequest(any(), any(), any(), any());\n\n    // close the connection before the publisher completes\n    webSocketConnection.stop();\n\n    StepVerifier.setDefaultTimeout(Duration.ofSeconds(2));\n\n    StepVerifier.create(flux)\n        .expectSubscription()\n        .expectNextCount(1)\n        .then(() -> assertTrue(canceled.get()))\n        // this is not entirely intuitive, but expecting a timeout is the recommendation for verifying cancellation\n        .expectTimeout(Duration.ofMillis(100))\n        .log()\n        .verify();\n  }\n\n  private static Envelope createMessage(final UUID senderUuid,\n      final UUID destinationUuid,\n      final long timestamp,\n      final String content) {\n\n    return Envelope.newBuilder()\n        .setServerGuid(UUID.randomUUID().toString())\n        .setType(Envelope.Type.CIPHERTEXT)\n        .setClientTimestamp(timestamp)\n        .setServerTimestamp(0)\n        .setSourceServiceId(senderUuid.toString())\n        .setSourceDevice(SOURCE_DEVICE_ID)\n        .setDestinationServiceId(destinationUuid.toString())\n        .setContent(ByteString.copyFrom(content.getBytes(StandardCharsets.UTF_8)))\n        .build();\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/FinishPushNotificationExperimentCommandTest.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.doAnswer;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSample;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\nimport software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException;\n\nclass FinishPushNotificationExperimentCommandTest {\n\n  private CommandDependencies commandDependencies;\n  private PushNotificationExperiment<String> experiment;\n\n  private FinishPushNotificationExperimentCommand<String> finishPushNotificationExperimentCommand;\n\n  private static final String EXPERIMENT_NAME = \"test\";\n\n  private static final Namespace NAMESPACE =\n      new Namespace(Map.of(FinishPushNotificationExperimentCommand.MAX_CONCURRENCY_ARGUMENT, 1));\n\n  private static class TestFinishPushNotificationExperimentCommand extends FinishPushNotificationExperimentCommand<String> {\n\n    public TestFinishPushNotificationExperimentCommand(final PushNotificationExperiment<String> experiment) {\n      super(\"test-finish-push-notification-experiment\",\n          \"Test start push notification experiment command\",\n          (ignoredDependencies, ignoredConfiguration) -> experiment);\n    }\n  }\n\n  @BeforeEach\n  void setUp() {\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n\n    final PushNotificationExperimentSamples pushNotificationExperimentSamples =\n        mock(PushNotificationExperimentSamples.class);\n\n    when(pushNotificationExperimentSamples.recordFinalState(any(), anyByte(), any(), any()))\n        .thenAnswer(invocation -> {\n          final UUID accountIdentifier = invocation.getArgument(0);\n          final byte deviceId = invocation.getArgument(1);\n\n          return CompletableFuture.completedFuture(\n              new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", \"test\"));\n        });\n\n    commandDependencies = new CommandDependencies(accountsManager,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        pushNotificationExperimentSamples,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null,\n        null);\n\n    //noinspection unchecked\n    experiment = mock(PushNotificationExperiment.class);\n    when(experiment.getExperimentName()).thenReturn(EXPERIMENT_NAME);\n    when(experiment.getState(any(), any())).thenReturn(\"test\");\n    when(experiment.getStateClass()).thenReturn(String.class);\n\n    doAnswer(invocation -> {\n      final Flux<PushNotificationExperimentSample<String>> samples = invocation.getArgument(0);\n      samples.then().block();\n\n      return null;\n    }).when(experiment).analyzeResults(any());\n\n    finishPushNotificationExperimentCommand = new TestFinishPushNotificationExperimentCommand(experiment);\n  }\n\n  @Test\n  void run() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n\n    final Account account = mock(Account.class);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    when(commandDependencies.accountsManager().getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", null)));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(experiment).getState(account, device);\n    verify(commandDependencies.pushNotificationExperimentSamples())\n        .recordFinalState(eq(accountIdentifier), eq(deviceId), eq(EXPERIMENT_NAME), any());\n  }\n\n  @Test\n  void runMissingAccount() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    when(commandDependencies.accountsManager().getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.completedFuture(Optional.empty()));\n\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", null)));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(experiment).getState(null, null);\n    verify(commandDependencies.pushNotificationExperimentSamples())\n        .recordFinalState(eq(accountIdentifier), eq(deviceId), eq(EXPERIMENT_NAME), any());\n  }\n\n  @Test\n  void runMissingDevice() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Account account = mock(Account.class);\n    when(account.getDevice(deviceId)).thenReturn(Optional.empty());\n\n    when(commandDependencies.accountsManager().getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", null)));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(experiment).getState(account, null);\n    verify(commandDependencies.pushNotificationExperimentSamples())\n        .recordFinalState(eq(accountIdentifier), eq(deviceId), eq(EXPERIMENT_NAME), any());\n  }\n\n  @Test\n  void runAccountFetchRetry() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n\n    final Account account = mock(Account.class);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    when(commandDependencies.accountsManager().getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", null)));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(experiment).getState(account, device);\n    verify(commandDependencies.pushNotificationExperimentSamples())\n        .recordFinalState(eq(accountIdentifier), eq(deviceId), eq(EXPERIMENT_NAME), any());\n\n    verify(commandDependencies.accountsManager(), times(3)).getByAccountIdentifierAsync(accountIdentifier);\n  }\n\n  @Test\n  void runStoreSampleRetry() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n\n    final Account account = mock(Account.class);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    when(commandDependencies.accountsManager().getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", null)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().recordFinalState(any(), anyByte(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()))\n        .thenReturn(CompletableFuture.completedFuture(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", \"test\")));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(experiment).getState(account, device);\n    verify(commandDependencies.pushNotificationExperimentSamples(), times(3))\n        .recordFinalState(eq(accountIdentifier), eq(deviceId), eq(EXPERIMENT_NAME), any());\n  }\n\n  @Test\n  void runMissingInitialSample() {\n    final UUID accountIdentifier = UUID.randomUUID();\n    final byte deviceId = Device.PRIMARY_ID;\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n\n    final Account account = mock(Account.class);\n    when(account.getDevice(deviceId)).thenReturn(Optional.of(device));\n\n    when(commandDependencies.accountsManager().getByAccountIdentifierAsync(accountIdentifier))\n        .thenReturn(CompletableFuture.completedFuture(Optional.of(account)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(accountIdentifier, deviceId, true, \"test\", null)));\n\n    when(commandDependencies.pushNotificationExperimentSamples().recordFinalState(any(), anyByte(), any(), any()))\n        .thenReturn(CompletableFuture.failedFuture(ConditionalCheckFailedException.builder().build()));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(experiment).getState(account, device);\n    verify(commandDependencies.pushNotificationExperimentSamples())\n        .recordFinalState(eq(accountIdentifier), eq(deviceId), eq(EXPERIMENT_NAME), any());\n  }\n\n  @Test\n  void runFinalSampleAlreadyRecorded() {\n    when(commandDependencies.pushNotificationExperimentSamples().getSamples(eq(EXPERIMENT_NAME), eq(String.class)))\n        .thenReturn(Flux.just(new PushNotificationExperimentSample<>(UUID.randomUUID(), Device.PRIMARY_ID, true, \"test\", \"test\")));\n\n    assertDoesNotThrow(() -> finishPushNotificationExperimentCommand.run(null, NAMESPACE, null, commandDependencies));\n    verify(commandDependencies.accountsManager(), never()).getByAccountIdentifier(any());\n    verify(experiment, never()).getState(any(), any());\n    verify(commandDependencies.pushNotificationExperimentSamples(), never())\n        .recordFinalState(any(), anyByte(), any(), any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/IdleWakeupEligibilityCheckerTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport com.google.i18n.phonenumbers.PhoneNumberUtil;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\n\npublic class IdleWakeupEligibilityCheckerTest {\n\n  private static final Instant CURRENT_TIME = Instant.now();\n  private final Clock clock = Clock.fixed(CURRENT_TIME, ZoneId.systemDefault());\n\n  private MessagesManager messagesManager;\n  private IdleWakeupEligibilityChecker idleChecker;\n\n  @BeforeEach\n  void setup() {\n    messagesManager = mock(MessagesManager.class);\n    idleChecker = new IdleWakeupEligibilityChecker(clock, messagesManager);\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void isDeviceEligible(final Account account,\n      final Device device,\n      final boolean mayHaveMessages,\n      final boolean mayHaveUrgentMessages,\n      final boolean expectEligible) {\n\n    when(messagesManager.mayHavePersistedMessages(account.getIdentifier(IdentityType.ACI), device))\n        .thenReturn(CompletableFuture.completedFuture(mayHaveMessages));\n\n    when(messagesManager.mayHaveUrgentPersistedMessages(account.getIdentifier(IdentityType.ACI), device))\n        .thenReturn(CompletableFuture.completedFuture(mayHaveUrgentMessages));\n\n    assertEquals(expectEligible, idleChecker.isDeviceEligible(account, device).join());\n  }\n\n  private static List<Arguments> isDeviceEligible() {\n    final List<Arguments> arguments = new ArrayList<>();\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID());\n    when(account.getNumber()).thenReturn(PhoneNumberUtil.getInstance().format(\n        PhoneNumberUtil.getInstance().getExampleNumber(\"US\"), PhoneNumberUtil.PhoneNumberFormat.E164));\n\n    {\n      // Long-idle device with push token and messages\n      final Device device = mock(Device.class);\n      when(device.getApnId()).thenReturn(\"apns-token\");\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, true, true, false));\n    }\n\n    {\n      // Long-idle device missing push token, but with messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, true, true, false));\n    }\n\n    {\n      // Long-idle device missing push token and messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, false, false, false));\n    }\n\n    {\n      // Long-idle device with push token, but no messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli());\n      when(device.getApnId()).thenReturn(\"apns-token\");\n\n      arguments.add(Arguments.of(account, device, false, false, true));\n    }\n\n    {\n      // Short-idle device with push token and urgent messages\n      final Device device = mock(Device.class);\n      when(device.getApnId()).thenReturn(\"apns-token\");\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, true, true, true));\n    }\n\n    {\n      // Short-idle device with push token and only non-urgent messages\n      final Device device = mock(Device.class);\n      when(device.getApnId()).thenReturn(\"apns-token\");\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, true, false, false));\n    }\n\n    {\n      // Short-idle device missing push token, but with urgent messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, true, true, false));\n    }\n\n    {\n      // Short-idle device missing push token and messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, false, false, false));\n    }\n\n    {\n      // Short-idle device with push token, but no messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(\n          CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION).toEpochMilli());\n      when(device.getApnId()).thenReturn(\"apns-token\");\n\n      arguments.add(Arguments.of(account, device, false, false, false));\n    }\n\n    {\n      // Active device with push token and urgent messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n      when(device.getApnId()).thenReturn(\"apns-token\");\n\n      arguments.add(Arguments.of(account, device, true, true, false));\n    }\n\n    {\n      // Active device missing push token, but with urgent messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, true, true, false));\n    }\n\n    {\n      // Active device missing push token and messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n\n      arguments.add(Arguments.of(account, device, false, false, false));\n    }\n\n    {\n      // Active device with push token, but no messages\n      final Device device = mock(Device.class);\n      when(device.getLastSeen()).thenReturn(CURRENT_TIME.toEpochMilli());\n      when(device.getApnId()).thenReturn(\"apns-token\");\n\n      arguments.add(Arguments.of(account, device, false, false, false));\n    }\n\n    return arguments;\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void isShortIdle(final Duration idleDuration, final boolean expectIdle) {\n    final Instant currentTime = Instant.now();\n    final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault());\n\n    final Device device = mock(Device.class);\n    when(device.getLastSeen()).thenReturn(currentTime.minus(idleDuration).toEpochMilli());\n\n    assertEquals(expectIdle, IdleWakeupEligibilityChecker.isShortIdle(device, clock));\n  }\n\n  private static List<Arguments> isShortIdle() {\n    return List.of(\n        Arguments.of(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION, true),\n        Arguments.of(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION.plusMillis(1), true),\n        Arguments.of(IdleWakeupEligibilityChecker.MIN_SHORT_IDLE_DURATION.minusMillis(1), false),\n        Arguments.of(IdleWakeupEligibilityChecker.MAX_SHORT_IDLE_DURATION, false),\n        Arguments.of(IdleWakeupEligibilityChecker.MAX_SHORT_IDLE_DURATION.plusMillis(1), false),\n        Arguments.of(IdleWakeupEligibilityChecker.MAX_SHORT_IDLE_DURATION.minusMillis(1), true)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void isLongIdle(final Duration idleDuration, final boolean expectIdle) {\n    final Instant currentTime = Instant.now();\n    final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault());\n\n    final Device device = mock(Device.class);\n    when(device.getLastSeen()).thenReturn(currentTime.minus(idleDuration).toEpochMilli());\n\n    assertEquals(expectIdle, IdleWakeupEligibilityChecker.isLongIdle(device, clock));\n  }\n\n  private static List<Arguments> isLongIdle() {\n    return List.of(\n        Arguments.of(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION, true),\n        Arguments.of(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION.plusMillis(1), true),\n        Arguments.of(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION.minusMillis(1), false),\n        Arguments.of(IdleWakeupEligibilityChecker.MAX_LONG_IDLE_DURATION, false),\n        Arguments.of(IdleWakeupEligibilityChecker.MAX_LONG_IDLE_DURATION.plusMillis(1), false),\n        Arguments.of(IdleWakeupEligibilityChecker.MAX_LONG_IDLE_DURATION.minusMillis(1), true)\n    );\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void hasPushToken(final Device device, final boolean expectHasPushToken) {\n    assertEquals(expectHasPushToken, IdleWakeupEligibilityChecker.hasPushToken(device));\n  }\n\n  private static List<Arguments> hasPushToken() {\n    final List<Arguments> arguments = new ArrayList<>();\n\n    {\n      // No token at all\n      final Device device = mock(Device.class);\n\n      arguments.add(Arguments.of(device, false));\n    }\n\n    {\n      // FCM token\n      final Device device = mock(Device.class);\n      when(device.getGcmId()).thenReturn(\"fcm-token\");\n\n      arguments.add(Arguments.of(device, true));\n    }\n\n    {\n      // APNs token\n      final Device device = mock(Device.class);\n      when(device.getApnId()).thenReturn(\"apns-token\");\n\n      arguments.add(Arguments.of(device, true));\n    }\n\n    return arguments;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/NotifyIdleDevicesCommandTest.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.push.IdleDeviceNotificationScheduler;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport org.whispersystems.textsecuregcm.storage.MessagesManager;\nimport reactor.core.publisher.Flux;\n\nclass NotifyIdleDevicesCommandTest {\n\n  private MessagesManager messagesManager;\n  private IdleDeviceNotificationScheduler idleDeviceNotificationScheduler;\n\n  private TestNotifyIdleDevicesCommand notifyIdleDevicesWithoutMessagesCommand;\n\n  private static final Instant CURRENT_TIME = Instant.now();\n\n  private static class TestNotifyIdleDevicesCommand extends NotifyIdleDevicesCommand {\n\n    private final CommandDependencies commandDependencies;\n    private final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler;\n\n    private boolean dryRun = false;\n\n    private TestNotifyIdleDevicesCommand(final MessagesManager messagesManager,\n        final IdleDeviceNotificationScheduler idleDeviceNotificationScheduler) {\n\n      this.commandDependencies = new CommandDependencies(\n          null,\n          null,\n          null,\n          null,\n          messagesManager,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null);\n\n      this.idleDeviceNotificationScheduler = idleDeviceNotificationScheduler;\n    }\n\n    public void setDryRun(final boolean dryRun) {\n      this.dryRun = dryRun;\n    }\n\n    @Override\n    protected CommandDependencies getCommandDependencies() {\n      return commandDependencies;\n    }\n\n    @Override\n    protected Clock getClock() {\n      return Clock.fixed(CURRENT_TIME, ZoneId.systemDefault());\n    }\n\n    @Override\n    protected IdleDeviceNotificationScheduler buildIdleDeviceNotificationScheduler() {\n      return idleDeviceNotificationScheduler;\n    }\n\n    @Override\n    protected Namespace getNamespace() {\n      return new Namespace(Map.of(\n          NotifyIdleDevicesCommand.MAX_CONCURRENCY_ARGUMENT, 1,\n          NotifyIdleDevicesCommand.DRY_RUN_ARGUMENT, dryRun));\n    }\n  }\n\n  @BeforeEach\n  void setUp() {\n    messagesManager = mock(MessagesManager.class);\n    idleDeviceNotificationScheduler = mock(IdleDeviceNotificationScheduler.class);\n\n    when(idleDeviceNotificationScheduler.scheduleNotification(any(), any(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    notifyIdleDevicesWithoutMessagesCommand =\n        new TestNotifyIdleDevicesCommand(messagesManager, idleDeviceNotificationScheduler);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccounts(final boolean dryRun) {\n    notifyIdleDevicesWithoutMessagesCommand.setDryRun(dryRun);\n\n    final UUID accountIdentifier = UUID.randomUUID();\n\n    final Device eligibleDevice = mock(Device.class);\n    when(eligibleDevice.getId()).thenReturn(Device.PRIMARY_ID);\n    when(eligibleDevice.getApnId()).thenReturn(\"apns-token\");\n    when(eligibleDevice.getLastSeen())\n        .thenReturn(CURRENT_TIME.minus(IdleWakeupEligibilityChecker.MIN_LONG_IDLE_DURATION).toEpochMilli());\n\n    final Device ineligibleDevice = mock(Device.class);\n    when(ineligibleDevice.getId()).thenReturn((byte) (Device.PRIMARY_ID + 1));\n\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(accountIdentifier);\n    when(account.getDevices()).thenReturn(List.of(eligibleDevice, ineligibleDevice));\n\n    when(messagesManager.mayHavePersistedMessages(accountIdentifier, eligibleDevice))\n        .thenReturn(CompletableFuture.completedFuture(false));\n\n    notifyIdleDevicesWithoutMessagesCommand.crawlAccounts(Flux.just(account));\n\n    if (dryRun) {\n      verify(idleDeviceNotificationScheduler, never()).scheduleNotification(account, eligibleDevice, NotifyIdleDevicesCommand.PREFERRED_NOTIFICATION_TIME);\n    } else {\n      verify(idleDeviceNotificationScheduler).scheduleNotification(account, eligibleDevice, NotifyIdleDevicesCommand.PREFERRED_NOTIFICATION_TIME);\n    }\n\n    verify(idleDeviceNotificationScheduler, never()).scheduleNotification(eq(account), eq(ineligibleDevice), any());\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/ProcessScheduledJobsServiceCommandTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertTrue;\n\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CountDownLatch;\nimport java.util.concurrent.Executors;\nimport java.util.concurrent.ScheduledExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport javax.annotation.Nullable;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.Timeout;\nimport org.whispersystems.textsecuregcm.scheduler.JobScheduler;\nimport reactor.core.publisher.Mono;\nimport reactor.test.publisher.TestPublisher;\n\n@Timeout(value = 5, threadMode = Timeout.ThreadMode.SEPARATE_THREAD)\nclass ProcessScheduledJobsServiceCommandTest {\n\n  private ScheduledExecutorService scheduledExecutorService;\n\n  @BeforeEach\n  void setUp() {\n    scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();\n  }\n\n  @Test\n  void testDisposeOnStopCancels() throws Exception {\n    // This test publisher will never emit any values or intentionally complete\n    final TestPublisher<Integer> testPublisher = TestPublisher.create();\n    final TestJobScheduler testJobScheduler = new TestJobScheduler(testPublisher);\n\n    final ProcessScheduledJobsServiceCommand.ScheduledJobProcessor scheduledJobProcessor =\n        new ProcessScheduledJobsServiceCommand.ScheduledJobProcessor(testJobScheduler, scheduledExecutorService, 60);\n\n    scheduledJobProcessor.start();\n    testJobScheduler.getStartLatch().await();\n\n    scheduledJobProcessor.stop();\n    testJobScheduler.getEndLatch().await();\n\n    scheduledExecutorService.shutdown();\n    assertTrue(scheduledExecutorService.awaitTermination(1, TimeUnit.SECONDS), \"The submitted task should complete\");\n\n    testPublisher.assertCancelled();\n  }\n\n  @Test\n  void testCompletedPublisher() throws Exception {\n    final TestPublisher<Integer> testPublisher = TestPublisher.create();\n    testPublisher.complete();\n\n    final TestJobScheduler testJobScheduler = new TestJobScheduler(testPublisher);\n\n    final ProcessScheduledJobsServiceCommand.ScheduledJobProcessor scheduledJobProcessor =\n        new ProcessScheduledJobsServiceCommand.ScheduledJobProcessor(testJobScheduler, scheduledExecutorService, 60);\n\n    scheduledJobProcessor.start();\n    testJobScheduler.getStartLatch().await();\n\n    scheduledJobProcessor.stop();\n    testJobScheduler.getEndLatch().await();\n\n    scheduledExecutorService.shutdown();\n    assertTrue(scheduledExecutorService.awaitTermination(1, TimeUnit.SECONDS), \"The submitted task should complete\");\n\n    testPublisher.assertNotCancelled();\n  }\n\n  private static class TestJobScheduler extends JobScheduler {\n\n    private final TestPublisher<Integer> testPublisher;\n    private final CountDownLatch startLatch = new CountDownLatch(1);\n    private final CountDownLatch endLatch = new CountDownLatch(1);\n\n    protected TestJobScheduler(TestPublisher<Integer> testPublisher) {\n      super(null, null, null, null);\n      this.testPublisher = testPublisher;\n    }\n\n    /**\n     * A {@link CountDownLatch} indicating whether the {@link Mono} returned by {@link #processAvailableJobs()} has been\n     * subscribed to.\n     */\n    public CountDownLatch getStartLatch() {\n      return startLatch;\n    }\n\n    /**\n     * A {@link CountDownLatch} indicating whether the {@link Mono} returned by {@link #processAvailableJobs()} has\n     * terminated or been canceled.\n     */\n    public CountDownLatch getEndLatch() {\n      return endLatch;\n    }\n\n    @Override\n    public String getSchedulerName() {\n      return \"test\";\n    }\n\n    @Override\n    protected CompletableFuture<String> processJob(@Nullable byte[] jobData) {\n      return CompletableFuture.failedFuture(new IllegalStateException(\"Not implemented\"));\n    }\n\n    @Override\n    public Mono<Void> processAvailableJobs() {\n      return testPublisher.flux()\n          .then()\n          .doOnSubscribe(ignored -> startLatch.countDown())\n          .doOnTerminate(endLatch::countDown)\n          .doOnCancel(endLatch::countDown);\n    }\n  }\n\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/RegenerateSecondaryDynamoDbTableDataCommandTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport java.util.Map;\nimport java.util.concurrent.CompletableFuture;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.DynamoDbRecoveryManager;\nimport reactor.core.publisher.Flux;\n\nclass RegenerateSecondaryDynamoDbTableDataCommandTest {\n\n  private DynamoDbRecoveryManager dynamoDbRecoveryManager;\n\n  private static class TestRegenerateSecondaryDynamoDbTableDataCommand extends RegenerateSecondaryDynamoDbTableDataCommand {\n\n    private final CommandDependencies commandDependencies;\n    private final Namespace namespace;\n\n    TestRegenerateSecondaryDynamoDbTableDataCommand(final DynamoDbRecoveryManager dynamoDbRecoveryManager, final boolean dryRun) {\n      commandDependencies = new CommandDependencies(null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          dynamoDbRecoveryManager);\n\n      namespace = new Namespace(Map.of(\n          RegenerateSecondaryDynamoDbTableDataCommand.DRY_RUN_ARGUMENT, dryRun,\n          RegenerateSecondaryDynamoDbTableDataCommand.MAX_CONCURRENCY_ARGUMENT, 16,\n          RegenerateSecondaryDynamoDbTableDataCommand.RETRIES_ARGUMENT, 3));\n    }\n\n    @Override\n    protected CommandDependencies getCommandDependencies() {\n      return commandDependencies;\n    }\n\n    @Override\n    protected Namespace getNamespace() {\n      return namespace;\n    }\n  }\n\n  @BeforeEach\n  void setUp() {\n    dynamoDbRecoveryManager = mock(DynamoDbRecoveryManager.class);\n\n    when(dynamoDbRecoveryManager.regenerateData(any())).thenReturn(CompletableFuture.completedFuture(null));\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccounts(final boolean dryRun) {\n    final Account account = mock(Account.class);\n\n    final RegenerateSecondaryDynamoDbTableDataCommand regenerateSecondaryDynamoDbTableDataCommand =\n        new TestRegenerateSecondaryDynamoDbTableDataCommand(dynamoDbRecoveryManager, dryRun);\n\n    regenerateSecondaryDynamoDbTableDataCommand.crawlAccounts(Flux.just(account));\n\n    if (!dryRun) {\n      verify(dynamoDbRecoveryManager).regenerateData(account);\n    }\n\n    verifyNoMoreInteractions(dynamoDbRecoveryManager);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredAccountsCommandTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Clock;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.stream.Stream;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport reactor.core.publisher.Flux;\n\nclass RemoveExpiredAccountsCommandTest {\n\n  private static class TestRemoveExpiredAccountsCommand extends RemoveExpiredAccountsCommand {\n\n    private final CommandDependencies commandDependencies;\n    private final Namespace namespace;\n\n    public TestRemoveExpiredAccountsCommand(final Clock clock, final AccountsManager accountsManager, final boolean isDryRun) {\n      super(clock);\n\n      commandDependencies = mock(CommandDependencies.class);\n      when(commandDependencies.accountsManager()).thenReturn(accountsManager);\n\n      namespace = mock(Namespace.class);\n      when(namespace.getBoolean(RemoveExpiredAccountsCommand.DRY_RUN_ARGUMENT)).thenReturn(isDryRun);\n    }\n\n    @Override\n    protected CommandDependencies getCommandDependencies() {\n      return commandDependencies;\n    }\n\n    @Override\n    protected Namespace getNamespace() {\n      return namespace;\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccounts(final boolean isDryRun) {\n    final Clock clock = Clock.fixed(Instant.now(), ZoneId.systemDefault());\n\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n\n    final RemoveExpiredAccountsCommand removeExpiredAccountsCommand =\n        new TestRemoveExpiredAccountsCommand(clock, accountsManager, isDryRun);\n\n    final Account activeAccount = mock(Account.class);\n    when(activeAccount.getLastSeen()).thenReturn(clock.instant().toEpochMilli());\n\n    final Account expiredAccount = mock(Account.class);\n    when(expiredAccount.getLastSeen())\n        .thenReturn(clock.instant().minus(RemoveExpiredAccountsCommand.MAX_IDLE_DURATION).minusMillis(1).toEpochMilli());\n\n    removeExpiredAccountsCommand.crawlAccounts(Flux.just(activeAccount, expiredAccount));\n\n    if (isDryRun) {\n      verify(accountsManager, never()).delete(any(), any());\n    } else {\n      verify(accountsManager).delete(expiredAccount, AccountsManager.DeletionReason.EXPIRED);\n      verify(accountsManager, never()).delete(eq(activeAccount), any());\n    }\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void isExpired(final Instant currentTime, final Instant lastSeen, final boolean expectExpired) {\n    final Clock clock = Clock.fixed(currentTime, ZoneId.systemDefault());\n\n    final Account account = mock(Account.class);\n    when(account.getLastSeen()).thenReturn(lastSeen.toEpochMilli());\n\n    assertEquals(expectExpired, new RemoveExpiredAccountsCommand(clock).isExpired(account));\n  }\n\n  private static Stream<Arguments> isExpired() {\n    final Instant currentTime = Instant.now();\n\n    return Stream.of(\n        Arguments.of(currentTime, currentTime, false),\n        Arguments.of(currentTime, currentTime.minus(RemoveExpiredAccountsCommand.MAX_IDLE_DURATION).plusMillis(1), false),\n        Arguments.of(currentTime, currentTime.minus(RemoveExpiredAccountsCommand.MAX_IDLE_DURATION), true),\n        Arguments.of(currentTime, currentTime.minus(RemoveExpiredAccountsCommand.MAX_IDLE_DURATION).minusMillis(1), true)\n    );\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredLinkedDevicesCommandTest.java",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\nimport java.util.List;\nimport java.util.Set;\nimport java.util.stream.Stream;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.Arguments;\nimport org.junit.jupiter.params.provider.MethodSource;\nimport org.whispersystems.textsecuregcm.storage.Device;\n\nclass RemoveExpiredLinkedDevicesCommandTest {\n\n  public static Stream<Arguments> getDeviceIdsToRemove() {\n    final Device primary = device(Device.PRIMARY_ID, false);\n\n    final byte expiredDevice2Id = 2;\n    final Device expiredDevice2 = device(expiredDevice2Id, true);\n\n    final byte deviceId3 = 3;\n    final Device device3 = device(deviceId3, false);\n\n    final Device expiredPrimary = device(Device.PRIMARY_ID, true);\n\n    return Stream.of(\n        Arguments.of(List.of(primary), Set.of()),\n        Arguments.of(List.of(primary, expiredDevice2), Set.of(expiredDevice2Id)),\n        Arguments.of(List.of(primary, expiredDevice2, device3), Set.of(expiredDevice2Id)),\n        Arguments.of(List.of(expiredPrimary, expiredDevice2, device3), Set.of(expiredDevice2Id))\n    );\n  }\n\n  private static Device device(byte id, boolean expired) {\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(id);\n    when(device.isExpired()).thenReturn(expired);\n    when(device.isPrimary()).thenCallRealMethod();\n    return device;\n  }\n\n  @ParameterizedTest\n  @MethodSource\n  void getDeviceIdsToRemove(final List<Device> devices, final Set<Byte> expectedIds) {\n    assertEquals(expectedIds, RemoveExpiredLinkedDevicesCommand.getExpiredLinkedDeviceIds(devices));\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveExpiredUsernameHoldsCommandTest.java",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.argThat;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoInteractions;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Iterator;\nimport java.util.List;\nimport java.util.concurrent.ThreadLocalRandom;\nimport java.util.function.Consumer;\nimport java.util.stream.IntStream;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.mockito.ArgumentCaptor;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport org.whispersystems.textsecuregcm.util.TestRandomUtil;\nimport reactor.core.publisher.Flux;\n\nclass RemoveExpiredUsernameHoldsCommandTest {\n\n  private static class TestRemoveExpiredUsernameHoldsCommand extends RemoveExpiredUsernameHoldsCommand {\n\n    private final CommandDependencies commandDependencies;\n    private final Namespace namespace;\n\n    public TestRemoveExpiredUsernameHoldsCommand(final Clock clock, final AccountsManager accountsManager,\n        final boolean isDryRun) {\n      super(clock);\n\n      commandDependencies = mock(CommandDependencies.class);\n      when(commandDependencies.accountsManager()).thenReturn(accountsManager);\n\n      namespace = mock(Namespace.class);\n      when(namespace.getBoolean(RemoveExpiredUsernameHoldsCommand.DRY_RUN_ARGUMENT)).thenReturn(isDryRun);\n      when(namespace.getInt(RemoveExpiredUsernameHoldsCommand.MAX_CONCURRENCY_ARGUMENT)).thenReturn(16);\n    }\n\n    @Override\n    protected CommandDependencies getCommandDependencies() {\n      return commandDependencies;\n    }\n\n    @Override\n    protected Namespace getNamespace() {\n      return namespace;\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccounts(final boolean isDryRun) {\n    final TestClock clock = TestClock.pinned(Instant.EPOCH.plus(Duration.ofSeconds(1)));\n\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n    final RemoveExpiredUsernameHoldsCommand removeExpiredUsernameHoldsCommand =\n        new TestRemoveExpiredUsernameHoldsCommand(clock, accountsManager, isDryRun);\n\n    final Account hasHolds = mock(Account.class);\n    final List<Account.UsernameHold> originalHolds = List.of(\n        // expired\n        new Account.UsernameHold(TestRandomUtil.nextBytes(32), Instant.EPOCH.getEpochSecond()),\n        // not expired\n        new Account.UsernameHold(TestRandomUtil.nextBytes(32),\n            Instant.EPOCH.plus(Duration.ofSeconds(5)).getEpochSecond()));\n    when(hasHolds.getUsernameHolds()).thenReturn(originalHolds);\n    final Account noHolds = mock(Account.class);\n\n    removeExpiredUsernameHoldsCommand.crawlAccounts(Flux.just(hasHolds, noHolds));\n\n    if (isDryRun) {\n      verifyNoInteractions(accountsManager);\n    } else {\n      ArgumentCaptor<Consumer<Account>> updaterCaptor = ArgumentCaptor.forClass(Consumer.class);\n      verify(accountsManager, times(1)).update(eq(hasHolds), updaterCaptor.capture());\n      final Consumer<Account> consumer = updaterCaptor.getValue();\n      consumer.accept(hasHolds);\n      verify(hasHolds, times(1)).setUsernameHolds(argThat(holds ->\n          holds.equals(List.of(originalHolds.getLast()))));\n      verifyNoMoreInteractions(accountsManager);\n    }\n  }\n\n  @Test\n  public void removeHolds() {\n    final List<Account.UsernameHold> holds = IntStream.range(0, 100)\n        .mapToObj(i -> new Account.UsernameHold(TestRandomUtil.nextBytes(32), i)).toList();\n    final List<Account.UsernameHold> shuffled = new ArrayList<>(holds);\n    Collections.shuffle(shuffled);\n\n    final int currentTime = ThreadLocalRandom.current().nextInt(0, 100);\n    final Clock clock = TestClock.pinned(Instant.EPOCH.plus(Duration.ofSeconds(currentTime)));\n    final RemoveExpiredUsernameHoldsCommand removeExpiredUsernameHoldsCommand =\n        new TestRemoveExpiredUsernameHoldsCommand(clock, mock(AccountsManager.class), false);\n\n    final List<Account.UsernameHold> actual = new ArrayList<>(shuffled);\n    final int numRemoved = removeExpiredUsernameHoldsCommand.removeExpired(actual);\n\n    assertThat(numRemoved).isEqualTo(currentTime);\n    assertThat(actual).hasSize(100 - currentTime);\n\n    // should preserve order\n    final Iterator<Account.UsernameHold> expected = shuffled.iterator();\n    for (Account.UsernameHold hold : actual) {\n      while (!Arrays.equals(expected.next().usernameHash(), hold.usernameHash())) {\n        assertThat(expected).as(\"expected should be in order\").hasNext();\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/RemoveOrphanedPreKeyPagesCommandTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.anyInt;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.core.setup.Environment;\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.assertj.core.api.Assertions;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.junitpioneer.jupiter.cartesian.CartesianTest;\nimport org.whispersystems.textsecuregcm.WhisperServerConfiguration;\nimport org.whispersystems.textsecuregcm.storage.DeviceKEMPreKeyPages;\nimport org.whispersystems.textsecuregcm.storage.KeysManager;\nimport org.whispersystems.textsecuregcm.util.TestClock;\nimport reactor.core.publisher.Flux;\n\npublic class RemoveOrphanedPreKeyPagesCommandTest {\n\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  public void removeStalePages(boolean dryRun) throws Exception {\n    final TestClock clock = TestClock.pinned(Instant.EPOCH.plus(Duration.ofSeconds(10)));\n    final KeysManager keysManager = mock(KeysManager.class);\n\n    final UUID currentPage = UUID.randomUUID();\n    final UUID freshOrphanedPage = UUID.randomUUID();\n    final UUID staleOrphanedPage = UUID.randomUUID();\n\n    when(keysManager.listStoredKEMPreKeyPages(anyInt())).thenReturn(Flux.fromIterable(List.of(\n        new DeviceKEMPreKeyPages(UUID.randomUUID(), (byte) 1, Optional.of(currentPage), Map.of(\n            currentPage, Instant.EPOCH,\n            staleOrphanedPage, Instant.EPOCH.plus(Duration.ofSeconds(4)),\n            freshOrphanedPage, Instant.EPOCH.plus(Duration.ofSeconds(5)))))));\n\n    when(keysManager.pruneDeadPage(any(), anyByte(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    runCommand(clock, Duration.ofSeconds(5), dryRun, keysManager);\n    verify(keysManager, times(dryRun ? 0 : 1))\n        .pruneDeadPage(any(), eq((byte) 1), eq(staleOrphanedPage));\n    verify(keysManager, times(1)).listStoredKEMPreKeyPages(anyInt());\n    verifyNoMoreInteractions(keysManager);\n  }\n\n  @Test\n  public void noCurrentPage() throws Exception {\n    final TestClock clock = TestClock.pinned(Instant.EPOCH.plus(Duration.ofSeconds(10)));\n    final KeysManager keysManager = mock(KeysManager.class);\n\n    final UUID freshOrphanedPage = UUID.randomUUID();\n    final UUID staleOrphanedPage = UUID.randomUUID();\n\n    when(keysManager.listStoredKEMPreKeyPages(anyInt())).thenReturn(Flux.fromIterable(List.of(\n        new DeviceKEMPreKeyPages(UUID.randomUUID(), (byte) 1, Optional.empty(), Map.of(\n            staleOrphanedPage, Instant.EPOCH.plus(Duration.ofSeconds(4)),\n            freshOrphanedPage, Instant.EPOCH.plus(Duration.ofSeconds(5)))))));\n\n    when(keysManager.pruneDeadPage(any(), anyByte(), any()))\n        .thenReturn(CompletableFuture.completedFuture(null));\n\n    runCommand(clock, Duration.ofSeconds(5), false, keysManager);\n    verify(keysManager, times(1))\n        .pruneDeadPage(any(), eq((byte) 1), eq(staleOrphanedPage));\n    verify(keysManager, times(1)).listStoredKEMPreKeyPages(anyInt());\n    verifyNoMoreInteractions(keysManager);\n  }\n\n  @Test\n  public void noPages() throws Exception {\n    final TestClock clock = TestClock.pinned(Instant.EPOCH);\n    final KeysManager keysManager = mock(KeysManager.class);\n    when(keysManager.listStoredKEMPreKeyPages(anyInt())).thenReturn(Flux.empty());\n    runCommand(clock, Duration.ofSeconds(5), false, keysManager);\n    verify(keysManager).listStoredKEMPreKeyPages(anyInt());\n    verifyNoMoreInteractions(keysManager);\n  }\n\n  private enum PageStatus {NO_CURRENT, MATCH_CURRENT, MISMATCH_CURRENT}\n\n  @CartesianTest\n  void shouldDeletePage(\n      @CartesianTest.Enum final PageStatus pageStatus,\n      @CartesianTest.Values(booleans = {false, true}) final boolean isOld) {\n    final Optional<UUID> currentPage = pageStatus == PageStatus.NO_CURRENT\n        ? Optional.empty()\n        : Optional.of(UUID.randomUUID());\n    final UUID page = switch (pageStatus) {\n      case MATCH_CURRENT -> currentPage.orElseThrow();\n      case NO_CURRENT, MISMATCH_CURRENT -> UUID.randomUUID();\n    };\n\n    final Instant threshold = Instant.EPOCH.plus(Duration.ofSeconds(10));\n    final Instant lastModified = isOld ? threshold.minus(Duration.ofSeconds(1)) : threshold;\n\n    final boolean shouldDelete = pageStatus != PageStatus.MATCH_CURRENT && isOld;\n    Assertions.assertThat(RemoveOrphanedPreKeyPagesCommand.shouldDeletePage(currentPage, page, threshold, lastModified))\n        .isEqualTo(shouldDelete);\n  }\n\n\n  private void runCommand(final Clock clock, final Duration minimumOrphanAge, final boolean dryRun,\n      final KeysManager keysManager) throws Exception {\n    final CommandDependencies commandDependencies = mock(CommandDependencies.class);\n    when(commandDependencies.keysManager()).thenReturn(keysManager);\n\n    final Namespace namespace = mock(Namespace.class);\n    when(namespace.getBoolean(RemoveOrphanedPreKeyPagesCommand.DRY_RUN_ARGUMENT)).thenReturn(dryRun);\n    when(namespace.getInt(RemoveOrphanedPreKeyPagesCommand.CONCURRENCY_ARGUMENT)).thenReturn(2);\n    when(namespace.getString(RemoveOrphanedPreKeyPagesCommand.MINIMUM_ORPHAN_AGE_ARGUMENT))\n        .thenReturn(minimumOrphanAge.toString());\n\n    final RemoveOrphanedPreKeyPagesCommand command = new RemoveOrphanedPreKeyPagesCommand(clock);\n    command.run(mock(Environment.class), namespace, mock(WhisperServerConfiguration.class), commandDependencies);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/StartPushNotificationExperimentCommandTest.java",
    "content": "package org.whispersystems.textsecuregcm.workers;\n\nimport static org.junit.jupiter.api.Assertions.assertDoesNotThrow;\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyBoolean;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperiment;\nimport org.whispersystems.textsecuregcm.experiment.PushNotificationExperimentSamples;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\n\nclass StartPushNotificationExperimentCommandTest {\n\n  private PushNotificationExperimentSamples pushNotificationExperimentSamples;\n  private PushNotificationExperiment<String> experiment;\n\n  private TestStartPushNotificationExperimentCommand startPushNotificationExperimentCommand;\n\n  // Taken together, these parameters will produce a device that's enrolled in the experimental group (as opposed to the\n  // control group) for an experiment.\n  private static final UUID ACCOUNT_IDENTIFIER = UUID.fromString(\"341fb18f-9dee-4181-bc40-e485958341d3\");\n  private static final byte DEVICE_ID = Device.PRIMARY_ID;\n  private static final String EXPERIMENT_NAME = \"test\";\n\n  private static class TestStartPushNotificationExperimentCommand extends StartPushNotificationExperimentCommand<String> {\n\n    private final CommandDependencies commandDependencies;\n    private boolean dryRun = false;\n\n    public TestStartPushNotificationExperimentCommand(\n        final PushNotificationExperimentSamples pushNotificationExperimentSamples,\n        final PushNotificationExperiment<String> experiment) {\n\n      super(\"test-start-push-notification-experiment\",\n          \"Test start push notification experiment command\",\n          (ignoredDependencies, ignoredConfiguration) -> experiment);\n\n      this.commandDependencies = new CommandDependencies(null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          pushNotificationExperimentSamples,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null,\n          null);\n    }\n\n    void setDryRun(final boolean dryRun) {\n      this.dryRun = dryRun;\n    }\n\n    @Override\n    protected Namespace getNamespace() {\n      return new Namespace(Map.of(\n          StartPushNotificationExperimentCommand.MAX_CONCURRENCY_ARGUMENT, 1,\n          StartPushNotificationExperimentCommand.DRY_RUN_ARGUMENT, dryRun));\n    }\n\n    @Override\n    protected CommandDependencies getCommandDependencies() {\n      return commandDependencies;\n    }\n  }\n\n  @BeforeEach\n  void setUp() {\n    //noinspection unchecked\n    experiment = mock(PushNotificationExperiment.class);\n    when(experiment.getExperimentName()).thenReturn(EXPERIMENT_NAME);\n    when(experiment.isDeviceEligible(any(), any())).thenReturn(CompletableFuture.completedFuture(true));\n    when(experiment.getState(any(), any())).thenReturn(\"test\");\n    when(experiment.applyExperimentTreatment(any(), any())).thenReturn(CompletableFuture.completedFuture(null));\n\n    pushNotificationExperimentSamples = mock(PushNotificationExperimentSamples.class);\n\n    try {\n      when(pushNotificationExperimentSamples.recordInitialState(any(), anyByte(), any(), anyBoolean(), any()))\n          .thenReturn(CompletableFuture.completedFuture(true));\n    } catch (final JsonProcessingException e) {\n      throw new AssertionError(e);\n    }\n\n    startPushNotificationExperimentCommand =\n        new TestStartPushNotificationExperimentCommand(pushNotificationExperimentSamples, experiment);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccounts(final boolean dryRun) {\n    startPushNotificationExperimentCommand.setDryRun(dryRun);\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTIFIER);\n    when(account.getDevices()).thenReturn(List.of(device));\n\n    assertDoesNotThrow(() -> startPushNotificationExperimentCommand.crawlAccounts(Flux.just(account)));\n\n    if (dryRun) {\n      verify(experiment, never()).applyExperimentTreatment(any(), any());\n    } else {\n      verify(experiment).applyExperimentTreatment(account, device);\n    }\n\n    verify(experiment, never()).applyControlTreatment(account, device);\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccountsExistingSample(final boolean dryRun) throws JsonProcessingException {\n    startPushNotificationExperimentCommand.setDryRun(dryRun);\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTIFIER);\n    when(account.getDevices()).thenReturn(List.of(device));\n\n    when(pushNotificationExperimentSamples.recordInitialState(any(), anyByte(), any(), anyBoolean(), any()))\n        .thenReturn(CompletableFuture.completedFuture(false));\n\n    assertDoesNotThrow(() -> startPushNotificationExperimentCommand.crawlAccounts(Flux.just(account)));\n    verify(experiment, never()).applyExperimentTreatment(any(), any());\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccountsSampleRetry(final boolean dryRun) throws JsonProcessingException {\n    startPushNotificationExperimentCommand.setDryRun(dryRun);\n\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTIFIER);\n    when(account.getDevices()).thenReturn(List.of(device));\n\n    when(pushNotificationExperimentSamples.recordInitialState(any(), anyByte(), any(), anyBoolean(), any()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()))\n        .thenReturn(CompletableFuture.completedFuture(true));\n\n    assertDoesNotThrow(() -> startPushNotificationExperimentCommand.crawlAccounts(Flux.just(account)));\n\n    if (dryRun) {\n      verify(experiment, never()).applyExperimentTreatment(any(), any());\n      verify(pushNotificationExperimentSamples, never())\n          .recordInitialState(any(), anyByte(), any(), anyBoolean(), any());\n    } else {\n      verify(experiment).applyExperimentTreatment(account, device);\n      verify(pushNotificationExperimentSamples, times(3))\n          .recordInitialState(ACCOUNT_IDENTIFIER, DEVICE_ID, EXPERIMENT_NAME, true, \"test\");\n    }\n\n    verify(experiment, never()).applyControlTreatment(account, device);\n  }\n\n  @Test\n  void crawlAccountsExperimentException() {\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(DEVICE_ID);\n\n    final Account account = mock(Account.class);\n    when(account.getIdentifier(IdentityType.ACI)).thenReturn(ACCOUNT_IDENTIFIER);\n    when(account.getDevices()).thenReturn(List.of(device));\n\n    when(experiment.applyExperimentTreatment(account, device))\n        .thenReturn(CompletableFuture.failedFuture(new RuntimeException()));\n\n    assertDoesNotThrow(() -> startPushNotificationExperimentCommand.crawlAccounts(Flux.just(account)));\n    verify(experiment).applyExperimentTreatment(account, device);\n  }\n}\n"
  },
  {
    "path": "service/src/test/java/org/whispersystems/textsecuregcm/workers/UnlinkDevicesWithIdlePrimaryCommandTest.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.workers;\n\nimport static org.mockito.ArgumentMatchers.any;\nimport static org.mockito.ArgumentMatchers.anyByte;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport java.time.Clock;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.time.ZoneId;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport net.sourceforge.argparse4j.inf.Namespace;\nimport org.junit.jupiter.params.ParameterizedTest;\nimport org.junit.jupiter.params.provider.ValueSource;\nimport org.whispersystems.textsecuregcm.identity.IdentityType;\nimport org.whispersystems.textsecuregcm.storage.Account;\nimport org.whispersystems.textsecuregcm.storage.AccountsManager;\nimport org.whispersystems.textsecuregcm.storage.Device;\nimport reactor.core.publisher.Flux;\n\nclass UnlinkDevicesWithIdlePrimaryCommandTest {\n\n  private static final Clock CLOCK = Clock.fixed(Instant.now(), ZoneId.systemDefault());\n\n  private static class TestUnlinkDevicesWithIdlePrimaryCommand extends UnlinkDevicesWithIdlePrimaryCommand {\n\n    private final CommandDependencies commandDependencies;\n    private final Namespace namespace;\n\n    public TestUnlinkDevicesWithIdlePrimaryCommand(final Clock clock,\n        final AccountsManager accountsManager,\n        final boolean isDryRun) {\n\n      super(clock);\n\n      commandDependencies = mock(CommandDependencies.class);\n      when(commandDependencies.accountsManager()).thenReturn(accountsManager);\n\n      namespace = new Namespace(Map.of(\n          UnlinkDevicesWithIdlePrimaryCommand.DRY_RUN_ARGUMENT, isDryRun,\n          UnlinkDevicesWithIdlePrimaryCommand.MAX_CONCURRENCY_ARGUMENT, 16,\n          UnlinkDevicesWithIdlePrimaryCommand.PRIMARY_IDLE_DAYS_ARGUMENT, UnlinkDevicesWithIdlePrimaryCommand.DEFAULT_PRIMARY_IDLE_DAYS\n      ));\n    }\n\n    @Override\n    protected CommandDependencies getCommandDependencies() {\n      return commandDependencies;\n    }\n\n    @Override\n    protected Namespace getNamespace() {\n      return namespace;\n    }\n  }\n\n  @ParameterizedTest\n  @ValueSource(booleans = {true, false})\n  void crawlAccounts(final boolean isDryRun) {\n    final AccountsManager accountsManager = mock(AccountsManager.class);\n    when(accountsManager.removeDevice(any(), anyByte()))\n        .thenReturn(null);\n\n    final Duration idleDeviceLastSeenDuration =\n        Duration.ofDays(UnlinkDevicesWithIdlePrimaryCommand.DEFAULT_PRIMARY_IDLE_DAYS).plus(Duration.ofDays(1));\n\n    final Duration activeDeviceLastSeenDuration =\n        Duration.ofDays(UnlinkDevicesWithIdlePrimaryCommand.DEFAULT_PRIMARY_IDLE_DAYS).minus(Duration.ofDays(1));\n\n    final Account accountWithIdlePrimaryAndNoLinkedDevice = mock(Account.class);\n    {\n      when(accountWithIdlePrimaryAndNoLinkedDevice.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID());\n\n      final Device primaryDevice =\n          generateMockDevice(Device.PRIMARY_ID, idleDeviceLastSeenDuration);\n\n      when(accountWithIdlePrimaryAndNoLinkedDevice.getPrimaryDevice()).thenReturn(primaryDevice);\n      when(accountWithIdlePrimaryAndNoLinkedDevice.getDevices()).thenReturn(List.of(primaryDevice));\n    }\n\n    final Account accountWithActivePrimaryAndLinkedDevice = mock(Account.class);\n    {\n      when(accountWithActivePrimaryAndLinkedDevice.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID());\n\n      final Device primaryDevice =\n          generateMockDevice(Device.PRIMARY_ID, activeDeviceLastSeenDuration);\n\n      final Device linkedDevice = generateMockDevice((byte) (Device.PRIMARY_ID + 1), activeDeviceLastSeenDuration);\n\n      when(accountWithActivePrimaryAndLinkedDevice.getPrimaryDevice()).thenReturn(primaryDevice);\n      when(accountWithActivePrimaryAndLinkedDevice.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n    }\n\n    final byte linkedDeviceId = Device.PRIMARY_ID + 2;\n\n    final Account accountWithIdlePrimaryAndLinkedDevice = mock(Account.class);\n    {\n      when(accountWithIdlePrimaryAndLinkedDevice.getIdentifier(IdentityType.ACI)).thenReturn(UUID.randomUUID());\n\n      final Device primaryDevice =\n          generateMockDevice(Device.PRIMARY_ID, idleDeviceLastSeenDuration);\n\n      final Device linkedDevice = generateMockDevice(linkedDeviceId, activeDeviceLastSeenDuration);\n\n      when(accountWithIdlePrimaryAndLinkedDevice.getPrimaryDevice()).thenReturn(primaryDevice);\n      when(accountWithIdlePrimaryAndLinkedDevice.getDevices()).thenReturn(List.of(primaryDevice, linkedDevice));\n    }\n\n    final UnlinkDevicesWithIdlePrimaryCommand unlinkDevicesWithIdlePrimaryCommand =\n        new TestUnlinkDevicesWithIdlePrimaryCommand(CLOCK, accountsManager, isDryRun);\n\n    unlinkDevicesWithIdlePrimaryCommand.crawlAccounts(Flux.just(accountWithIdlePrimaryAndNoLinkedDevice,\n        accountWithActivePrimaryAndLinkedDevice,\n        accountWithIdlePrimaryAndLinkedDevice));\n\n    if (!isDryRun) {\n      verify(accountsManager).removeDevice(accountWithIdlePrimaryAndLinkedDevice, linkedDeviceId);\n    }\n\n    verifyNoMoreInteractions(accountsManager);\n  }\n\n  private static Device generateMockDevice(final byte deviceId, final Duration primaryIdleDuration) {\n    final Device device = mock(Device.class);\n    when(device.getId()).thenReturn(deviceId);\n    when(device.isPrimary()).thenReturn(deviceId == Device.PRIMARY_ID);\n    when(device.getLastSeen()).thenReturn(CLOCK.instant().minus(primaryIdleDuration).toEpochMilli());\n\n    return device;\n  }\n}\n"
  },
  {
    "path": "service/src/test/java-templates/org/whispersystems/textsecuregcm/util/TestcontainersImages.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.textsecuregcm.util;\n\npublic class TestcontainersImages {\n\n  private static final String DYNAMO_DB = \"${dynamodb.image}\";\n  private static final String LOCAL_STACK = \"${localstack.image}\";\n  private static final String REDIS = \"${redis.image}\";\n  private static final String REDIS_CLUSTER = \"${redis-cluster.image}\";\n\n  public static String getDynamoDb() {\n    return DYNAMO_DB;\n  }\n\n  public static String getLocalStack() {\n    return LOCAL_STACK;\n  }\n\n  public static String getRedis() {\n    return REDIS;\n  }\n\n  public static String getRedisCluster() {\n    return REDIS_CLUSTER;\n  }\n}\n"
  },
  {
    "path": "service/src/test/proto/echo_service.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.rpc;\n\n// A simple service for testing gRPC interceptors\nservice EchoService {\n  rpc echo (EchoRequest) returns (EchoResponse) {}\n  rpc echo2 (EchoRequest) returns (EchoResponse) {}\n  rpc echoStream (stream EchoRequest) returns (stream EchoResponse) {}\n}\n\nmessage EchoRequest {\n  bytes payload = 1;\n}\n\nmessage EchoResponse {\n  bytes payload = 1;\n}\n"
  },
  {
    "path": "service/src/test/proto/request_attributes_service.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.rpc;\n\n// A simple test service that echoes request attributes to callers\nservice RequestAttributes {\n  rpc GetRequestAttributes (GetRequestAttributesRequest) returns (GetRequestAttributesResponse) {}\n\n  rpc GetAuthenticatedDevice (GetAuthenticatedDeviceRequest) returns (GetAuthenticatedDeviceResponse) {}\n}\n\nmessage GetRequestAttributesRequest {\n}\n\nmessage GetRequestAttributesResponse {\n  repeated string acceptable_languages = 1;\n  repeated string available_accepted_locales = 2;\n  string remote_address = 3;\n  string user_agent = 4;\n}\n\nmessage GetAuthenticatedDeviceRequest {\n}\n\nmessage GetAuthenticatedDeviceResponse {\n  bytes account_identifier = 1;\n  uint32 device_id = 2;\n}\n"
  },
  {
    "path": "service/src/test/proto/tag_test.proto",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.rpc;\n\nimport \"org/signal/chat/tag.proto\";\nimport \"google/protobuf/empty.proto\";\n\nservice TagTestService {\n  rpc TagEndpoint(google.protobuf.Empty) returns (TagResponse) {}\n  rpc StreamingTagEndpoint(google.protobuf.Empty) returns (stream TagResponse) {}\n}\n\nmessage TagResponse {\n  oneof response {\n    bool no_reason = 1;\n    bool reason_1 = 2 [(tag.reason) = \"reason_1\"];\n  }\n\n  bool conflicting_reason = 4 [(tag.reason) = \"duplicate_reason\"];\n\n  message NestedReason {\n    bool reason = 1 [(tag.reason) = \"nested_reason\"];\n  }\n  NestedReason nested_reason = 5;\n}\n"
  },
  {
    "path": "service/src/test/proto/test_tree_head.proto",
    "content": "/*\n * Copyright 2024 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.rpc;\n\nmessage TestTreeHead {\n  uint64 tree_size = 1;\n  int64 timestamp = 2;\n  bytes signature = 3;\n  // Test that the deserializer properly ignores unknown fields\n  bytes unknown_field = 4;\n}\n"
  },
  {
    "path": "service/src/test/proto/validation_test.proto",
    "content": "/*\n * Copyright 2023 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto3\";\n\noption java_multiple_files = true;\n\npackage org.signal.chat.rpc;\n\nimport \"org/signal/chat/require.proto\";\nimport \"google/protobuf/empty.proto\";\n\nservice ValidationTestService {\n  rpc ValidationsEndpoint (ValidationsRequest) returns (ValidationsResponse) {}\n}\n\nservice AuthService {\n  option (require.auth) = AUTH_ONLY_AUTHENTICATED;\n\n  rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}\n}\n\nservice AnonymousService {\n  option (require.auth) = AUTH_ONLY_ANONYMOUS;\n\n  rpc AnonymousMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {}\n}\n\nmessage ValidationsRequest {\n  optional string number = 1 [(require.e164) = true];\n\n  optional string fixedSizeString = 2 [(require.exactlySize) = 5];\n  optional string rangeSizeString = 3 [(require.size) = {min: 3, max: 8}];\n\n  optional bytes fixedSizeBytes = 4 [(require.exactlySize) = 5];\n  optional bytes rangeSizeBytes = 5 [(require.size) = {min: 3, max: 8}];\n\n  repeated string fixedSizeList = 6 [(require.exactlySize) = 5];\n  repeated string rangeSizeList = 7 [(require.size) = {min: 3, max: 8}];\n\n  bytes withMinBytes = 8 [(require.size).min = 3];\n  string withMinString = 9 [(require.size).min = 3];\n\n  bytes withMaxBytes = 10 [(require.size).max = 8];\n  string withMaxString = 11 [(require.size).max = 8];\n\n  optional string exactlySizeVariants = 12 [(require.exactlySize) = 2, (require.exactlySize) = 4];\n  optional string exactlySizeVariantsWithZero = 13 [(require.exactlySize) = 0, (require.exactlySize) = 4];\n\n  optional string nonEmptyStringOptional = 14 [(require.nonEmpty) = true];\n  optional bytes nonEmptyBytesOptional = 15 [(require.nonEmpty) = true];\n  string nonEmptyString = 16 [(require.nonEmpty) = true];\n  bytes nonEmptyBytes = 17 [(require.nonEmpty) = true];\n  repeated string nonEmptyList = 18 [(require.nonEmpty) = true];\n\n  optional Color colorOptional = 19 [(require.specified) = true];\n  Color color = 20 [(require.specified) = true];\n\n  int32 i32 = 21 [(require.range).max = 100];\n  uint32 ui32 = 22 [(require.range).max = 100];\n  int32 i32range = 23 [(require.range) = {min: 10, max: 20}];\n  optional int32 i32OptRange = 24 [(require.range) = {min: 10, max: 20}];\n\n  message RequirePresentMessage {}\n  RequirePresentMessage presentMessage = 25 [(require.present) = true];\n  optional RequirePresentMessage optionalPresentMessage = 26 [(require.present) = true];\n\n  NestedMessage nested = 27;\n  repeated NestedMessage repeatedNested = 28;\n  map<string, NestedMessage> mapNested = 29;\n\n  RecursiveMessage recursiveMessage = 30;\n\n  oneof oneOf {\n    RequirePresentMessage oneOfMessage = 31 [(require.present) = true];\n    bytes oneOfNonEmptyBytes = 32 [(require.nonEmpty) = true];\n  }\n}\n\nmessage NestedMessage {\n  int32 i32 = 1 [(require.range).max = 100];\n}\n\nmessage RecursiveMessage {\n  RecursiveMessage next = 1;\n  int32 i32 = 2 [(require.range).max = 100];\n}\n\nmessage MessageWithInvalidRangeConstraint {\n  int32 i32 = 1 [(require.range) = {min: 10, max: 5}];\n}\n\nenum Color {\n  COLOR_UNSPECIFIED = 0;\n  COLOR_RED = 1;\n  COLOR_GREEN = 2;\n  COLOR_BLUE = 3;\n}\n\nmessage ValidationsResponse {\n}\n\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.DynamicConfigurationManagerFactory",
    "content": ""
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.DynamoDbClientFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.LocalDynamoDbFactory\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClientFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.LocalFaultTolerantRedisClientFactory\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.FaultTolerantRedisClusterFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.LocalFaultTolerantRedisClusterFactory\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.PaymentsServiceClientsFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.StubPaymentsServiceClientsFactory\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.PubSubPublisherFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.StubPubSubPublisherFactory\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.RegistrationServiceClientFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.StubRegistrationServiceClientFactory\n"
  },
  {
    "path": "service/src/test/resources/META-INF/services/org.whispersystems.textsecuregcm.configuration.S3ObjectMonitorFactory",
    "content": "org.whispersystems.textsecuregcm.configuration.StaticS3ObjectMonitorFactory\n"
  },
  {
    "path": "service/src/test/resources/config/test-secrets-bundle.yml",
    "content": "aws.accessKeyId: accessKey\naws.secretAccessKey: secretAccess\n\nstripe.apiKey: unset\nstripe.idempotencyKeyGenerator: abcdefg12345678= # base64 for creating request idempotency hash\n\nbraintree.publicKey: unset\nbraintree.privateKey: unset\n\n# The below private key was generated exclusively for testing purposes. Do not use it in any other context.\ngooglePlayBilling.credentialsJson: |\n  { \"type\": \"service_account\", \"client_id\": \"client_id\", \"client_email\": \"fake@example.com\",\n    \"private_key_id\": \"id\",\n    \"private_key\": \"-----BEGIN PRIVATE KEY-----\n  MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrfHLw9zr/8mTX\n  c0YMN3P9pNLtn+JCsNx/6sz/7FYoJjH8CKG4zNgcJLATLGxQikTjD6yNDlgkpByD\n  qOmgXgZvIBBJadbbl+plJbU4kKwTRwdrYiq/ICMkVZBk5jfqYqSxzdw80ytj5Tha\n  3M/3uqto7qELK91z/5cCC6pVsQXIrTqq4D41XyORKF2u4eeKOz3jiuXkdxRj4Vsb\n  MDwcS1WEi1ApoG50tDDn7e9mk3MAeE5L54ROHkd7FM471LRSU9ytpOzcH56tExLP\n  21nN5vXZoyJnNvbgd1KZeZajjH+XHJS/wiqNAPEX2yvrFID4ECQMIonXtYyNDkmY\n  YxggNaCnAgMBAAECggEAFLDJStr+8A7BArXSh9AmWz4zLPSTiim+EQ5gJFN8Tw/S\n  DBob2SjuEkc4RLf2waj33XrwqNGdlPOFdTqWJavylB8xl99V9dzYgn0QO9OeJMf3\n  Kd+y+f3Yqkj188FLPH52Z0ryqGwaL3gNWqPge9VhWncgUIa/C4CVKcFakJ2b7bW2\n  NIk2bSMCNW8rptQZ+tWV9k86OAxjIocLbkpPgigRk6T3MAunMGVf6iviNSnOyOlZ\n  qmAPkRVs2uyK3Hnl0lEavaBW3KRs0ChU0rkfXHvGmi7V6aZ4rnG6OdRQiOgk3NYf\n  qQYqhnRMmN4st2WN6CDDdpk5o2pHR625Wqx11t/50QKBgQDmf+fYWKdQa8r+TO4w\n  32JAiEdmFuA8fSEOaWyBik/NliJIPEApGMWLuZSmSzW80l4vt5zQ3LVgvRrxZv2y\n  7odLxUP9jpFGVg3NpCB27nES+psmo7X4kXIfzPWGvkOs2HLpp8elVEPeOn7gkng9\n  XXXmB9vja8g/Jo9ym9FkigB0LQKBgQC+dTFTPvvVYFQ1KmeL94EOEL21ZXkgwjnx\n  1BcnqK4p0M1NQ2xW1wwCljxlEQx5P6UY9HRWS6DecVpj6P7nRF2HWB+xsaO1aPZj\n  nMOETrUXGq8ksQml+0kI5f0A2w22wzpj3+kjiXSFBjxoWLAfKPHMKeUg/oYRfIVp\n  LeShMptIowKBgQC4H44U3ORyMlkKAGv4sEhs4i+elkFzMEU6nO4nIFQVFou2BiL+\n  cSJENe9PUx7PAYBpP5PNp7BfYU/na+zWhQGgfiiMn9jeRZlrHmMsfdXnYjaTjAyt\n  TYnLa07p3oxywsgwa2zoXUKFf1agj3/rDQBDyx1UMmHYSDYoR93hIPex1QKBgQCF\n  4y6sna89ff1Ubp3iKDjiIWSre00eeUtwtC8e4xakMLPSZ95mYcCApQqJ5eVF6zbt\n  hxOtgnbxSPBJIgbnnwi813dYXE+AfOwQdKiBfy8QseKDwazNsQvTpJIqItPOMgn/\n  Ie3r3Ho79XlLxWTyUr9ATgdUHXk0G7xRh0CdDU1aTwKBgC5kDNr/R2XIWZL0TMzz\n  EVL2BkL11YumIpEBm+Hkx6fm3uCgR/ywMqplGdZcD+D5r0fUsckbOd1z6fFGAJqe\n  QJ3/4qaA+dcWPwB5GiKa1WIs48GJMyPrFciindEwr3BaDhhB9cEdxpVY2e/KEeZL\n  TQkqmVUmgKKvCFTPWwCgeIOD\n  -----END PRIVATE KEY-----\" }\n\n# The below private key was generated exclusively for testing purposes. Do not use it in any other context.\nappleAppStore.encodedKey: |\n  -----BEGIN PRIVATE KEY-----\n  MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgF32wH6zG3GFMBs38\n  pOp712zI2NBwnccdfwxI6lidSkShRANCAATUzzM68ATLZ+TD09nT03ZyxjY1MA7H\n  dCuKcyQAVQo+X5lc8TpMgTWg36Kzxb4hPU1cqshIJomI0iE70eLOUe8p\n  -----END PRIVATE KEY-----\n\ndirectoryV2.client.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth tokens for Signal users\ndirectoryV2.client.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with CDS to generate auth identity tokens for Signal users\n\nsvr2.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth tokens for Signal users\nsvr2.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVR2 to generate auth identity tokens for Signal users\n\nsvrb.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth tokens for Signal users\nsvrb.userIdTokenSharedSecret: bbcdefghijklmnopqrstuvwxyz0123456789ABCDEFG= # base64-encoded secret shared with SVRB to generate auth identity tokens for Signal users\n\ntus.userAuthenticationTokenSharedSecret: abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG=\n\n# The below private key was generated exclusively for testing purposes. Do not use it in any other context.\ngcpAttachments.rsaSigningKey: |\n  -----BEGIN PRIVATE KEY-----\n  MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrfHLw9zr/8mTX\n  c0YMN3P9pNLtn+JCsNx/6sz/7FYoJjH8CKG4zNgcJLATLGxQikTjD6yNDlgkpByD\n  qOmgXgZvIBBJadbbl+plJbU4kKwTRwdrYiq/ICMkVZBk5jfqYqSxzdw80ytj5Tha\n  3M/3uqto7qELK91z/5cCC6pVsQXIrTqq4D41XyORKF2u4eeKOz3jiuXkdxRj4Vsb\n  MDwcS1WEi1ApoG50tDDn7e9mk3MAeE5L54ROHkd7FM471LRSU9ytpOzcH56tExLP\n  21nN5vXZoyJnNvbgd1KZeZajjH+XHJS/wiqNAPEX2yvrFID4ECQMIonXtYyNDkmY\n  YxggNaCnAgMBAAECggEAFLDJStr+8A7BArXSh9AmWz4zLPSTiim+EQ5gJFN8Tw/S\n  DBob2SjuEkc4RLf2waj33XrwqNGdlPOFdTqWJavylB8xl99V9dzYgn0QO9OeJMf3\n  Kd+y+f3Yqkj188FLPH52Z0ryqGwaL3gNWqPge9VhWncgUIa/C4CVKcFakJ2b7bW2\n  NIk2bSMCNW8rptQZ+tWV9k86OAxjIocLbkpPgigRk6T3MAunMGVf6iviNSnOyOlZ\n  qmAPkRVs2uyK3Hnl0lEavaBW3KRs0ChU0rkfXHvGmi7V6aZ4rnG6OdRQiOgk3NYf\n  qQYqhnRMmN4st2WN6CDDdpk5o2pHR625Wqx11t/50QKBgQDmf+fYWKdQa8r+TO4w\n  32JAiEdmFuA8fSEOaWyBik/NliJIPEApGMWLuZSmSzW80l4vt5zQ3LVgvRrxZv2y\n  7odLxUP9jpFGVg3NpCB27nES+psmo7X4kXIfzPWGvkOs2HLpp8elVEPeOn7gkng9\n  XXXmB9vja8g/Jo9ym9FkigB0LQKBgQC+dTFTPvvVYFQ1KmeL94EOEL21ZXkgwjnx\n  1BcnqK4p0M1NQ2xW1wwCljxlEQx5P6UY9HRWS6DecVpj6P7nRF2HWB+xsaO1aPZj\n  nMOETrUXGq8ksQml+0kI5f0A2w22wzpj3+kjiXSFBjxoWLAfKPHMKeUg/oYRfIVp\n  LeShMptIowKBgQC4H44U3ORyMlkKAGv4sEhs4i+elkFzMEU6nO4nIFQVFou2BiL+\n  cSJENe9PUx7PAYBpP5PNp7BfYU/na+zWhQGgfiiMn9jeRZlrHmMsfdXnYjaTjAyt\n  TYnLa07p3oxywsgwa2zoXUKFf1agj3/rDQBDyx1UMmHYSDYoR93hIPex1QKBgQCF\n  4y6sna89ff1Ubp3iKDjiIWSre00eeUtwtC8e4xakMLPSZ95mYcCApQqJ5eVF6zbt\n  hxOtgnbxSPBJIgbnnwi813dYXE+AfOwQdKiBfy8QseKDwazNsQvTpJIqItPOMgn/\n  Ie3r3Ho79XlLxWTyUr9ATgdUHXk0G7xRh0CdDU1aTwKBgC5kDNr/R2XIWZL0TMzz\n  EVL2BkL11YumIpEBm+Hkx6fm3uCgR/ywMqplGdZcD+D5r0fUsckbOd1z6fFGAJqe\n  QJ3/4qaA+dcWPwB5GiKa1WIs48GJMyPrFciindEwr3BaDhhB9cEdxpVY2e/KEeZL\n  TQkqmVUmgKKvCFTPWwCgeIOD\n  -----END PRIVATE KEY-----\n\napn.teamId: team-id\napn.keyId: key-id\n# The below private key was generated exclusively for testing purposes. Do not use it in any other context.\napn.signingKey: |\n  -----BEGIN PRIVATE KEY-----\n  MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxIXnNiHH35DDbKHY\n  8kxoAYbukvMPVWN+kiIhZsFvqaahRANCAAQTWXjgagaLnTxcMJTUpO3rkhi8xjav\n  7NSEd5L+df4M7V9YxxDoYY+UHd8B/KmrWR29SVIRLncSULgfSnHnHvoH\n  -----END PRIVATE KEY-----\n\n# The below private key was generated exclusively for testing purposes. Do not use it in any other context.\nfcm.credentials: |\n  { \"type\": \"service_account\", \"client_id\": \"client_id\", \"client_email\": \"fake@example.com\",\n    \"private_key_id\": \"id\",\n    \"private_key\": \"-----BEGIN PRIVATE KEY-----\n  MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCrfHLw9zr/8mTX\n  c0YMN3P9pNLtn+JCsNx/6sz/7FYoJjH8CKG4zNgcJLATLGxQikTjD6yNDlgkpByD\n  qOmgXgZvIBBJadbbl+plJbU4kKwTRwdrYiq/ICMkVZBk5jfqYqSxzdw80ytj5Tha\n  3M/3uqto7qELK91z/5cCC6pVsQXIrTqq4D41XyORKF2u4eeKOz3jiuXkdxRj4Vsb\n  MDwcS1WEi1ApoG50tDDn7e9mk3MAeE5L54ROHkd7FM471LRSU9ytpOzcH56tExLP\n  21nN5vXZoyJnNvbgd1KZeZajjH+XHJS/wiqNAPEX2yvrFID4ECQMIonXtYyNDkmY\n  YxggNaCnAgMBAAECggEAFLDJStr+8A7BArXSh9AmWz4zLPSTiim+EQ5gJFN8Tw/S\n  DBob2SjuEkc4RLf2waj33XrwqNGdlPOFdTqWJavylB8xl99V9dzYgn0QO9OeJMf3\n  Kd+y+f3Yqkj188FLPH52Z0ryqGwaL3gNWqPge9VhWncgUIa/C4CVKcFakJ2b7bW2\n  NIk2bSMCNW8rptQZ+tWV9k86OAxjIocLbkpPgigRk6T3MAunMGVf6iviNSnOyOlZ\n  qmAPkRVs2uyK3Hnl0lEavaBW3KRs0ChU0rkfXHvGmi7V6aZ4rnG6OdRQiOgk3NYf\n  qQYqhnRMmN4st2WN6CDDdpk5o2pHR625Wqx11t/50QKBgQDmf+fYWKdQa8r+TO4w\n  32JAiEdmFuA8fSEOaWyBik/NliJIPEApGMWLuZSmSzW80l4vt5zQ3LVgvRrxZv2y\n  7odLxUP9jpFGVg3NpCB27nES+psmo7X4kXIfzPWGvkOs2HLpp8elVEPeOn7gkng9\n  XXXmB9vja8g/Jo9ym9FkigB0LQKBgQC+dTFTPvvVYFQ1KmeL94EOEL21ZXkgwjnx\n  1BcnqK4p0M1NQ2xW1wwCljxlEQx5P6UY9HRWS6DecVpj6P7nRF2HWB+xsaO1aPZj\n  nMOETrUXGq8ksQml+0kI5f0A2w22wzpj3+kjiXSFBjxoWLAfKPHMKeUg/oYRfIVp\n  LeShMptIowKBgQC4H44U3ORyMlkKAGv4sEhs4i+elkFzMEU6nO4nIFQVFou2BiL+\n  cSJENe9PUx7PAYBpP5PNp7BfYU/na+zWhQGgfiiMn9jeRZlrHmMsfdXnYjaTjAyt\n  TYnLa07p3oxywsgwa2zoXUKFf1agj3/rDQBDyx1UMmHYSDYoR93hIPex1QKBgQCF\n  4y6sna89ff1Ubp3iKDjiIWSre00eeUtwtC8e4xakMLPSZ95mYcCApQqJ5eVF6zbt\n  hxOtgnbxSPBJIgbnnwi813dYXE+AfOwQdKiBfy8QseKDwazNsQvTpJIqItPOMgn/\n  Ie3r3Ho79XlLxWTyUr9ATgdUHXk0G7xRh0CdDU1aTwKBgC5kDNr/R2XIWZL0TMzz\n  EVL2BkL11YumIpEBm+Hkx6fm3uCgR/ywMqplGdZcD+D5r0fUsckbOd1z6fFGAJqe\n  QJ3/4qaA+dcWPwB5GiKa1WIs48GJMyPrFciindEwr3BaDhhB9cEdxpVY2e/KEeZL\n  TQkqmVUmgKKvCFTPWwCgeIOD\n  -----END PRIVATE KEY-----\" }\n\ncdn.accessKey: test    # AWS Access Key ID\ncdn.accessSecret: test # AWS Access Secret\n\ncdn3StorageManager.clientSecret: test\n\nkeyTransparencyService.clientPrivateKey: |\n  -----BEGIN PRIVATE KEY-----\n  MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgxIXnNiHH35DDbKHY\n  8kxoAYbukvMPVWN+kiIhZsFvqaahRANCAAQTWXjgagaLnTxcMJTUpO3rkhi8xjav\n  7NSEd5L+df4M7V9YxxDoYY+UHd8B/KmrWR29SVIRLncSULgfSnHnHvoH\n  -----END PRIVATE KEY-----\n\n# The below private keys are exclusively for testing purposes. Do not use them in any other context.\n# Trust root for the certificate in test.yml:\n# Public key : BS/lfaNHzWJDFSjarF+7KQcw//aEr8TPwu2QmV9Yyzt0\n# Private key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\nunidentifiedDelivery.privateKey: //////////////////////////////////////////8=\n\nstorageService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n\n# The below secrets were generated exclusively for testing purposes. Do not use them in any other context.\nzkConfig-libsignal-0.42.serverSecret: ALxy0qUiV9B3OgF1GzTgn6g4NSN22ww87p5xOlYkypQJIqxoTBGOPREr4ZXvOfQ/tFYQJxn3xoFJ5DKt+mDOJw8KzWephNaaXBSLcbWb/fPfSUjoXnPL+fcU26OpMIUrIVews/i0Eh4ongRWuLfFUpLyZ34wfgpKkWbIUBjbVT4M5e7GzRPVFOBGibICk9Q66o9l3K4PphKaoMQKlGPo4g85xsVH1HFN5J5u4/pdq80+3E7WvWN4c8NpDZQ7RqtECDpawSc/J4vim9tL7QR6hFYwzvA0cmCcM0NCPFpp69EHB1eRksDvPHiA+NuMqoFwXwIzSHzA86ALPxnyLKB7KgBG06DxfMmMav3dij6giTawgATRsoNFLQ/ol6H0TtYJCAp8oB0D4EV2q7hSue3Kxzh1Vc88/nmLuRR9G3EefC0+CMcxJFQwDMgjFvFBKx3o6m9gJLevYiKcm/NxXX9WtnEzqAh2DRr0G0fvk9NZF2Lw2kWgAX2qkPHZLJ3nKA90BgryBAJsk8Q78N5ghBhQzdgikURLC+mX1fbmMzkmGcwCYDnLpo8qjrIoBZzDAjI4Ty04MaJcLowJqNMmK29btza50WiZdsd4tuVqQAKlqJcERCsUewlZSkWpsDLrZkeUBY0rGCi51FW5WOUvdXwTHtTL2hlcBqP/E8cbBC+yce8AurjJ6Z9HVtM7tVk7a5xRAqwoFRSH7eq5BA60hDq2sgWIQaN1owunKZvsHFn0qzoGKuWAEO0PpbGbHtFDUjxzBkgUIN13yIbem9KPeZm9ZVrjkyDo3uZZsfFFUHnFeasKOt9WMJLLx1s7DttJ4Ns2o02q4e+aQ60oMeWsyMuzKgNMDHgDgfqHxbCi2rm20SgoHnuoph6XArmEOX6a1xLJVxgDtgfm1IbcyyqROXYxe9v2RvMUAnjbLI/fm0rXXhldjVX7Yoo7ZGAlVBuGOC28haRrd684Lcajdequ6Css1geOzOVVH/BGnJtf3RFSMXIv/YByG21/cgL9KFYqQHqZZeLNQpweILMnU0/iVK/fjLvrhyI2jNy1B0Ox62zE2o8EdVT/H1WgXa2NHC591aEqI5EXwribRKUM56v/O3IDBAxC5CLIQcUeDhWouaFqXjfxNza9rFC69smtUXl3sx8KG6Ze7VkXb372daAN+rMWIdq5kbRcetkXxuqTuYOz4NsDnEPnBNNO6hqOv6+dZAQK3wmhOah3NIL0kKNUHpuk+gidQkLehBvahNKpUfh9yBMkEcLNkR7D1r8fAcrA7u2sCKfRero8FOkCc9ChawShJXdcGtKv50d2/4vJp9B0ddUBYdFnbM/siX4VJghb+rSkhCkkXXS7QXFfbO5A4WJLkwthkNezNgqCBmEoda7UcOOaW9KQMFisRy74OZagCUAJCPC4UJw1/N4IJD7Dtw2cNtaxwFyuG6i7sdm9u3Xr9h0JcvKn5Z3BLxmR6WkhO/IraGw+9/ijFpEtB9VFhdQxfnnj4JRneZtQxC9nAY0liDuO41OhGYaWinVVRkljKe7GAmw726P1wYiyd2ajlkbI/7KB9VwxEicEijeyuR6UZDUFqOPk4q+fdkBRS2GuGKiy4ISgha6sVRkb5sLlAsRhmG1W0Anh8d2dHxmE8CzyoAXbschIeY2LJTUIORpvAaFrDI74dagBDNlIVnHw89PGYWnosia9YFMLpsYyjOccQKAGKJLZ8MdcgRw8W2Mw2AwZbQWUUU1VZxdWX6y878WJ6g4at5NJ2YpdZ/qh5zBTDbTDpGnTsv9Ioyw5+91h6qagDXecb1wekN0RZsI3KWXQeOZZ+uxTNRqqpWZhH9QtrEsPRdNyOlVJDmJ1H2Rl3o30Crt8j6EM6ZgR444GuDf/jATe9shz3Ljc0S5/OTEAJL54OfHq9jGgtXS+05AuhlxCC9zYpEBb30xnZD6zxXnz6IB//uVROuWc1BEFhkvn+JcJxpapbKH6PthuOzMVRkf+I3Xz3/bNjiQSlQkmAXlgB1YujgABYnJ6yJXQKP2mR4UJ3UYoGroYoafWycDa+vUYYqIMcK6tbIgvxFx8TmMoQ1MueOIzDt0Nyx3Uov5qVvcG8gyflIv4fbzlu7GTYE8Ov6sRGY8KzF5ywxvrq0VldgfoGF4AGdQ3RxB5EDdlHIvlOG8VRoD+7Ch/S1kemdyvD2co9wN/GfU58Q1DO7dSQTl/O86t1eZBp+8H4IIarAgLunN/vkV34LAYjk1DOfeNxrCnfHz9RWtT3Vy3FJKuaQUotgZRyu36BLSs/ozriMR1nkT2+Luknw/zDD496ZvyAwvtG18Tk5B5b3DSMSUq9vA4h8KKCFfkgTHNeOHFyogNkfwaeGEflkrckI4RTtGrIL+lmW+1LYRoDU8F2T4VzAnyqsfmfT5+g6nbzw7FRXGfEu/E94Xaacj0t90t+eqtBruaoJ9kqw4iJmQdrSonz4fo4yOAXukaTnzFnalXZdoFjNpSbMWOwFhalgT4fI8mUnZBoVOulWrFfs1exJuDy3fki99Z9kwT1gFnZ4SsO6fCRoTiINCEeXkhcCFscOuvZP9su7zhyXWhobzNsct/ejd+CHyDKoNPBjNId5hGwnIAP5F55x+n51UIPvCkotwIGsvbfExLMw/JgwCJCDSHNgZEmXO+xEVozKRbUDK8d0mR46M59k8qaPKkKAORatQPVXOyszQTLx8gnPf/HDDqthyyp7mfjfE09vv3CpREfzkGYZpMWv1aDG2AHpAdrOH3cVW2UQq9uPRtkiHZMZg9CQnapTCmq3YAvsKugvU2CnrnJlACFYO1Hr5RpjfIRMCkBrfHrdFQEwB4/u6opMApJThcbXzhbVEwIwOq/ZcleloJKnN4GdaZyLFphtApVSuMVYDNm0X6KNGSl1kFbgxs8gBBhLxXqqhdfoBnwOtOXHO+kFGY1WzGUJZviHD43glxBIWPRSjD0pPvuWX91IWv/GZuCIkwCa34p+P9v7iKRuoBGievGCJJ+5SUoBQyhhQdpG+gbw1Bs3KSPwkx0IulpQqkInUCyVgxRXZWI61AkDNr7ybYMhq0nPPY3V0xYzN9Wltf8c37m3IWVkzGR3a0JjoUUWtFaCY9fasiOQM1nZUMe/0UTV2ZKjD4US9c2PEHrPPlwVowar4PlUJU1KIAjgsCyo9DhZPyEgPjGYu+tGpMcUImLukfebXsgzFFhyLgLktlMwFHNn6JHchCURY58OOcMzDKK/6IuLU621a+d8gP8U22MozRrL+GNEYwiyF9XM4Hp+ovB3yv6VFkBp7ukAJDETnNy+nPPjt0ShUpp4hj+WDWo/fs6Oy/fs0wPdziBPrWQ7Dn0vDGsXVbTib8rd4UucpZdGaY1yktsG4MNHAMVv+IH1hYcg87bfsUJbfuHgvLB1l5Qz8j7/Ezi54RFQBGS3QkDQnCl/mMmrNCe5xe1soC+rsCRblHuJjujjK/CxgYEs2Lc9ZWPc3FyzGQbblH5hUX1MxP0V1DM/VxpI8EGVWTk6Q3W1yX16EiWkauVbHsyScbniotURYRstCUmA1Qnz9bsSBgjuCftVHZZ4lFmWogd976tG5uGQ+tvu6xCqH+EsGOQ843I/5w0xTPTJcFyQ9cuRoTPzFIeP2wa9AA\ngenericZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==\ncallingZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==\nbackupsZkConfig.serverSecret: AIZmPk8ms6TWBTGFcFE1iEuu4kSpTRL1EAPA2ZVWm4EIIF/N811ZhILbCx8QSLBf90mNXhUtsfNF5PY5UdnJMgBGu3AtrVs5erRXf5hi6RxvCkl1QnYs/tcuUGNbkejyR9bPR2uJaK6CxGJS0RRUDWf8f2hQloe/+kWKilM1I/MHSV2+PcyCDJIigPi9RhbD2STXc6cHEpYXReg+1OYSEQk3K2M0qnUoVOAjbPuFXANEPU+106f37w/iF6MhyfWyDCb+oit29DFtoDS31cxheB3x1KVga2ErfnIyHpQrSWYHUdGPZLXc0xRmaa0VwDyyXzK0o3w4oS/F9+xqWYUWkwgsAm9e7dP4l0qVolnPQ67uNj7BFG4JQ0vXxD/JJQ+5B4bHyK+v5ndJpRMXDC9rJw8ehopvDCTXSoICqN7nvY8Fyqhf5zkM880Su2XiBa2paDTVuZgwq07zBeDrrPc2zQ8A4neV6++t95veOfpp94FymnHJ8ILaznKqzJluGDdtCA==\npaymentsService.userAuthenticationTokenSharedSecret: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # base64-encoded 32-byte secret shared with MobileCoin services used to generate auth tokens for Signal users\npaymentsService.fixerApiKey: unset\npaymentsService.coinGeckoApiKey: unset\n\ncurrentReportingKey.secret: AAAAAAAAAAA=\ncurrentReportingKey.salt: AAAAAAAAAAA=\n\nregistrationService.collationKeySalt: AAAAAAAAAAA=\n\nturn.cloudflare.apiToken: ABCDEFGHIJKLM\n\nlinkDevice.secret: AAAAAAAAAAA=\n\ntlsKeyStore.password: unset\n\nhlrLookup.apiKey: AAAAAAAAAAA\nhlrLookup.apiSecret: AAAAAAAAAAA\n"
  },
  {
    "path": "service/src/test/resources/config/test.yml",
    "content": "logging:\n  level: INFO\n  appenders:\n    - type: console\n      threshold: ALL\n      timeZone: UTC\n      target: stdout\n\nhealth:\n  delayedShutdownHandlerEnabled: false\n\nawsCredentialsProvider:\n  type: static\n  accessKeyId: secret://aws.accessKeyId\n  secretAccessKey: secret://aws.secretAccessKey\n\ntlsKeyStore:\n  password: secret://tlsKeyStore.password\n\nstripe:\n  apiKey: secret://stripe.apiKey\n  idempotencyKeyGenerator: secret://stripe.idempotencyKeyGenerator\n  boostDescription: >\n    Example\n  supportedCurrenciesByPaymentMethod:\n    CARD:\n      - usd\n      - eur\n    SEPA_DEBIT:\n      - eur\n\nbraintree:\n  merchantId: unset\n  publicKey: secret://braintree.publicKey\n  privateKey: secret://braintree.privateKey\n  environment: sandbox\n  graphqlUrl: unset\n  merchantAccounts:\n    # ISO 4217 currency code and its corresponding sub-merchant account\n    'xts': unset\n  supportedCurrenciesByPaymentMethod:\n    PAYPAL:\n      - usd\n      - xts\n  pubSubPublisher:\n    type: stub\n\ngooglePlayBilling:\n  credentialsJson: |\n    {\n      \"type\": \"external_account\",\n      \"credential_source\": {\n        \"file\": \"/tmp/my-token\"\n      },\n      \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n      \"audience\": \"//iam.googleapis.com/abc\",\n      \"token_url\": \"https://sts.googleapis.com/v1/token\"\n    }\n  packageName: package.name\n  applicationName: test\n  productIdToLevel: {}\n\nappleAppStore:\n  env: LOCAL_TESTING\n  bundleId: bundle.name\n  appAppleId: 12345\n  issuerId: abcdefg\n  keyId: abcdefg\n  encodedKey: secret://appleAppStore.encodedKey\n  subscriptionGroupId: example_subscriptionGroupId\n  productIdToLevel: {}\n  appleRootCerts:\n    # An apple root cert https://www.apple.com/certificateauthority/\n    - MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh\n\nappleDeviceCheck:\n  production: false\n  teamId: 0123456789\n  bundleId: bundle.name\n\ndeviceCheck:\n  backupRedemptionDuration: P30D\n  backupRedemptionLevel: 201\n\ndynamoDbClient:\n  type: local\n\ndynamoDbTables:\n  accounts:\n    tableName: accounts_test\n    phoneNumberTableName: numbers_test\n    phoneNumberIdentifierTableName: pni_assignment_test\n    usernamesTableName: usernames_test\n    usedLinkDeviceTokensTableName: used_link_device_tokens_test\n  appleDeviceChecks:\n    tableName: apple_device_checks_test\n  appleDeviceCheckPublicKeys:\n    tableName: apple_device_check_public_keys_test\n  backups:\n    tableName: backups_test\n  clientReleases:\n    tableName: client_releases_test\n  deletedAccounts:\n    tableName: deleted_accounts_test\n  deletedAccountsLock:\n    tableName: deleted_accounts_lock_test\n  issuedReceipts:\n    tableName: issued_receipts_test\n    expiration: P30D # Duration of time until rows expire\n    generator: abcdefg12345678= # random base64-encoded binary sequence\n    maxIssuedReceiptsPerPaymentId:\n      STRIPE: 1\n      BRAINTREE: 1\n      GOOGLE_PLAY_BILLING: 1\n      APPLE_APP_STORE: 1\n  ecKeys:\n    tableName: keys_test\n  ecSignedPreKeys:\n    tableName: repeated_use_signed_ec_pre_keys_test\n  pagedPqKeys:\n    tableName: paged_pq_keys_test\n  pqLastResortKeys:\n    tableName: repeated_use_signed_kem_pre_keys_test\n  messages:\n    tableName: messages_test\n    expiration: P30D # Duration of time until rows expire\n  onetimeDonations:\n    tableName: onetime_donations_test\n    expiration: P90D\n  phoneNumberIdentifiers:\n    tableName: pni_test\n  profiles:\n    tableName: profiles_test\n  pushChallenge:\n    tableName: push_challenge_test\n  pushNotificationExperimentSamples:\n    tableName: Example_PushNotificationExperimentSamples\n  redeemedReceipts:\n    tableName: redeemed_receipts_test\n    expiration: P30D # Duration of time until rows expire\n  registrationRecovery:\n    tableName: registration_recovery_passwords_test\n    expiration: P300D # Duration of time until rows expire\n  remoteConfig:\n    tableName: remote_config_test\n  reportMessage:\n    tableName: report_messages_test\n  scheduledJobs:\n    tableName: scheduled_jobs_test\n    expiration: P7D\n  subscriptions:\n    tableName: subscriptions_test\n  clientPublicKeys:\n    tableName: client_public_keys_test\n  verificationSessions:\n    tableName: verification_sessions_test\n\npagedSingleUseKEMPreKeyStore:\n  bucket: preKeyBucket # S3 Bucket name\n  region: us-west-2    # AWS region\n\ncacheCluster: # Redis server configuration for cache cluster\n  type: local\n\npubsub: # Redis server configuration for pubsub cluster\n  type: local\n\npushSchedulerCluster: # Redis server configuration for push scheduler cluster\n  type: local\n\nrateLimitersCluster: # Redis server configuration for rate limiters cluster\n  type: local\n\ndirectoryV2:\n  client: # Configuration for interfacing with Contact Discovery Service v2 cluster\n    userAuthenticationTokenSharedSecret: secret://directoryV2.client.userAuthenticationTokenSharedSecret\n    userIdTokenSharedSecret: secret://directoryV2.client.userIdTokenSharedSecret\n\nsvr2:\n  uri: svr2.example.com\n  userAuthenticationTokenSharedSecret: secret://svr2.userAuthenticationTokenSharedSecret\n  userIdTokenSharedSecret: secret://svr2.userIdTokenSharedSecret\n  svrCaCertificates:\n    # this is a randomly generated test certificate\n    - |\n      -----BEGIN CERTIFICATE-----\n      MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL\n      BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\n      GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz\n      MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\n      HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\n      AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD\n      2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8\n      ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP\n      ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq\n      llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH\n      c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud\n      DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0\n      SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw\n      ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h\n      rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP\n      UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ\n      6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58\n      O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd\n      9Kxq0DY7RCEpdHMCKcOL\n      -----END CERTIFICATE-----\n\nsvrb:\n  uri: svrb.example.com\n  userAuthenticationTokenSharedSecret: secret://svrb.userAuthenticationTokenSharedSecret\n  userIdTokenSharedSecret: secret://svrb.userIdTokenSharedSecret\n  svrCaCertificates:\n    # this is a randomly generated test certificate\n    - |\n      -----BEGIN CERTIFICATE-----\n      MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL\n      BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\n      GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz\n      MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\n      HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\n      AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD\n      2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8\n      ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP\n      ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq\n      llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH\n      c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud\n      DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0\n      SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw\n      ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h\n      rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP\n      UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ\n      6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58\n      O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd\n      9Kxq0DY7RCEpdHMCKcOL\n      -----END CERTIFICATE-----\n\nmessageCache: # Redis server configuration for message store cache\n  persistDelayMinutes: 1\n  cluster:\n    type: local\n\ngcpAttachments: # GCP Storage configuration\n  domain: example.com\n  email: user@example.cocm\n  maxSizeInBytes: 1024\n  pathPrefix:\n  rsaSigningKey: secret://gcpAttachments.rsaSigningKey\n\ntus:\n  uploadUri: https://example.org/upload\n  userAuthenticationTokenSharedSecret: secret://tus.userAuthenticationTokenSharedSecret\n\napn: # Apple Push Notifications configuration\n  sandbox: true\n  bundleId: com.example.textsecuregcm\n  keyId: secret://apn.keyId\n  teamId: secret://apn.teamId\n  signingKey: secret://apn.signingKey\n\nfcm: # FCM configuration\n  credentials: secret://fcm.credentials\n\ncdn:\n  bucket: cdn        # S3 Bucket name\n  credentials:\n    accessKeyId: secret://cdn.accessKey\n    secretAccessKey: secret://cdn.accessSecret\n  region: us-west-2  # AWS region\n\ncdn3StorageManager:\n  baseUri: https://storage-manager.example.com\n  clientId: example\n  clientSecret: secret://cdn3StorageManager.clientSecret\n  sourceSchemes:\n    2: gcs\n    3: r2\n\nopenTelemetry:\n  enabled: false\n  environment: dev\n  shutdownWaitDuration: PT0S\n  url: http://127.0.0.1:4318/\n\nunidentifiedDelivery:\n  certificate: CikI14bfmgcSIQWEfA0sN1I082XmYJVRh6NzWg92E9FgnTpqTYxTrqpaIhJA4LnrrN/Dqign95JLaXeE0cJeRMoF3UM+GjjcY4LrJzDUGcqaJQsb6dWpRj5h79Z4F3epG4PJe4RNAUIG4oKXhQ==\n  privateKey: secret://unidentifiedDelivery.privateKey\n  expiresDays: 7\n  embedSigner: true\n\nshortCode:\n  baseUrl: https://example.com/shortcodes/\n\nstorageService:\n  uri: storage.example.com\n  userAuthenticationTokenSharedSecret: secret://storageService.userAuthenticationTokenSharedSecret\n  storageCaCertificates:\n    - |\n      -----BEGIN CERTIFICATE-----\n      MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL\n      BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\n      GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz\n      MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\n      HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\n      AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD\n      2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8\n      ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP\n      ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq\n      llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH\n      c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud\n      DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0\n      SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw\n      ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h\n      rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP\n      UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ\n      6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58\n      O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd\n      9Kxq0DY7RCEpdHMCKcOL\n      -----END CERTIFICATE-----\n\nzkConfig:\n  serverPublic: AAp8oB0D4EV2q7hSue3Kxzh1Vc88/nmLuRR9G3EefC0+CMcxJFQwDMgjFvFBKx3o6m9gJLevYiKcm/NxXX9WtnFMDHgDgfqHxbCi2rm20SgoHnuoph6XArmEOX6a1xLJVxgDtgfm1IbcyyqROXYxe9v2RvMUAnjbLI/fm0rXXhldjszlVR/wRpybX90RUjFyL/2Achttf3IC/ShWKkB6mWXwuFCcNfzeCCQ+w7cNnDbWscBcrhuou7HZvbt16/YdCXLyp+WdwS8ZkelpITvyK2hsPvf4oxaRLQfVRYXUMX55xpapbKH6PthuOzMVRkf+I3Xz3/bNjiQSlQkmAXlgB1YujgABYnJ6yJXQKP2mR4UJ3UYoGroYoafWycDa+vUYYozaUmzFjsBYWpYE+HyPJlJ2QaFTrpVqxX7NXsSbg8t35IvfWfZME9YBZ2eErDunwkaE4iDQhHl5IXAhbHDrr2QaJ68YIkn7lJSgFDKGFB2kb6BvDUGzcpI/CTHQi6WlCqQidQLJWDFFdlYjrUCQM2vvJtgyGrSc89jdXTFjM31aqmtcPWgWL0qv+RmK/BC392Nsu8WoSJcAE4yhccQuRSemtolgwewnjasoOFBNOPh4+pX55SwhyTVgtwl+NTNVNFydxGp9Me8ogRWElzwA9BFtNAgQtlfgIyZRTetFqLkYmIBDxwMcpizDKES5lPhV2uJJuzcMq/06mVQz2OrXgglWk01uN8U59pfNFpTZhcGQv+MHjwEAudq5eLpt3aFrdxJ7D26Fwl5j215SJ0yZo7vmSEML1vf7FaGh0IL57bRpCvdebB5WapSChUX+PPvCXohVjGrERFvQpeET6pydGGlEKYLWuWa3zFGmPvJJYZ/QfcmIP9zyhqzQT/7a7RIqFA==\n  serverSecret: secret://zkConfig-libsignal-0.42.serverSecret\n\ncallingZkConfig:\n  serverSecret: secret://callingZkConfig.serverSecret\n\nbackupsZkConfig:\n  serverSecret: secret://backupsZkConfig.serverSecret\n\ndynamicConfig:\n  type: static\n  object: |\n    captcha:\n      scoreFloor: 1.0\n\nremoteConfig:\n  globalConfig: # keys and values that are given to clients on GET /v1/config\n    EXAMPLE_KEY: VALUE\n\npaymentsService:\n  userAuthenticationTokenSharedSecret: secret://paymentsService.userAuthenticationTokenSharedSecret\n  paymentCurrencies:\n    # list of symbols for supported currencies\n    - MOB\n  externalClients:\n    type: stub\n\nbadges:\n  badges:\n    - id: TEST\n      category: other\n      sprites: # exactly 6\n        - sprite-1.png\n        - sprite-2.png\n        - sprite-3.png\n        - sprite-4.png\n        - sprite-5.png\n        - sprite-6.png\n      svg: example.svg\n      svgs:\n        - light: example-light.svg\n          dark: example-dark.svg\n  badgeIdsEnabledForAll:\n    - TEST\n  receiptLevels:\n    '1': TEST\n\nsubscription: # configuration for Stripe subscriptions\n  badgeExpiration: P30D\n  badgeGracePeriod: P15D\n  backupExpiration: P30D\n  backupGracePeriod: P15D\n  backupFreeTierMediaDuration: P30D\n  backupLevels:\n    201:\n      playProductId: EXAMPLE\n      mediaTtl: P30D\n      prices: {}\n  levels:\n    500:\n      badge: TEST\n      prices:\n        # list of ISO 4217 currency codes and amounts for the given badge level\n        xts:\n          amount: '10'\n          processorIds:\n            STRIPE: price_example   # stripe Price ID\n            BRAINTREE: plan_example # braintree Plan ID\n\noneTimeDonations:\n  sepaMaximumEuros: '10000'\n  boost:\n    level: 1\n    expiration: P90D\n    badge: TEST\n  gift:\n    level: 10\n    expiration: P90D\n    badge: TEST\n  currencies:\n    # ISO 4217 currency codes and amounts in those currencies\n    xts:\n      minimum: '0.5'\n      gift: '2'\n      boosts:\n        - '1'\n        - '2'\n        - '4'\n        - '8'\n        - '20'\n        - '40'\n\nregistrationService:\n  type: stub\n  collationKeySalt: secret://registrationService.collationKeySalt\n  registrationCaCertificate: |\n    -----BEGIN CERTIFICATE-----\n    MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL\n    BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\n    GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz\n    MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\n    HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\n    AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD\n    2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8\n    ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP\n    ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq\n    llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH\n    c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud\n    DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0\n    SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw\n    ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h\n    rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP\n    UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ\n    6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58\n    O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd\n    9Kxq0DY7RCEpdHMCKcOL\n    -----END CERTIFICATE-----\n\nkeyTransparencyService:\n  host: kt.example.com\n  port: 443\n  tlsCertificate: |\n    -----BEGIN CERTIFICATE-----\n    MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL\n    BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\n    GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz\n    MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\n    HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\n    AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD\n    2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8\n    ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP\n    ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq\n    llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH\n    c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud\n    DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0\n    SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw\n    ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h\n    rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP\n    UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ\n    6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58\n    O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd\n    9Kxq0DY7RCEpdHMCKcOL\n    -----END CERTIFICATE-----\n  clientCertificate: |\n    -----BEGIN CERTIFICATE-----\n    MIIDazCCAlOgAwIBAgIUW5lcNWkuynRVc8Rq5pO6mHQBuZAwDQYJKoZIhvcNAQEL\n    BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM\n    GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAzMjUwMzE4MTNaFw0yOTAz\n    MjQwMzE4MTNaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw\n    HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB\n    AQUAA4IBDwAwggEKAoIBAQCfH4Um+fv2r4KudhD37/UXp8duRLTmp4XvpBTpDHpD\n    2HF8p2yThVKlJnMkP/9Ey1Rb0vhxO7DCltLdW8IYcxJuHoyMvyhGUEtxxkOZbrk8\n    ciUR9jTZ37x7vXRGj/RxcdlS6iD0MeF0D/LAkImt4T/kiKwDbENrVEnYWJmipCKP\n    ribxWky7HqxDCoYMQr0zatxB3A9mx5stH+H3kbw3CZcm+ugF9ZIKDEVHb0lf28gq\n    llmD120q/vs9YV3rzVL7sBGDqf6olkulvHQJKElZg2rdcHWFcngSlU2BjR04oyuH\n    c/SSiLSB3YB0tdFGta5uorXyV1y7RElPeBfOfvEjsG3TAgMBAAGjUzBRMB0GA1Ud\n    DgQWBBQX+xlgSWWbDjv0SrJ+h67xauJ80zAfBgNVHSMEGDAWgBQX+xlgSWWbDjv0\n    SrJ+h67xauJ80zAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAw\n    ZG2MCCjscn6h/QOoJU+IDfa68OqLq0I37gMnLMde4yEhAmm//miePIq4Uz9GRJ+h\n    rAmdEnspKgyQ93PjF7Xpk/JdJA4B1bIrsOl/cSwqx2sFhRt8Kt1DHGlGWXqOaHRP\n    UkZ86MyRL3sXly6WkxEYxZJeQaOzMy2XmQh7grzrlTBuSI+0xf7vsRRDipxr6LVQ\n    6qGWyGODLLc2JD1IXj/1HpRVT2LoGGlKMuyxACQAm4oak1vvJ9mGxgfd9AU+eo58\n    O/esB2Eaf+QqMPELdFSZQfG2jvp+3WQTZK8fDKHyLr076G3UetEMy867F6fzTSZd\n    9Kxq0DY7RCEpdHMCKcOL\n    -----END CERTIFICATE-----\n  clientPrivateKey: secret://keyTransparencyService.clientPrivateKey\n\nturn:\n  cloudflare:\n    apiToken: secret://turn.cloudflare.apiToken\n    endpoint: https://rtc.live.cloudflare.com/v1/turn/keys/LMNOP/credentials/generate\n    requestedCredentialTtl: PT24H\n    clientCredentialTtl: PT12H\n    urls:\n      - turn:turn.example.com:80\n    urlsWithIps:\n      - turn:%s\n      - turn:%s:80?transport=tcp\n      - turns:%s:443?transport=tcp\n    hostname: turn.cloudflare.example.com\n    numHttpClients: 1\n\nlinkDevice:\n  secret: secret://linkDevice.secret\n\nexternalRequestFilter:\n  grpcMethods:\n    - com.example.grpc.ExampleService/exampleMethod\n  paths:\n    - /example\n  permittedInternalRanges:\n    - 127.0.0.0/8\n\nidlePrimaryDeviceReminder:\n  minIdleDuration: P30D\n\ngrpc:\n  port: 50051\n\ngrpcAllowList:\n  enableAll: true\n\nasnTable:\n  s3Region: a-region\n  s3Bucket: a-bucket\n  objectKey: asn.tsv\n  maxSize: 100000\n  refreshInterval: PT10S\n\ncallQualitySurvey:\n  pubSubPublisher:\n    type: stub\n\nhlrLookup:\n  apiKey: secret://hlrLookup.apiKey\n  apiSecret: secret://hlrLookup.apiSecret\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_duplicate_device.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"destinationRegistrationId\" : 222,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n    {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 2,\n        \"destinationRegistrationId\" : 333,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n    {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"destinationRegistrationId\" : 222,\n        \"content\" : \"Zm9vYmFyego\"\n    }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_extra_device.json",
    "content": "{\n  \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n    {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 3,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n      {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 4,\n        \"content\" : \"Zm9vYmFyego\"\n      }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_multi_device.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"destinationRegistrationId\" : 222,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n    {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 2,\n        \"destinationRegistrationId\" : 333,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n      {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 3,\n        \"destinationRegistrationId\" : 444,\n        \"content\" : \"Zm9vYmFyego\"\n      }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_multi_device_not_urgent.json",
    "content": "{\n  \"urgent\": false,\n  \"timestamp\": 1234,\n  \"messages\": [\n    {\n      \"type\": 1,\n      \"destinationDeviceId\": 1,\n      \"destinationRegistrationId\": 222,\n      \"content\": \"Zm9vYmFyego\"\n    },\n    {\n      \"type\": 1,\n      \"destinationDeviceId\": 2,\n      \"destinationRegistrationId\": 333,\n      \"content\": \"Zm9vYmFyego\"\n    },\n    {\n      \"type\": 1,\n      \"destinationDeviceId\": 3,\n      \"destinationRegistrationId\": 444,\n      \"content\": \"Zm9vYmFyego\"\n    }\n  ]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_multi_device_pni.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"destinationRegistrationId\" : 2222,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n    {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 2,\n        \"destinationRegistrationId\" : 3333,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n      {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 3,\n        \"destinationRegistrationId\" : 4444,\n        \"content\" : \"Zm9vYmFyego\"\n      }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_null_message_in_list.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [ {\n      \"type\" : 1,\n      \"destinationDeviceId\" : 1,\n      \"content\" : \"Zm9vYmFyego\"\n    }, null ]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_registration_id.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"destinationRegistrationId\" : 222,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n    {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 2,\n        \"destinationRegistrationId\" : 999,\n        \"content\" : \"Zm9vYmFyego\"\n    },\n      {\n        \"type\" : 1,\n        \"destinationDeviceId\" : 3,\n        \"destinationRegistrationId\" : 444,\n        \"content\" : \"Zm9vYmFyego\"\n      }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_single_device.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 1,\n        \"destinationDeviceId\" : 1,\n        \"content\" : \"Zm9vYmFyego\"\n    }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_single_device_bad_type.json",
    "content": "{\n    \"timestamp\" : 1234,\n    \"messages\" : [{\n        \"type\" : 7,\n        \"destinationDeviceId\" : 1,\n        \"content\" : \"Zm9vYmFyego\"\n    }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_single_device_not_urgent.json",
    "content": "{\n  \"urgent\": false,\n  \"timestamp\": 1234,\n  \"messages\": [\n    {\n      \"type\": 1,\n      \"destinationDeviceId\": 1,\n      \"content\": \"Zm9vYmFyego\"\n    }\n  ]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_single_device_server_receipt_type.json",
    "content": "{\n  \"timestamp\": 1234,\n  \"messages\": [\n    {\n      \"type\": 5,\n      \"destinationDeviceId\": 1,\n      \"content\": \"Zm9vYmFyego\"\n    }\n  ]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/current_message_sync.json",
    "content": "{\n  \"timestamp\" : 1234,\n  \"messages\" : [{\n    \"type\" : 1,\n    \"destinationDeviceId\" : 2,\n    \"content\" : \"Zm9vYmFyego\"\n  }]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/fixer.res.json",
    "content": "{\n  \"success\": true,\n  \"timestamp\": 1519296206,\n  \"base\": \"EUR\",\n  \"date\": \"2021-08-01\",\n  \"rates\": {\n    \"AUD\": 1.566015,\n    \"CAD\": 1.560132,\n    \"CHF\": 1.154727,\n    \"CNY\": 7.827874,\n    \"GBP\": 0.882047,\n    \"JPY\": 132.360679,\n    \"USD\": 1.23396\n  }\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/mismatched_registration_id.json",
    "content": "{\n    \"staleDevices\" : [2]\n}"
  },
  {
    "path": "service/src/test/resources/fixtures/missing_device_response.json",
    "content": "{\n    \"missingDevices\" : [2, 3],\n    \"extraDevices\" : []\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/missing_device_response2.json",
    "content": "{\n    \"missingDevices\" : [2],\n    \"extraDevices\" : [4]\n}\n"
  },
  {
    "path": "service/src/test/resources/fixtures/prekey_v2.json",
    "content": "{\n    \"keyId\" : 1234,\n    \"publicKey\" : \"BQ+NbroQtVKyFaCSfqzSw8Wy72Ff22RSa5ERKTv5DIk2\"\n}"
  },
  {
    "path": "service/src/test/resources/logback-test.xml",
    "content": "<configuration>\n  <import class=\"org.whispersystems.textsecuregcm.util.logging.UnknownKeepaliveOptionFilter\"/>\n\n  <appender name=\"STDOUT\" class=\"ch.qos.logback.core.ConsoleAppender\">\n    <filter class=\"UnknownKeepaliveOptionFilter\"/>\n\n    <encoder>\n      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>\n    </encoder>\n  </appender>\n\n  <root level=\"warn\">\n    <appender-ref ref=\"STDOUT\"/>\n  </root>\n\n  <!-- uncomment and combine with .log() in StepVerifier for more insight into reactor operations -->\n  <!--  <logger name=\"reactor\" level=\"debug\"/> -->\n</configuration>\n"
  },
  {
    "path": "service/src/test/resources/org/whispersystems/textsecuregcm/asn/ip2asn-test.tsv",
    "content": "1.3.0.0\t1.4.127.255\t0\tNone\tNot routed\n1.0.0.0\t1.0.0.255\t13335\tUS\tCLOUDFLARENET\n2.16.112.0\t2.16.113.255\t16625\tUS\tAKAMAI-AS\n2001:4:113::\t2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff\t0\tNone\tNot routed\n2001:200:e00::\t2001:200:eff:ffff:ffff:ffff:ffff:ffff\t4690\tJP\tWIDE-SFO WIDE Project\n2001:420:4488::\t2001:420:c0ef:ffff:ffff:ffff:ffff:ffff\t109\tUS\tCISCOSYSTEMS\n"
  },
  {
    "path": "service/src/test/resources/org/whispersystems/textsecuregcm/storage/AccountsManagerTest-testJsonRoundTripSerialization.json",
    "content": "{\n  \"number\": \"+14152222222\",\n  \"usernameHash\": null,\n  \"reservedUsernameHash\": null,\n  \"usernameLinkHandle\": null,\n  \"devices\": [\n    {\n      \"id\": 1,\n      \"name\": null,\n      \"authToken\": null,\n      \"salt\": null,\n      \"gcmId\": null,\n      \"apnId\": null,\n      \"pushTimestamp\": 0,\n      \"uninstalledFeedback\": 0,\n      \"fetchesMessages\": true,\n      \"registrationId\": 1,\n      \"signedPreKey\": {\n        \"keyId\": 1,\n        \"publicKey\": \"BerKjYSh1PdniL5bhI9kwbH/Et3mx/8CypR1TYo/+d5o\",\n        \"signature\": \"iK2yJkl0l6qe58Fy1dVo31X5sp6EiXSS5FZfa3W//E+Abylfa6ZRmM97CzTdXNu2DjgxZYF43G6HfJ49+99hgg\"\n      },\n      \"lastSeen\" : 1692748800000,\n      \"created\" : 1692718240137,\n      \"userAgent\": null,\n      \"capabilities\": null,\n      \"pniRegistrationId\": 2,\n      \"pniSignedPreKey\": {\n        \"keyId\": 2,\n        \"publicKey\": \"BXcLL1VLft3tUnr/5UIW5Q0Hsr8/Az0CGJ+EuFqiXCYc\",\n        \"signature\": \"YoKqyeOCHC0E9mqMoc1UPeyuLqGc8nvY+3D3YX5HC1bhxS48ZLYo40xql51A2CpIBqVmA+2gV3PXCV1Yhq4UAQ\"\n      }\n    }\n  ],\n  \"identityKey\": \"BaMV4k/+jSn7jmHnRAPvfc7XBZOcayrhOmHFbGJwMyFS\",\n  \"badges\": [],\n  \"registrationLock\": null,\n  \"registrationLockSalt\": null,\n  \"version\": 0,\n  \"pni\": \"22222222-2222-2222-2222-222222222222\",\n  \"eu\": null,\n  \"pniIdentityKey\": \"Bc0Myhpf2D+iCgUfIs+UStgffR/VGQRfP9mwFHI4U2x4\",\n  \"cpv\": null,\n  \"uak\": \"p5uWNi83Muqsd16PLi0/tQ==\",\n  \"uua\": true,\n  \"inCds\": true\n}\n"
  },
  {
    "path": "websocket-resources/pom.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\"\n  xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n  xsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd\">\n  <parent>\n    <artifactId>TextSecureServer</artifactId>\n    <groupId>org.whispersystems.textsecure</groupId>\n    <version>JGITVER</version>\n  </parent>\n  <modelVersion>4.0.0</modelVersion>\n  <artifactId>websocket-resources</artifactId>\n\n  <dependencies>\n    <dependency>\n      <groupId>org.eclipse.jetty.websocket</groupId>\n      <artifactId>websocket-jetty-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.eclipse.jetty.websocket</groupId>\n      <artifactId>websocket-jetty-server</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.eclipse.jetty.websocket</groupId>\n      <artifactId>websocket-servlet</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-auth</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-jersey</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>io.dropwizard</groupId>\n      <artifactId>dropwizard-logging</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>jakarta.inject</groupId>\n      <artifactId>jakarta.inject-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>jakarta.validation</groupId>\n      <artifactId>jakarta.validation-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>jakarta.ws.rs</groupId>\n      <artifactId>jakarta.ws.rs-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.glassfish.jersey.core</groupId>\n      <artifactId>jersey-common</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.glassfish.jersey.core</groupId>\n      <artifactId>jersey-server</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.core</groupId>\n      <artifactId>jackson-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.core</groupId>\n      <artifactId>jackson-annotations</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.fasterxml.jackson.core</groupId>\n      <artifactId>jackson-databind</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>ch.qos.logback</groupId>\n      <artifactId>logback-core</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>ch.qos.logback</groupId>\n      <artifactId>logback-classic</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.google.protobuf</groupId>\n      <artifactId>protobuf-java</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.google.guava</groupId>\n      <artifactId>guava</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>org.slf4j</groupId>\n      <artifactId>slf4j-api</artifactId>\n    </dependency>\n    <dependency>\n      <groupId>com.google.code.findbugs</groupId>\n      <artifactId>jsr305</artifactId>\n    </dependency>\n\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n      <scope>test</scope>\n    </dependency>\n    <dependency>\n      <groupId>org.mockito</groupId>\n      <artifactId>mockito-core</artifactId>\n      <scope>test</scope>\n    </dependency>\n  </dependencies>\n\n  <build>\n    <plugins>\n      <plugin>\n        <groupId>org.apache.maven.plugins</groupId>\n        <artifactId>maven-surefire-plugin</artifactId>\n        <configuration>\n          <argLine>-javaagent:${org.mockito:mockito-core:jar}</argLine>\n        </configuration>\n      </plugin>\n    </plugins>\n  </build>\n\n</project>\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketClient.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket;\n\nimport com.google.common.net.HttpHeaders;\nimport java.net.SocketAddress;\nimport java.nio.ByteBuffer;\nimport java.security.SecureRandom;\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport org.eclipse.jetty.websocket.api.RemoteEndpoint;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.WriteCallback;\nimport org.eclipse.jetty.websocket.api.exceptions.WebSocketException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.messages.WebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic class WebSocketClient {\n\n  private static final Logger logger = LoggerFactory.getLogger(WebSocketClient.class);\n  private static final SecureRandom SECURE_RANDOM = new SecureRandom();\n\n  private final Session session;\n  private final RemoteEndpoint remoteEndpoint;\n  private final WebSocketMessageFactory messageFactory;\n  private final Map<Long, CompletableFuture<WebSocketResponseMessage>> pendingRequestMapper;\n  private final Instant created;\n\n  public WebSocketClient(Session session, RemoteEndpoint remoteEndpoint, WebSocketMessageFactory messageFactory,\n                         Map<Long, CompletableFuture<WebSocketResponseMessage>> pendingRequestMapper) {\n    this.session = session;\n    this.remoteEndpoint = remoteEndpoint;\n    this.messageFactory = messageFactory;\n    this.pendingRequestMapper = pendingRequestMapper;\n    this.created = Instant.now();\n  }\n\n  public CompletableFuture<WebSocketResponseMessage> sendRequest(String verb, String path,\n                                                                 List<String> headers,\n                                                                 Optional<byte[]> body)\n  {\n    final long                                        requestId = generateRequestId();\n    final CompletableFuture<WebSocketResponseMessage> future    = new CompletableFuture<>();\n\n    pendingRequestMapper.put(requestId, future);\n\n    WebSocketMessage requestMessage = messageFactory.createRequest(Optional.of(requestId), verb, path, headers, body);\n\n    try {\n      remoteEndpoint.sendBytes(ByteBuffer.wrap(requestMessage.toByteArray()), new WriteCallback() {\n        @Override\n        public void writeFailed(Throwable x) {\n          logger.debug(\"Write failed\", x);\n          pendingRequestMapper.remove(requestId);\n          future.completeExceptionally(x);\n        }\n      });\n    } catch (WebSocketException e) {\n      logger.debug(\"Write\", e);\n      pendingRequestMapper.remove(requestId);\n      future.completeExceptionally(e);\n    }\n\n    return future;\n  }\n\n  public String getUserAgent() {\n    return session.getUpgradeRequest().getHeader(HttpHeaders.USER_AGENT);\n  }\n\n  public Instant getCreated() {\n    return this.created;\n  }\n\n  public boolean isOpen() {\n    return session.isOpen();\n  }\n\n  public void close(final int code, final String message) {\n    session.close(code, message, new WriteCallback() {\n      @Override\n      public void writeFailed(final Throwable throwable) {\n        try {\n          session.disconnect();\n        } catch (final Exception e) {\n          logger.warn(\"Failed to disconnect session\", e);\n        }\n      }\n    });\n  }\n\n  public boolean shouldDeliverStories() {\n    String value = session.getUpgradeRequest().getHeader(WebsocketHeaders.X_SIGNAL_RECEIVE_STORIES);\n    return WebsocketHeaders.parseReceiveStoriesHeader(value);\n  }\n\n  private long generateRequestId() {\n    return Math.abs(SECURE_RANDOM.nextLong());\n  }\n\n  public SocketAddress getRemoteAddress() {\n    return session.getRemoteAddress();\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProvider.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket;\n\nimport com.google.common.annotations.VisibleForTesting;\nimport com.google.common.net.HttpHeaders;\nimport com.google.protobuf.UninitializedMessageException;\nimport jakarta.ws.rs.core.MultivaluedMap;\nimport jakarta.ws.rs.core.Response;\nimport java.io.ByteArrayInputStream;\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.net.URI;\nimport java.nio.ByteBuffer;\nimport java.security.Principal;\nimport java.time.Duration;\nimport java.util.HashMap;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ConcurrentHashMap;\nimport org.eclipse.jetty.server.Request;\nimport org.eclipse.jetty.websocket.api.RemoteEndpoint;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.WebSocketListener;\nimport org.eclipse.jetty.websocket.api.WriteCallback;\nimport org.eclipse.jetty.websocket.api.exceptions.MessageTooLargeException;\nimport org.glassfish.jersey.internal.MapPropertiesDelegate;\nimport org.glassfish.jersey.server.ApplicationHandler;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.ContainerResponse;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.websocket.logging.WebsocketRequestLog;\nimport org.whispersystems.websocket.messages.InvalidMessageException;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.messages.WebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.WebSocketRequestMessage;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\nimport org.whispersystems.websocket.session.ContextPrincipal;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\nimport org.whispersystems.websocket.setup.WebSocketConnectListener;\n\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic class WebSocketResourceProvider<T extends Principal> implements WebSocketListener {\n\n  /**\n   * A static exception instance passed to outstanding requests (via {@code completeExceptionally} in\n   * {@link #onWebSocketClose(int, String)}\n   */\n  public static final IOException CONNECTION_CLOSED_EXCEPTION = new IOException(\"Connection closed!\");\n  private static final Logger logger = LoggerFactory.getLogger(WebSocketResourceProvider.class);\n\n  private final Map<Long, CompletableFuture<WebSocketResponseMessage>> requestMap = new ConcurrentHashMap<>();\n\n  private final Optional<T> reusableAuth;\n  private final WebSocketMessageFactory messageFactory;\n  private final Optional<WebSocketConnectListener> connectListener;\n  private final ApplicationHandler jerseyHandler;\n  private final WebsocketRequestLog requestLog;\n  private final Duration idleTimeout;\n  private final String remoteAddress;\n  private final String remoteAddressPropertyName;\n  private final int localPort;\n\n  private Session session;\n  private RemoteEndpoint remoteEndpoint;\n  private WebSocketSessionContext context;\n\n  private static final Set<String> EXCLUDED_UPGRADE_REQUEST_HEADERS = Set.of(\"connection\", \"upgrade\");\n\n  public WebSocketResourceProvider(String remoteAddress,\n      String remoteAddressPropertyName,\n      int localPort,\n      ApplicationHandler jerseyHandler,\n      WebsocketRequestLog requestLog,\n      Optional<T> authenticated,\n      WebSocketMessageFactory messageFactory,\n      Optional<WebSocketConnectListener> connectListener,\n      Duration idleTimeout) {\n    this.remoteAddress = remoteAddress;\n    this.remoteAddressPropertyName = remoteAddressPropertyName;\n    this.localPort = localPort;\n    this.jerseyHandler = jerseyHandler;\n    this.requestLog = requestLog;\n    this.reusableAuth = authenticated;\n    this.messageFactory = messageFactory;\n    this.connectListener = connectListener;\n    this.idleTimeout = idleTimeout;\n  }\n\n  @Override\n  public void onWebSocketConnect(Session session) {\n    this.session = session;\n    this.remoteEndpoint = session.getRemote();\n    this.context = new WebSocketSessionContext(\n        new WebSocketClient(session, remoteEndpoint, messageFactory, requestMap));\n    this.context.setAuthenticated(reusableAuth.orElse(null));\n    this.session.setIdleTimeout(idleTimeout);\n\n    connectListener.ifPresent(listener -> listener.onWebSocketConnect(this.context));\n  }\n\n  @Override\n  public void onWebSocketError(Throwable cause) {\n    logger.debug(\"onWebSocketError\", cause);\n\n    final int closeCode;\n    final String message;\n    if (cause instanceof MessageTooLargeException) {\n      closeCode = 1009;\n      message = \"Frame too large\";\n    } else {\n      closeCode = 1011;\n      message = \"Server error\";\n    }\n\n    close(session, closeCode, message);\n  }\n\n  @Override\n  public void onWebSocketBinary(byte[] payload, int offset, int length) {\n    try {\n      WebSocketMessage webSocketMessage = messageFactory.parseMessage(payload, offset, length);\n\n      switch (webSocketMessage.getType()) {\n        case REQUEST_MESSAGE:\n          handleRequest(webSocketMessage.getRequestMessage());\n          break;\n        case RESPONSE_MESSAGE:\n          handleResponse(webSocketMessage.getResponseMessage());\n          break;\n        default:\n          close(session, 1007, \"Badly formatted\");\n          break;\n      }\n    } catch (UninitializedMessageException | InvalidMessageException e) {\n      logger.debug(\"Parsing\", e);\n      close(session, 1007, \"Badly formatted\");\n    }\n  }\n\n  @Override\n  public void onWebSocketClose(int statusCode, String reason) {\n    if (context != null) {\n      context.notifyClosed(statusCode, reason);\n\n      for (long requestId : requestMap.keySet()) {\n        CompletableFuture<WebSocketResponseMessage> outstandingRequest = requestMap.remove(requestId);\n\n        if (outstandingRequest != null) {\n          outstandingRequest.completeExceptionally(CONNECTION_CLOSED_EXCEPTION);\n        }\n      }\n    }\n  }\n\n  @Override\n  public void onWebSocketText(String message) {\n    logger.debug(\"onWebSocketText!\");\n  }\n\n  /**\n   * The property name where {@link org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider} can find an\n   * authenticated principal that lives for the lifetime of the websocket\n   */\n  public static final String REUSABLE_AUTH_PROPERTY = WebSocketResourceProvider.class.getName() + \".reusableAuth\";\n\n  /**\n   * The property name where request byte count is stored for metrics collection\n   */\n  public static final String REQUEST_LENGTH_PROPERTY = WebSocketResourceProvider.class.getName() + \".requestBytes\";\n\n  /**\n   * The property name where response byte count is stored for metrics collection\n   */\n  public static final String RESPONSE_LENGTH_PROPERTY = WebSocketResourceProvider.class.getName() + \".responseBytes\";\n\n  /**\n   * The property name where the listening port number is stored for metrics collection.\n   */\n  public static final String LISTEN_PORT_PROPERTY = WebSocketResourceProvider.class.getName() + \".listenPort\";\n\n  private void handleRequest(WebSocketRequestMessage requestMessage) {\n    ContainerRequest containerRequest = new ContainerRequest(null, URI.create(requestMessage.getPath()),\n        requestMessage.getVerb(), new WebSocketSecurityContext(new ContextPrincipal(context)),\n        new MapPropertiesDelegate(new HashMap<>()), jerseyHandler.getConfiguration());\n    containerRequest.headers(getCombinedHeaders(session.getUpgradeRequest().getHeaders(), requestMessage.getHeaders()));\n\n    final int requestBytes = requestMessage.getBody().map(body -> body.length).orElse(0);\n\n    if (requestMessage.getBody().isPresent()) {\n      containerRequest.setEntityStream(new ByteArrayInputStream(requestMessage.getBody().get()));\n    }\n\n\n    containerRequest.setProperty(remoteAddressPropertyName, remoteAddress);\n    containerRequest.setProperty(REUSABLE_AUTH_PROPERTY, reusableAuth);\n    containerRequest.setProperty(REQUEST_LENGTH_PROPERTY, requestBytes);\n    containerRequest.setProperty(LISTEN_PORT_PROPERTY, this.localPort);\n\n    ByteArrayOutputStream responseBody = new ByteArrayOutputStream();\n    CompletableFuture<ContainerResponse> responseFuture = (CompletableFuture<ContainerResponse>) jerseyHandler.apply(\n        containerRequest, responseBody);\n\n    responseFuture\n        .thenAccept(response -> {\n          try {\n            final int responseBytes = responseBody.size();\n            containerRequest.setProperty(RESPONSE_LENGTH_PROPERTY, responseBytes);\n            sendResponse(requestMessage, response, responseBody);\n          } catch (IOException e) {\n            throw new RuntimeException(e);\n          }\n          requestLog.log(remoteAddress, containerRequest, response);\n        })\n        .exceptionally(exception -> {\n          logger.warn(\"Websocket Error: \" + requestMessage.getVerb() + \" \" + requestMessage.getPath() + \"\\n\"\n              + requestMessage.getBody(), exception);\n          try {\n            containerRequest.setProperty(RESPONSE_LENGTH_PROPERTY, 0);\n            sendErrorResponse(requestMessage, Response.status(500).build());\n          } catch (IOException e) {\n            logger.warn(\"Failed to send error response\", e);\n          }\n          requestLog.log(remoteAddress, containerRequest,\n              new ContainerResponse(containerRequest, Response.status(500).build()));\n          return null;\n        });\n  }\n\n  @VisibleForTesting\n  static Map<String, List<String>> getCombinedHeaders(final Map<String, List<String>> upgradeRequestHeaders, final Map<String, String> requestMessageHeaders) {\n    final Map<String, List<String>> combinedHeaders = new HashMap<>();\n\n    upgradeRequestHeaders.entrySet().stream()\n        .filter(entry -> shouldIncludeUpgradeRequestHeader(entry.getKey()))\n        .forEach(entry -> combinedHeaders.put(entry.getKey(), entry.getValue()));\n\n    requestMessageHeaders.entrySet().stream()\n        .filter(entry -> shouldIncludeRequestMessageHeader(entry.getKey()))\n        .forEach(entry -> combinedHeaders.put(entry.getKey(), List.of(entry.getValue())));\n\n    return combinedHeaders;\n  }\n\n  @VisibleForTesting\n  static boolean shouldIncludeUpgradeRequestHeader(final String header) {\n    return !EXCLUDED_UPGRADE_REQUEST_HEADERS.contains(header.toLowerCase()) && !header.toLowerCase().contains(\"websocket-\");\n  }\n\n  @VisibleForTesting\n  static boolean shouldIncludeRequestMessageHeader(final String header) {\n    return !HttpHeaders.X_FORWARDED_FOR.equalsIgnoreCase(header.trim());\n  }\n\n  private void handleResponse(WebSocketResponseMessage responseMessage) {\n    CompletableFuture<WebSocketResponseMessage> future = requestMap.remove(responseMessage.getRequestId());\n\n    if (future != null) {\n      future.complete(responseMessage);\n    }\n  }\n\n  private void close(Session session, int status, String message) {\n    session.close(status, message);\n  }\n\n  private void sendResponse(WebSocketRequestMessage requestMessage, ContainerResponse response,\n      ByteArrayOutputStream responseBody) throws IOException {\n    if (requestMessage.hasRequestId()) {\n      byte[] body = responseBody.toByteArray();\n      response.getHeaders().putIfAbsent(HttpHeaders.CONTENT_LENGTH, List.of(body.length));\n      if (body.length <= 0) {\n        body = null;\n      }\n\n      byte[] responseBytes = messageFactory.createResponse(requestMessage.getRequestId(),\n              response.getStatus(),\n              response.getStatusInfo().getReasonPhrase(),\n              getHeaderList(response.getStringHeaders()),\n              Optional.ofNullable(body))\n          .toByteArray();\n\n      remoteEndpoint.sendBytes(ByteBuffer.wrap(responseBytes), WriteCallback.NOOP);\n    }\n  }\n\n  private void sendErrorResponse(WebSocketRequestMessage requestMessage, Response error) throws IOException {\n    if (requestMessage.hasRequestId()) {\n      WebSocketMessage response = messageFactory.createResponse(requestMessage.getRequestId(),\n          error.getStatus(),\n          \"Error response\",\n          getHeaderList(error.getStringHeaders()),\n          Optional.empty());\n\n      remoteEndpoint.sendBytes(ByteBuffer.wrap(response.toByteArray()), WriteCallback.NOOP);\n    }\n  }\n\n\n  @VisibleForTesting\n  WebSocketSessionContext getContext() {\n    return context;\n  }\n\n  @VisibleForTesting\n  static List<String> getHeaderList(final MultivaluedMap<String, String> headerMap) {\n    final List<String> headers = new LinkedList<>();\n\n    if (headerMap != null) {\n      for (String key : headerMap.keySet()) {\n        headers.add(key + \":\" + headerMap.getFirst(key));\n      }\n    }\n\n    return headers;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketResourceProviderFactory.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket;\n\nimport static java.util.Optional.ofNullable;\n\nimport io.dropwizard.jersey.jackson.JacksonMessageBodyProvider;\nimport jakarta.ws.rs.InternalServerErrorException;\nimport java.io.IOException;\nimport java.security.Principal;\nimport java.util.Map;\nimport java.util.Optional;\nimport org.apache.commons.lang3.StringUtils;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;\nimport org.eclipse.jetty.websocket.server.JettyWebSocketCreator;\nimport org.eclipse.jetty.websocket.server.JettyWebSocketServlet;\nimport org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory;\nimport org.glassfish.jersey.CommonProperties;\nimport org.glassfish.jersey.server.ApplicationHandler;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.websocket.auth.InvalidCredentialsException;\nimport org.whispersystems.websocket.auth.WebSocketAuthenticator;\nimport org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\n\npublic class WebSocketResourceProviderFactory<T extends Principal> extends JettyWebSocketServlet implements\n    JettyWebSocketCreator {\n\n  private static final Logger logger = LoggerFactory.getLogger(WebSocketResourceProviderFactory.class);\n\n  private final WebSocketEnvironment<T> environment;\n  private final ApplicationHandler jerseyApplicationHandler;\n  private final WebSocketConfiguration configuration;\n\n  private final String remoteAddressPropertyName;\n\n  public WebSocketResourceProviderFactory(WebSocketEnvironment<T> environment, Class<T> principalClass,\n      WebSocketConfiguration configuration, String remoteAddressPropertyName) {\n    this.environment = environment;\n\n    environment.jersey().register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    environment.jersey().register(new WebsocketAuthValueFactoryProvider.Binder<>(principalClass));\n    environment.jersey().register(new JacksonMessageBodyProvider(environment.getObjectMapper()));\n\n    // Jersey buffers responses (by default up to 8192 bytes) just so it can add a content length to responses. We\n    // already buffer our responses to serialize them as protos, so we can compute the content length ourselves. Setting\n    // the buffer to zero disables buffering.\n    environment.jersey().addProperties(Map.of(CommonProperties.OUTBOUND_CONTENT_LENGTH_BUFFER, 0));\n\n    this.jerseyApplicationHandler = new ApplicationHandler(environment.jersey());\n\n    this.configuration = configuration;\n    this.remoteAddressPropertyName = remoteAddressPropertyName;\n  }\n\n  @Override\n  public Object createWebSocket(final JettyServerUpgradeRequest request, final JettyServerUpgradeResponse response) {\n    try {\n      Optional<WebSocketAuthenticator<T>> authenticator = Optional.ofNullable(environment.getAuthenticator());\n\n      final Optional<T> authenticated = authenticator.isPresent()\n          ? authenticator.get().authenticate(request)\n          : Optional.empty();\n\n      Optional.ofNullable(environment.getAuthenticatedWebSocketUpgradeFilter())\n          .ifPresent(filter -> filter.handleAuthentication(authenticated, request, response));\n\n\n      return new WebSocketResourceProvider<>(getRemoteAddress(request),\n          remoteAddressPropertyName,\n          request.getHttpServletRequest().getLocalPort(),\n          this.jerseyApplicationHandler,\n          this.environment.getRequestLog(),\n          authenticated,\n          this.environment.getMessageFactory(),\n          ofNullable(this.environment.getConnectListener()),\n          this.environment.getIdleTimeout());\n    } catch (final InvalidCredentialsException e) {\n      try {\n        response.sendForbidden(\"Unauthorized\");\n      } catch (final IOException ignored) {\n      }\n      return null;\n    } catch (final Exception e) {\n      // Authentication may fail for non-incorrect-credential reasons (e.g. we couldn't read from the account database).\n      // If that happens, we don't want to incorrectly tell clients that they provided bad credentials.\n      logger.warn(\"Authentication failure\", e);\n      try {\n        response.sendError(500, \"Failure\");\n      } catch (final IOException ignored) {\n      }\n      return null;\n    }\n  }\n\n  @Override\n  public void configure(JettyWebSocketServletFactory factory) {\n    factory.setCreator(this);\n    factory.setMaxBinaryMessageSize(configuration.getMaxBinaryMessageSize());\n    factory.setMaxTextMessageSize(configuration.getMaxTextMessageSize());\n  }\n\n  private String getRemoteAddress(JettyServerUpgradeRequest request) {\n    final String remoteAddress = (String) request.getHttpServletRequest().getAttribute(remoteAddressPropertyName);\n    if (StringUtils.isBlank(remoteAddress)) {\n      logger.error(\"Remote address property is not present\");\n      throw new InternalServerErrorException();\n    }\n    return remoteAddress;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/WebSocketSecurityContext.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket;\n\nimport jakarta.ws.rs.core.SecurityContext;\nimport java.security.Principal;\nimport org.whispersystems.websocket.session.ContextPrincipal;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\npublic class WebSocketSecurityContext implements SecurityContext {\n\n  private final ContextPrincipal principal;\n\n  public WebSocketSecurityContext(ContextPrincipal principal) {\n    this.principal = principal;\n  }\n\n  @Override\n  public Principal getUserPrincipal() {\n    return (Principal)principal.getContext().getAuthenticated();\n  }\n\n  @Override\n  public boolean isUserInRole(String role) {\n    return false;\n  }\n\n  @Override\n  public boolean isSecure() {\n    return principal != null;\n  }\n\n  @Override\n  public String getAuthenticationScheme() {\n    return null;\n  }\n\n  public WebSocketSessionContext getSessionContext() {\n    return principal.getContext();\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/WebsocketHeaders.java",
    "content": "package org.whispersystems.websocket;\n\n/**\n * Class containing constants and shared logic for headers used in websocket upgrade requests.\n */\npublic class WebsocketHeaders {\n  public final static String X_SIGNAL_RECEIVE_STORIES = \"X-Signal-Receive-Stories\";\n\n  public static boolean parseReceiveStoriesHeader(String s) {\n    return \"true\".equals(s);\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/auth/AuthenticatedWebSocketUpgradeFilter.java",
    "content": "/*\n * Copyright 2025 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\npackage org.whispersystems.websocket.auth;\n\nimport java.security.Principal;\nimport java.util.Optional;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;\n\npublic interface AuthenticatedWebSocketUpgradeFilter<T extends Principal> {\n\n  void handleAuthentication(@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\") Optional<T> authenticated,\n      JettyServerUpgradeRequest request,\n      JettyServerUpgradeResponse response);\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/auth/InvalidCredentialsException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.auth;\n\npublic class InvalidCredentialsException extends Exception {\n\n  public InvalidCredentialsException() {\n    super(null, null, true, false);\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebSocketAuthenticator.java",
    "content": "/*\n * Copyright 2013 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.auth;\n\nimport java.security.Principal;\nimport java.util.Optional;\nimport org.eclipse.jetty.websocket.api.UpgradeRequest;\n\npublic interface WebSocketAuthenticator<T extends Principal> {\n\n  /**\n   * Authenticates an account from credential headers provided in a WebSocket upgrade request.\n   *\n   * @param request the request from which to extract credentials\n   *\n   * @return the authenticated principal if credentials were provided and authenticated or empty if the caller is\n   * anonymous\n   *\n   * @throws InvalidCredentialsException if credentials were provided, but could not be authenticated\n   */\n  Optional<T> authenticate(UpgradeRequest request) throws InvalidCredentialsException;\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/auth/WebsocketAuthValueFactoryProvider.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.auth;\n\nimport io.dropwizard.auth.Auth;\nimport jakarta.inject.Inject;\nimport jakarta.inject.Singleton;\nimport jakarta.ws.rs.WebApplicationException;\nimport java.lang.reflect.ParameterizedType;\nimport java.security.Principal;\nimport java.util.Optional;\nimport java.util.function.Function;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.internal.inject.AbstractBinder;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider;\nimport org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider;\nimport org.glassfish.jersey.server.model.Parameter;\nimport org.glassfish.jersey.server.spi.internal.ValueParamProvider;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.websocket.WebSocketResourceProvider;\n\n@Singleton\npublic class WebsocketAuthValueFactoryProvider<T extends Principal> extends AbstractValueParamProvider  {\n  private static final Logger logger = LoggerFactory.getLogger(WebsocketAuthValueFactoryProvider.class);\n\n  private final Class<T> principalClass;\n\n  @Inject\n  public WebsocketAuthValueFactoryProvider(MultivaluedParameterExtractorProvider mpep, WebsocketPrincipalClassProvider<T> principalClassProvider) {\n    super(() -> mpep, Parameter.Source.UNKNOWN);\n    this.principalClass = principalClassProvider.clazz;\n  }\n\n  @Nullable\n  @Override\n  protected Function<ContainerRequest, ?> createValueProvider(Parameter parameter) {\n    if (!parameter.isAnnotationPresent(Auth.class)) {\n      return null;\n    }\n\n    final boolean readOnly = true;\n\n    if (parameter.getRawType() == Optional.class\n        && ParameterizedType.class.isAssignableFrom(parameter.getType().getClass())\n        && principalClass == ((ParameterizedType) parameter.getType()).getActualTypeArguments()[0]) {\n      return this::createPrincipal;\n    } else if (principalClass.equals(parameter.getRawType())) {\n      return containerRequest ->\n          createPrincipal(containerRequest)\n              .orElseThrow(() -> new WebApplicationException(\"Authenticated resource\", 401));\n    } else {\n      throw new IllegalStateException(\"Can't inject unassignable principal: \" + principalClass + \" for parameter: \" + parameter);\n    }\n  }\n\n  private Optional<? extends Principal> createPrincipal(final ContainerRequest request) {\n    final Object obj = request.getProperty(WebSocketResourceProvider.REUSABLE_AUTH_PROPERTY);\n    if (!(obj instanceof Optional<?>)) {\n      logger.warn(\"Unexpected reusable auth property type {} : {}\", obj.getClass(), obj);\n      return Optional.empty();\n    }\n\n    //noinspection unchecked\n    return (Optional<T>) obj;\n  }\n\n  @Singleton\n  static class WebsocketPrincipalClassProvider<T extends Principal> {\n\n    private final Class<T> clazz;\n\n    WebsocketPrincipalClassProvider(Class<T> clazz) {\n      this.clazz = clazz;\n    }\n  }\n\n  /**\n   * Injection binder for {@link io.dropwizard.auth.AuthValueFactoryProvider}.\n   *\n   * @param <T> the type of the principal\n   */\n  public static class Binder<T extends Principal> extends AbstractBinder {\n\n    private final Class<T> principalClass;\n\n    public Binder(Class<T> principalClass) {\n      this.principalClass = principalClass;\n    }\n\n    @Override\n    protected void configure() {\n      bind(new WebsocketPrincipalClassProvider<>(principalClass)).to(WebsocketPrincipalClassProvider.class);\n      bind(WebsocketAuthValueFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class);\n    }\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/configuration/WebSocketConfiguration.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.configuration;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Max;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotNull;\nimport org.whispersystems.websocket.logging.WebsocketRequestLoggerFactory;\n\npublic class WebSocketConfiguration {\n\n  @Valid\n  @NotNull\n  @JsonProperty\n  private WebsocketRequestLoggerFactory requestLog = new WebsocketRequestLoggerFactory();\n\n  @Min(512 * 1024)       // 512 KB\n  @Max(10 * 1024 * 1024) // 10 MB\n  @JsonProperty\n  private int maxBinaryMessageSize = 512 * 1024;\n\n  @Min(512 * 1024)       // 512 KB\n  @Max(10 * 1024 * 1024) // 10 MB\n  @JsonProperty\n  private int maxTextMessageSize = 512 * 1024;\n\n  @Valid\n  @JsonProperty\n  private boolean disablePerMessageDeflate = false;\n\n  @Valid\n  @JsonProperty\n  private boolean disableCrossMessageOutgoingCompression = false;\n\n  public WebsocketRequestLoggerFactory getRequestLog() {\n    return requestLog;\n  }\n\n  public int getMaxBinaryMessageSize() {\n    return maxBinaryMessageSize;\n  }\n\n  public int getMaxTextMessageSize() {\n    return maxTextMessageSize;\n  }\n\n  public boolean isDisablePerMessageDeflate() {\n    return disablePerMessageDeflate;\n  }\n\n  public boolean isDisableCrossMessageOutgoingCompression() {\n    return disableCrossMessageOutgoingCompression;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/AsyncWebsocketEventAppenderFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging;\n\nimport ch.qos.logback.core.AsyncAppenderBase;\nimport io.dropwizard.logging.common.async.AsyncAppenderFactory;\n\npublic class AsyncWebsocketEventAppenderFactory implements AsyncAppenderFactory<WebsocketEvent> {\n  @Override\n  public AsyncAppenderBase<WebsocketEvent> build() {\n    return new AsyncAppenderBase<WebsocketEvent>() {\n      @Override\n      protected void preprocess(WebsocketEvent event) {\n        event.prepareForDeferredProcessing();\n      }\n    };\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketEvent.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging;\n\nimport ch.qos.logback.core.spi.DeferredProcessingAware;\nimport jakarta.ws.rs.core.MultivaluedMap;\nimport java.util.List;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.ContainerResponse;\n\npublic class WebsocketEvent implements DeferredProcessingAware {\n\n  public static final int    SENTINEL = -1;\n  public static final String NA       = \"-\";\n\n  private final String            remoteAddress;\n  private final ContainerRequest  request;\n  private final ContainerResponse response;\n  private final long              timestamp;\n\n  public WebsocketEvent(String remoteAddress, ContainerRequest jerseyRequest, ContainerResponse jettyResponse) {\n    this.timestamp     = System.currentTimeMillis();\n    this.remoteAddress = remoteAddress;\n    this.request       = jerseyRequest;\n    this.response      = jettyResponse;\n  }\n\n  public String getRemoteHost() {\n    return remoteAddress;\n  }\n\n  public long getTimestamp() {\n    return timestamp;\n  }\n\n  @Override\n  public void prepareForDeferredProcessing() {\n\n  }\n\n  public String getMethod() {\n    return request.getMethod();\n  }\n\n  public String getPath() {\n    return request.getBaseUri().getPath() + request.getPath(false);\n  }\n\n  public String getProtocol() {\n    return \"WS\";\n  }\n\n  public int getStatusCode() {\n    return response.getStatus();\n  }\n\n  public long getContentLength() {\n    return response.getLength();\n  }\n\n  public String getRequestHeader(String key) {\n    List<String> values = request.getRequestHeader(key);\n\n    if (values == null) return NA;\n    else                return values.stream().findFirst().orElse(NA);\n  }\n\n  public MultivaluedMap<String, String> getRequestHeaderMap() {\n    return request.getRequestHeaders();\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLog.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging;\n\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.ContainerResponse;\n\nimport ch.qos.logback.core.Appender;\nimport ch.qos.logback.core.filter.Filter;\nimport ch.qos.logback.core.spi.AppenderAttachableImpl;\nimport ch.qos.logback.core.spi.FilterAttachableImpl;\nimport ch.qos.logback.core.spi.FilterReply;\n\npublic class WebsocketRequestLog {\n\n  private final AppenderAttachableImpl<WebsocketEvent> aai = new AppenderAttachableImpl<>();\n  private final FilterAttachableImpl<WebsocketEvent>   fai = new FilterAttachableImpl<>();\n\n  public WebsocketRequestLog() {\n  }\n\n  public void log(String remoteAddress, ContainerRequest jerseyRequest, ContainerResponse jettyResponse) {\n    WebsocketEvent event = new WebsocketEvent(remoteAddress, jerseyRequest, jettyResponse);\n\n    if (getFilterChainDecision(event) == FilterReply.DENY) {\n      return;\n    }\n\n    aai.appendLoopOnAppenders(event);\n  }\n\n\n  public void addAppender(Appender<WebsocketEvent> newAppender) {\n    aai.addAppender(newAppender);\n  }\n\n  public void addFilter(Filter<WebsocketEvent> newFilter) {\n      fai.addFilter(newFilter);\n    }\n\n  public FilterReply getFilterChainDecision(WebsocketEvent event) {\n    return fai.getFilterChainDecision(event);\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/WebsocketRequestLoggerFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging;\n\nimport ch.qos.logback.classic.Logger;\nimport ch.qos.logback.classic.LoggerContext;\nimport com.google.common.annotations.VisibleForTesting;\nimport io.dropwizard.logging.common.AppenderFactory;\nimport io.dropwizard.logging.common.ConsoleAppenderFactory;\nimport io.dropwizard.logging.common.async.AsyncAppenderFactory;\nimport io.dropwizard.logging.common.filter.LevelFilterFactory;\nimport io.dropwizard.logging.common.filter.NullLevelFilterFactory;\nimport io.dropwizard.logging.common.layout.LayoutFactory;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.NotNull;\nimport java.util.Collections;\nimport java.util.List;\nimport org.slf4j.LoggerFactory;\nimport org.whispersystems.websocket.logging.layout.WebsocketEventLayoutFactory;\n\npublic class WebsocketRequestLoggerFactory {\n\n  @VisibleForTesting\n  @Valid\n  @NotNull\n  public List<AppenderFactory<WebsocketEvent>> appenders = Collections.singletonList(new ConsoleAppenderFactory<>());\n\n  public WebsocketRequestLog build(String name) {\n    final Logger logger = (Logger) LoggerFactory.getLogger(\"websocket.request\");\n    logger.setAdditive(false);\n\n    final LoggerContext                        context              = logger.getLoggerContext();\n    final WebsocketRequestLog                  requestLog           = new WebsocketRequestLog();\n    final LevelFilterFactory<WebsocketEvent>   levelFilterFactory   = new NullLevelFilterFactory<>();\n    final AsyncAppenderFactory<WebsocketEvent> asyncAppenderFactory = new AsyncWebsocketEventAppenderFactory();\n    final LayoutFactory<WebsocketEvent>        layoutFactory        = new WebsocketEventLayoutFactory();\n\n    for (AppenderFactory<WebsocketEvent> output : appenders) {\n      requestLog.addAppender(output.build(context, name, layoutFactory, levelFilterFactory, asyncAppenderFactory));\n    }\n\n    return requestLog;\n  }\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayout.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout;\n\nimport ch.qos.logback.core.Context;\nimport ch.qos.logback.core.pattern.DynamicConverter;\nimport ch.qos.logback.core.pattern.PatternLayoutBase;\nimport org.whispersystems.websocket.logging.WebsocketEvent;\nimport org.whispersystems.websocket.logging.layout.converters.ContentLengthConverter;\nimport org.whispersystems.websocket.logging.layout.converters.DateConverter;\nimport org.whispersystems.websocket.logging.layout.converters.EnsureLineSeparation;\nimport org.whispersystems.websocket.logging.layout.converters.NAConverter;\nimport org.whispersystems.websocket.logging.layout.converters.RemoteHostConverter;\nimport org.whispersystems.websocket.logging.layout.converters.RequestHeaderConverter;\nimport org.whispersystems.websocket.logging.layout.converters.RequestUrlConverter;\nimport org.whispersystems.websocket.logging.layout.converters.StatusCodeConverter;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.function.Supplier;\n\npublic class WebsocketEventLayout extends PatternLayoutBase<WebsocketEvent> {\n\n  // Provides a mapping of conversion words to converter classes;\n  // required for extending PatternLayoutBase.\n  // See https://logback.qos.ch/manual/layouts.html#ClassicPatternLayout for more details.\n  private static final Map<String, Supplier<DynamicConverter>> DEFAULT_CONVERTER_SUPPLIERS = Map.of(\n      \"h\", RemoteHostConverter::new,\n      \"l\", NAConverter::new,\n      \"u\", NAConverter::new,\n      \"t\", DateConverter::new,\n      \"r\", RequestUrlConverter::new,\n      \"s\", StatusCodeConverter::new,\n      \"b\", ContentLengthConverter::new,\n      \"i\", RequestHeaderConverter::new\n  );\n\n  // Provided for backwards compatibility\n  private static final Map<String, String> DEFAULT_CONVERTERS = new HashMap<>() {{\n    put(\"h\", RemoteHostConverter.class.getName());\n    put(\"l\", NAConverter.class.getName());\n    put(\"u\", NAConverter.class.getName());\n    put(\"t\", DateConverter.class.getName());\n    put(\"r\", RequestUrlConverter.class.getName());\n    put(\"s\", StatusCodeConverter.class.getName());\n    put(\"b\", ContentLengthConverter.class.getName());\n    put(\"i\", RequestHeaderConverter.class.getName());\n  }};\n\n  public static final String CLF_PATTERN = \"%h %l %u [%t] \\\"%r\\\" %s %b\";\n  public static final String CLF_PATTERN_NAME = \"common\";\n  public static final String CLF_PATTERN_NAME_2 = \"clf\";\n  public static final String COMBINED_PATTERN = \"%h %l %u [%t] \\\"%r\\\" %s %b \\\"%i{Referer}\\\" \\\"%i{User-Agent}\\\"\";\n  public static final String COMBINED_PATTERN_NAME = \"combined\";\n  public static final String HEADER_PREFIX = \"#logback.access pattern: \";\n\n  public WebsocketEventLayout(Context context) {\n    setOutputPatternAsHeader(false);\n    setPattern(COMBINED_PATTERN);\n    setContext(context);\n\n    this.postCompileProcessor = new EnsureLineSeparation();\n  }\n\n  @Override\n  protected Map<String, Supplier<DynamicConverter>> getDefaultConverterSupplierMap() {\n    return DEFAULT_CONVERTER_SUPPLIERS;\n  }\n\n  @Override\n  public Map<String, String> getDefaultConverterMap() {\n    return DEFAULT_CONVERTERS;\n  }\n\n  @Override\n  public String doLayout(WebsocketEvent event) {\n    if (!isStarted()) {\n      return null;\n    }\n\n    return writeLoopOnConverters(event);\n  }\n\n  @Override\n  public void start() {\n    if (getPattern().equalsIgnoreCase(CLF_PATTERN_NAME) || getPattern().equalsIgnoreCase(CLF_PATTERN_NAME_2)) {\n      setPattern(CLF_PATTERN);\n    } else if (getPattern().equalsIgnoreCase(COMBINED_PATTERN_NAME)) {\n      setPattern(COMBINED_PATTERN);\n    }\n\n    super.start();\n  }\n\n  @Override\n  protected String getPresentationHeaderPrefix() {\n    return HEADER_PREFIX;\n  }\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/WebsocketEventLayoutFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout;\n\nimport ch.qos.logback.classic.LoggerContext;\nimport ch.qos.logback.core.pattern.PatternLayoutBase;\nimport io.dropwizard.logging.common.layout.LayoutFactory;\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\nimport java.util.TimeZone;\n\npublic class WebsocketEventLayoutFactory implements LayoutFactory<WebsocketEvent> {\n  @Override\n  public PatternLayoutBase<WebsocketEvent> build(LoggerContext context, TimeZone timeZone) {\n    return new WebsocketEventLayout(context);\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/ContentLengthConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\npublic class ContentLengthConverter extends WebSocketEventConverter {\n  @Override\n  public String convert(WebsocketEvent event) {\n    if (event.getContentLength() == WebsocketEvent.SENTINEL) {\n      return WebsocketEvent.NA;\n    } else {\n      return Long.toString(event.getContentLength());\n    }\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/DateConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport ch.qos.logback.core.CoreConstants;\nimport ch.qos.logback.core.util.CachingDateFormatter;\nimport java.time.ZoneId;\nimport java.util.List;\nimport java.util.Optional;\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\npublic class DateConverter extends WebSocketEventConverter {\n\n  private CachingDateFormatter cachingDateFormatter = null;\n\n  @Override\n  public void start() {\n\n    String datePattern = getFirstOption();\n    if (datePattern == null) {\n      datePattern = CoreConstants.CLF_DATE_PATTERN;\n    }\n\n    if (datePattern.equals(CoreConstants.ISO8601_STR)) {\n      datePattern = CoreConstants.ISO8601_PATTERN;\n    }\n\n    try {\n      List<String> optionList = getOptionList();\n\n      Optional<ZoneId> timeZone = Optional.empty();\n      // if the option list contains a TZ option, then set it.\n      if (optionList != null && optionList.size() > 1) {\n        timeZone = Optional.of(ZoneId.of((String) optionList.get(1)));\n      }\n\n      cachingDateFormatter = new CachingDateFormatter(datePattern, timeZone.orElse(null));\n      // maximumCacheValidity = CachedDateFormat.getMaximumCacheValidity(pattern);\n    } catch (IllegalArgumentException e) {\n      addWarn(\"Could not instantiate SimpleDateFormat with pattern \" + datePattern, e);\n      addWarn(\"Defaulting to  \" + CoreConstants.CLF_DATE_PATTERN);\n      cachingDateFormatter = new CachingDateFormatter(CoreConstants.CLF_DATE_PATTERN);\n    }\n\n\n  }\n\n  @Override\n  public String convert(WebsocketEvent websocketEvent) {\n    long timestamp = websocketEvent.getTimestamp();\n    return cachingDateFormatter.format(timestamp);\n  }\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/EnsureLineSeparation.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\nimport ch.qos.logback.core.Context;\nimport ch.qos.logback.core.pattern.Converter;\nimport ch.qos.logback.core.pattern.ConverterUtil;\nimport ch.qos.logback.core.pattern.PostCompileProcessor;\n\npublic class EnsureLineSeparation implements PostCompileProcessor<WebsocketEvent> {\n\n  /**\n   * Add a line separator converter so that access event appears on a separate\n   * line.\n   */\n  @Override\n  public void process(Context context, Converter<WebsocketEvent> head) {\n    if (head == null)\n      throw new IllegalArgumentException(\"Empty converter chain\");\n\n    // if head != null, then tail != null as well\n    Converter<WebsocketEvent> tail             = ConverterUtil.findTail(head);\n    Converter<WebsocketEvent> newLineConverter = new LineSeparatorConverter();\n\n    if (!(tail instanceof LineSeparatorConverter)) {\n      tail.setNext(newLineConverter);\n    }\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/LineSeparatorConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\nimport ch.qos.logback.core.CoreConstants;\n\npublic class LineSeparatorConverter extends WebSocketEventConverter {\n  public LineSeparatorConverter() {\n  }\n\n  public String convert(WebsocketEvent event) {\n    return CoreConstants.LINE_SEPARATOR;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/NAConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\npublic class NAConverter extends WebSocketEventConverter {\n  @Override\n  public String convert(WebsocketEvent event) {\n    return WebsocketEvent.NA;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RemoteHostConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\npublic class RemoteHostConverter extends WebSocketEventConverter {\n  @Override\n  public String convert(WebsocketEvent event) {\n    return event.getRemoteHost();\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestHeaderConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\nimport ch.qos.logback.core.util.OptionHelper;\n\npublic class RequestHeaderConverter extends WebSocketEventConverter {\n\n  private String key;\n\n  @Override\n  public void start() {\n    key = getFirstOption();\n    if (OptionHelper.isEmpty(key)) {\n      addWarn(\"Missing key for the requested header. Defaulting to all keys.\");\n      key = null;\n    }\n    super.start();\n  }\n\n  @Override\n  public String convert(WebsocketEvent websocketEvent) {\n    if (!isStarted()) {\n      return \"INACTIVE_HEADER_CONV\";\n    }\n\n    if (key != null) {\n      return websocketEvent.getRequestHeader(key);\n    } else {\n      return websocketEvent.getRequestHeaderMap().toString();\n    }\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/RequestUrlConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\npublic class RequestUrlConverter extends WebSocketEventConverter {\n  @Override\n  public String convert(WebsocketEvent event) {\n    return\n        event.getMethod()                  +\n        WebSocketEventConverter.SPACE_CHAR +\n        event.getPath()                    +\n        WebSocketEventConverter.SPACE_CHAR +\n        event.getProtocol();\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/StatusCodeConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\npublic class StatusCodeConverter extends WebSocketEventConverter {\n  @Override\n  public String convert(WebsocketEvent event) {\n    if (event.getStatusCode() == WebsocketEvent.SENTINEL) {\n      return WebsocketEvent.NA;\n    } else {\n      return Integer.toString(event.getStatusCode());\n    }\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/logging/layout/converters/WebSocketEventConverter.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging.layout.converters;\n\nimport org.whispersystems.websocket.logging.WebsocketEvent;\n\nimport ch.qos.logback.core.Context;\nimport ch.qos.logback.core.pattern.DynamicConverter;\nimport ch.qos.logback.core.spi.ContextAware;\nimport ch.qos.logback.core.spi.ContextAwareBase;\nimport ch.qos.logback.core.status.Status;\n\npublic abstract class WebSocketEventConverter extends DynamicConverter<WebsocketEvent> implements  ContextAware {\n\n    public final static char SPACE_CHAR = ' ';\n    public final static char QUESTION_CHAR = '?';\n\n    ContextAwareBase cab = new ContextAwareBase();\n\n    @Override\n    public void setContext(Context context) {\n      cab.setContext(context);\n    }\n\n    @Override\n    public Context getContext() {\n      return cab.getContext();\n    }\n\n    @Override\n    public void addStatus(Status status) {\n      cab.addStatus(status);\n    }\n\n    @Override\n    public void addInfo(String msg) {\n      cab.addInfo(msg);\n    }\n\n    @Override\n    public void addInfo(String msg, Throwable ex) {\n      cab.addInfo(msg, ex);\n    }\n\n    @Override\n    public void addWarn(String msg) {\n      cab.addWarn(msg);\n    }\n\n    @Override\n    public void addWarn(String msg, Throwable ex) {\n      cab.addWarn(msg, ex);\n    }\n\n    @Override\n    public void addError(String msg) {\n      cab.addError(msg);\n    }\n\n    @Override\n    public void addError(String msg, Throwable ex) {\n      cab.addError(msg, ex);\n    }\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/InvalidMessageException.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages;\n\npublic class InvalidMessageException extends Exception {\n  public InvalidMessageException(String s) {\n    super(s);\n  }\n\n  public InvalidMessageException(Exception e) {\n    super(e);\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages;\n\npublic interface WebSocketMessage {\n\n  public enum Type {\n    UNKNOWN_MESSAGE,\n    REQUEST_MESSAGE,\n    RESPONSE_MESSAGE\n  }\n\n  public Type                     getType();\n  public WebSocketRequestMessage  getRequestMessage();\n  public WebSocketResponseMessage getResponseMessage();\n  public byte[]                   toByteArray();\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketMessageFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages;\n\n\nimport java.util.List;\nimport java.util.Optional;\n\n@SuppressWarnings(\"OptionalUsedAsFieldOrParameterType\")\npublic interface WebSocketMessageFactory {\n\n  public WebSocketMessage parseMessage(byte[] serialized, int offset, int len)\n      throws InvalidMessageException;\n\n  public WebSocketMessage createRequest(Optional<Long> requestId,\n                                        String verb, String path,\n                                        List<String> headers,\n                                        Optional<byte[]> body);\n\n  public WebSocketMessage createResponse(long requestId, int status, String message,\n                                         List<String> headers,\n                                         Optional<byte[]> body);\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketRequestMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages;\n\nimport java.util.Map;\nimport java.util.Optional;\n\npublic interface WebSocketRequestMessage {\n\n  public String             getVerb();\n  public String             getPath();\n  public Map<String,String> getHeaders();\n  public Optional<byte[]> getBody();\n  public long               getRequestId();\n  public boolean            hasRequestId();\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/WebSocketResponseMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages;\n\n\nimport java.util.Map;\nimport java.util.Optional;\n\npublic interface WebSocketResponseMessage {\n  public long               getRequestId();\n  public int                getStatus();\n  public String             getMessage();\n  public Map<String,String> getHeaders();\n  public Optional<byte[]> getBody();\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages.protobuf;\n\nimport com.google.protobuf.ByteString;\nimport com.google.protobuf.InvalidProtocolBufferException;\nimport org.whispersystems.websocket.messages.InvalidMessageException;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.messages.WebSocketRequestMessage;\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\n\npublic class ProtobufWebSocketMessage implements WebSocketMessage {\n\n  private final SubProtocol.WebSocketMessage message;\n\n  ProtobufWebSocketMessage(byte[] buffer, int offset, int length) throws InvalidMessageException {\n    try {\n      this.message = SubProtocol.WebSocketMessage.parseFrom(ByteString.copyFrom(buffer, offset, length));\n\n      if (getType() == Type.REQUEST_MESSAGE) {\n        if (!message.getRequest().hasVerb() || !message.getRequest().hasPath()) {\n          throw new InvalidMessageException(\"Missing required request attributes!\");\n        }\n      } else if (getType() == Type.RESPONSE_MESSAGE) {\n        if (!message.getResponse().hasId() || !message.getResponse().hasStatus() || !message.getResponse().hasMessage()) {\n          throw new InvalidMessageException(\"Missing required response attributes!\");\n        }\n      }\n    } catch (InvalidProtocolBufferException e) {\n      throw new InvalidMessageException(e);\n    }\n  }\n\n  ProtobufWebSocketMessage(SubProtocol.WebSocketMessage message) {\n    this.message = message;\n  }\n\n  @Override\n  public Type getType() {\n    if (message.getType().getNumber() == SubProtocol.WebSocketMessage.Type.REQUEST_VALUE &&\n        message.hasRequest())\n    {\n      return Type.REQUEST_MESSAGE;\n    } else if (message.getType().getNumber() == SubProtocol.WebSocketMessage.Type.RESPONSE_VALUE &&\n               message.hasResponse())\n    {\n      return Type.RESPONSE_MESSAGE;\n    } else {\n      return Type.UNKNOWN_MESSAGE;\n    }\n  }\n\n  @Override\n  public WebSocketRequestMessage getRequestMessage() {\n    return new ProtobufWebSocketRequestMessage(message.getRequest());\n  }\n\n  @Override\n  public WebSocketResponseMessage getResponseMessage() {\n    return new ProtobufWebSocketResponseMessage(message.getResponse());\n  }\n\n  @Override\n  public byte[] toByteArray() {\n    return message.toByteArray();\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketMessageFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages.protobuf;\n\nimport com.google.protobuf.ByteString;\nimport org.whispersystems.websocket.messages.InvalidMessageException;\nimport org.whispersystems.websocket.messages.WebSocketMessage;\nimport org.whispersystems.websocket.messages.WebSocketMessageFactory;\n\nimport java.util.List;\nimport java.util.Optional;\n\npublic class ProtobufWebSocketMessageFactory implements WebSocketMessageFactory {\n\n  @Override\n  public WebSocketMessage parseMessage(byte[] serialized, int offset, int len)\n      throws InvalidMessageException\n  {\n    return new ProtobufWebSocketMessage(serialized, offset, len);\n  }\n\n  @Override\n  public WebSocketMessage createRequest(Optional<Long> requestId,\n                                        String verb, String path,\n                                        List<String> headers,\n                                        Optional<byte[]> body)\n  {\n    SubProtocol.WebSocketRequestMessage.Builder requestMessage =\n        SubProtocol.WebSocketRequestMessage.newBuilder()\n                                           .setVerb(verb)\n                                           .setPath(path);\n\n    if (requestId.isPresent()) {\n      requestMessage.setId(requestId.get());\n    }\n\n    if (body.isPresent()) {\n      requestMessage.setBody(ByteString.copyFrom(body.get()));\n    }\n\n    if (headers != null) {\n      requestMessage.addAllHeaders(headers);\n    }\n\n    SubProtocol.WebSocketMessage message\n        = SubProtocol.WebSocketMessage.newBuilder()\n                                      .setType(SubProtocol.WebSocketMessage.Type.REQUEST)\n                                      .setRequest(requestMessage)\n                                      .build();\n\n    return new ProtobufWebSocketMessage(message);\n  }\n\n  @Override\n  public WebSocketMessage createResponse(long requestId, int status, String messageString, List<String> headers, Optional<byte[]> body) {\n    SubProtocol.WebSocketResponseMessage.Builder responseMessage =\n        SubProtocol.WebSocketResponseMessage.newBuilder()\n                                            .setId(requestId)\n                                            .setStatus(status)\n                                            .setMessage(messageString);\n\n    if (body.isPresent()) {\n      responseMessage.setBody(ByteString.copyFrom(body.get()));\n    }\n\n    if (headers != null) {\n      responseMessage.addAllHeaders(headers);\n    }\n\n    SubProtocol.WebSocketMessage message =\n        SubProtocol.WebSocketMessage.newBuilder()\n                                    .setType(SubProtocol.WebSocketMessage.Type.RESPONSE)\n                                    .setResponse(responseMessage)\n                                    .build();\n\n    return new ProtobufWebSocketMessage(message);\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketRequestMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages.protobuf;\n\nimport org.whispersystems.websocket.messages.WebSocketRequestMessage;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Optional;\n\npublic class ProtobufWebSocketRequestMessage implements WebSocketRequestMessage {\n\n  private final SubProtocol.WebSocketRequestMessage message;\n\n  ProtobufWebSocketRequestMessage(SubProtocol.WebSocketRequestMessage message) {\n    this.message = message;\n  }\n\n  @Override\n  public String getVerb() {\n    return message.getVerb();\n  }\n\n  @Override\n  public String getPath() {\n    return message.getPath();\n  }\n\n  @Override\n  public Optional<byte[]> getBody() {\n    if (message.hasBody()) {\n      return Optional.of(message.getBody().toByteArray());\n    } else {\n      return Optional.empty();\n    }\n  }\n\n  @Override\n  public long getRequestId() {\n    return message.getId();\n  }\n\n  @Override\n  public boolean hasRequestId() {\n    return message.hasId();\n  }\n\n  @Override\n  public Map<String, String> getHeaders() {\n    Map<String, String> results = new HashMap<>();\n\n    for (String header : message.getHeadersList()) {\n      String[] tokenized = header.split(\":\");\n\n      if (tokenized.length == 2 && tokenized[0] != null && tokenized[1] != null) {\n        results.put(tokenized[0].trim().toLowerCase(), tokenized[1].trim());\n      }\n    }\n\n    return results;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/messages/protobuf/ProtobufWebSocketResponseMessage.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.messages.protobuf;\n\nimport org.whispersystems.websocket.messages.WebSocketResponseMessage;\n\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.Optional;\n\npublic class ProtobufWebSocketResponseMessage implements WebSocketResponseMessage {\n\n  private final SubProtocol.WebSocketResponseMessage message;\n\n  public ProtobufWebSocketResponseMessage(SubProtocol.WebSocketResponseMessage message) {\n    this.message = message;\n  }\n\n  @Override\n  public long getRequestId() {\n    return message.getId();\n  }\n\n  @Override\n  public int getStatus() {\n    return message.getStatus();\n  }\n\n  @Override\n  public String getMessage() {\n    return message.getMessage();\n  }\n\n  @Override\n  public Optional<byte[]> getBody() {\n    if (message.hasBody()) {\n      return Optional.of(message.getBody().toByteArray());\n    } else {\n      return Optional.empty();\n    }\n  }\n\n  @Override\n  public Map<String, String> getHeaders() {\n    Map<String, String> results = new HashMap<>();\n\n    for (String header : message.getHeadersList()) {\n      String[] tokenized = header.split(\":\");\n\n      if (tokenized.length == 2 && tokenized[0] != null && tokenized[1] != null) {\n        results.put(tokenized[0].trim().toLowerCase(), tokenized[1].trim());\n      }\n    }\n\n    return results;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/session/ContextPrincipal.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.session;\n\nimport java.security.Principal;\n\npublic class ContextPrincipal implements Principal {\n\n  private final WebSocketSessionContext context;\n\n  public ContextPrincipal(WebSocketSessionContext context) {\n    this.context = context;\n  }\n\n  @Override\n  public boolean equals(Object another) {\n    return another instanceof ContextPrincipal &&\n           context.equals(((ContextPrincipal) another).context);\n  }\n\n  @Override\n  public String toString() {\n    return super.toString();\n  }\n\n  @Override\n  public int hashCode() {\n    return context.hashCode();\n  }\n\n  @Override\n  public String getName() {\n    return \"WebSocketSessionContext\";\n  }\n\n  public WebSocketSessionContext getContext() {\n    return context;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSession.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.session;\n\nimport java.lang.annotation.ElementType;\nimport java.lang.annotation.Retention;\nimport java.lang.annotation.RetentionPolicy;\nimport java.lang.annotation.Target;\n\n@Retention(RetentionPolicy.RUNTIME)\n@Target({ ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD })\npublic @interface WebSocketSession {\n}\n\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContainerRequestValueFactory.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.session;\n\nimport jakarta.ws.rs.core.SecurityContext;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.whispersystems.websocket.WebSocketSecurityContext;\n\npublic class WebSocketSessionContainerRequestValueFactory {\n  private final ContainerRequest request;\n\n  public WebSocketSessionContainerRequestValueFactory(ContainerRequest request) {\n    this.request = request;\n  }\n\n  public WebSocketSessionContext provide() {\n    SecurityContext securityContext = request.getSecurityContext();\n\n    if (!(securityContext instanceof WebSocketSecurityContext)) {\n      throw new IllegalStateException(\"Security context isn't for websocket!\");\n    }\n\n    WebSocketSessionContext sessionContext = ((WebSocketSecurityContext)securityContext).getSessionContext();\n\n    if (sessionContext == null) {\n      throw new IllegalStateException(\"No session context found for websocket!\");\n    }\n\n    return sessionContext;\n  }\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContext.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.session;\n\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.concurrent.locks.ReentrantLock;\nimport javax.annotation.Nullable;\nimport org.whispersystems.websocket.WebSocketClient;\n\npublic class WebSocketSessionContext {\n\n  private final List<WebSocketEventListener> closeListeners = new LinkedList<>();\n\n  private final ReentrantLock lock = new ReentrantLock();\n\n  private final WebSocketClient webSocketClient;\n\n  private Object authenticated;\n  private boolean closed;\n\n  public WebSocketSessionContext(WebSocketClient webSocketClient) {\n    this.webSocketClient = webSocketClient;\n  }\n\n  public void setAuthenticated(Object authenticated) {\n    this.authenticated = authenticated;\n  }\n\n  public <T> T getAuthenticated(Class<T> clazz) {\n    if (authenticated != null && clazz.equals(authenticated.getClass())) {\n      return clazz.cast(authenticated);\n    }\n\n    throw new IllegalArgumentException(\"No authenticated type for: \" + clazz + \", we have: \" + authenticated);\n  }\n\n  @Nullable\n  public Object getAuthenticated() {\n    return authenticated;\n  }\n\n  public void addWebsocketClosedListener(WebSocketEventListener listener) {\n    lock.lock();\n    try {\n      if (!closed)\n        this.closeListeners.add(listener);\n      else\n        listener.onWebSocketClose(this, 1000, \"Closed\");\n    } finally {\n      lock.unlock();\n    }\n  }\n\n  public WebSocketClient getClient() {\n    return webSocketClient;\n  }\n\n  public void notifyClosed(int statusCode, String reason) {\n    lock.lock();\n    try {\n      for (WebSocketEventListener listener : closeListeners) {\n        listener.onWebSocketClose(this, statusCode, reason);\n      }\n\n      closed = true;\n    } finally {\n      lock.unlock();\n    }\n  }\n\n  public interface WebSocketEventListener {\n    public void onWebSocketClose(WebSocketSessionContext context, int statusCode, String reason);\n  }\n\n\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/session/WebSocketSessionContextValueFactoryProvider.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.session;\n\nimport jakarta.inject.Inject;\nimport jakarta.inject.Singleton;\nimport java.util.function.Function;\nimport javax.annotation.Nullable;\nimport org.glassfish.jersey.internal.inject.AbstractBinder;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.internal.inject.AbstractValueParamProvider;\nimport org.glassfish.jersey.server.internal.inject.MultivaluedParameterExtractorProvider;\nimport org.glassfish.jersey.server.model.Parameter;\nimport org.glassfish.jersey.server.spi.internal.ValueParamProvider;\n\n\n@Singleton\npublic class WebSocketSessionContextValueFactoryProvider extends AbstractValueParamProvider {\n\n  @Inject\n  public WebSocketSessionContextValueFactoryProvider(MultivaluedParameterExtractorProvider mpep) {\n    super(() -> mpep, Parameter.Source.UNKNOWN);\n  }\n\n  @Nullable\n  @Override\n  protected Function<ContainerRequest, ?> createValueProvider(Parameter parameter) {\n    if (!parameter.isAnnotationPresent(WebSocketSession.class)) {\n      return null;\n    } else if (WebSocketSessionContext.class.equals(parameter.getRawType())) {\n      return request -> new WebSocketSessionContainerRequestValueFactory(request).provide();\n    } else {\n      throw new IllegalArgumentException(\"Can't inject custom type\");\n    }\n  }\n\n  public static class Binder extends AbstractBinder {\n\n    public Binder() { }\n\n    @Override\n    protected void configure() {\n      bind(WebSocketSessionContextValueFactoryProvider.class).to(ValueParamProvider.class).in(Singleton.class);\n    }\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketConnectListener.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.setup;\n\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\npublic interface WebSocketConnectListener {\n  public void onWebSocketConnect(WebSocketSessionContext context);\n}\n"
  },
  {
    "path": "websocket-resources/src/main/java/org/whispersystems/websocket/setup/WebSocketEnvironment.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.setup;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport io.dropwizard.core.setup.Environment;\nimport io.dropwizard.jersey.DropwizardResourceConfig;\nimport jakarta.validation.Validator;\nimport java.security.Principal;\nimport java.time.Duration;\nimport org.glassfish.jersey.server.ResourceConfig;\nimport org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;\nimport org.whispersystems.websocket.auth.WebSocketAuthenticator;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.logging.WebsocketRequestLog;\nimport org.whispersystems.websocket.messages.WebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;\nimport javax.annotation.Nullable;\n\npublic class WebSocketEnvironment<T extends Principal> {\n\n  private final ResourceConfig jerseyConfig;\n  private final ObjectMapper objectMapper;\n  private final Validator validator;\n  private final WebsocketRequestLog requestLog;\n  private final Duration idleTimeout;\n\n  private WebSocketAuthenticator<T> authenticator;\n  private AuthenticatedWebSocketUpgradeFilter<T> authenticatedWebSocketUpgradeFilter;\n  private WebSocketMessageFactory messageFactory;\n  private WebSocketConnectListener connectListener;\n\n  public WebSocketEnvironment(Environment environment, WebSocketConfiguration configuration) {\n    this(environment, configuration, Duration.ofMillis(60000));\n  }\n\n  public WebSocketEnvironment(Environment environment, WebSocketConfiguration configuration, Duration idleTimeout) {\n    this(environment, configuration.getRequestLog().build(\"websocket\"), idleTimeout);\n  }\n\n  public WebSocketEnvironment(Environment environment, WebsocketRequestLog requestLog, Duration idleTimeout) {\n    this.jerseyConfig = new DropwizardResourceConfig(environment.metrics());\n    this.objectMapper = environment.getObjectMapper();\n    this.validator = environment.getValidator();\n    this.requestLog = requestLog;\n    this.messageFactory = new ProtobufWebSocketMessageFactory();\n    this.idleTimeout = idleTimeout;\n  }\n\n  public ResourceConfig jersey() {\n    return jerseyConfig;\n  }\n\n  @Nullable\n  public WebSocketAuthenticator<T> getAuthenticator() {\n    return authenticator;\n  }\n\n  public void setAuthenticator(WebSocketAuthenticator<T> authenticator) {\n    this.authenticator = authenticator;\n  }\n\n  @Nullable\n  public AuthenticatedWebSocketUpgradeFilter<T> getAuthenticatedWebSocketUpgradeFilter() {\n    return authenticatedWebSocketUpgradeFilter;\n  }\n\n  public void setAuthenticatedWebSocketUpgradeFilter(final AuthenticatedWebSocketUpgradeFilter<T> authenticatedWebSocketUpgradeFilter) {\n    this.authenticatedWebSocketUpgradeFilter = authenticatedWebSocketUpgradeFilter;\n  }\n\n  public Duration getIdleTimeout() {\n    return idleTimeout;\n  }\n\n  public ObjectMapper getObjectMapper() {\n    return objectMapper;\n  }\n\n  public WebsocketRequestLog getRequestLog() {\n    return requestLog;\n  }\n\n  public Validator getValidator() {\n    return validator;\n  }\n\n  public WebSocketMessageFactory getMessageFactory() {\n    return messageFactory;\n  }\n\n  public void setMessageFactory(WebSocketMessageFactory messageFactory) {\n    this.messageFactory = messageFactory;\n  }\n\n  public WebSocketConnectListener getConnectListener() {\n    return connectListener;\n  }\n\n  public void setConnectListener(WebSocketConnectListener connectListener) {\n    this.connectListener = connectListener;\n  }\n}\n"
  },
  {
    "path": "websocket-resources/src/main/proto/WebSocketProtocol.proto",
    "content": "/*\n * Copyright 2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\n\nsyntax = \"proto2\";\n\npackage signalservice;\n\noption java_package = \"org.whispersystems.websocket.messages.protobuf\";\noption java_outer_classname = \"SubProtocol\";\n\nmessage WebSocketRequestMessage {\n  optional string verb = 1;\n  optional string path = 2;\n  optional bytes body = 3;\n  repeated string headers = 5;\n  optional uint64 id = 4;\n}\n\nmessage WebSocketResponseMessage {\n  optional uint64 id = 1;\n  optional uint32 status = 2;\n  optional string message = 3;\n  repeated string headers = 5;\n  optional bytes body = 4;\n}\n\nmessage WebSocketMessage {\n  enum Type {\n    UNKNOWN = 0;\n    REQUEST = 1;\n    RESPONSE = 2;\n  }\n\n  optional Type type = 1;\n  optional WebSocketRequestMessage request = 2;\n  optional WebSocketResponseMessage response = 3;\n}\n"
  },
  {
    "path": "websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderFactoryTest.java",
    "content": "/*\n * Copyright 2013-2021 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\nimport static org.junit.jupiter.api.Assertions.assertNull;\nimport static org.mockito.ArgumentMatchers.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.verifyNoMoreInteractions;\nimport static org.mockito.Mockito.when;\n\nimport io.dropwizard.jersey.DropwizardResourceConfig;\nimport jakarta.servlet.http.HttpServletRequest;\nimport java.io.IOException;\nimport java.security.Principal;\nimport java.util.Optional;\nimport javax.security.auth.Subject;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeRequest;\nimport org.eclipse.jetty.websocket.server.JettyServerUpgradeResponse;\nimport org.eclipse.jetty.websocket.server.JettyWebSocketServletFactory;\nimport org.glassfish.jersey.server.ResourceConfig;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.websocket.auth.InvalidCredentialsException;\nimport org.whispersystems.websocket.auth.AuthenticatedWebSocketUpgradeFilter;\nimport org.whispersystems.websocket.auth.WebSocketAuthenticator;\nimport org.whispersystems.websocket.configuration.WebSocketConfiguration;\nimport org.whispersystems.websocket.setup.WebSocketEnvironment;\n\npublic class WebSocketResourceProviderFactoryTest {\n\n  private static final String REMOTE_ADDRESS_PROPERTY_NAME = \"org.whispersystems.websocket.test.remoteAddress\";\n\n  private ResourceConfig jerseyEnvironment;\n  private WebSocketEnvironment<Account> environment;\n  private WebSocketAuthenticator<Account> authenticator;\n  private JettyServerUpgradeRequest request;\n  private JettyServerUpgradeResponse response;\n\n  @BeforeEach\n  void setup() {\n    jerseyEnvironment = new DropwizardResourceConfig();\n    //noinspection unchecked\n    environment = mock(WebSocketEnvironment.class);\n    //noinspection unchecked\n    authenticator = mock(WebSocketAuthenticator.class);\n    request = mock(JettyServerUpgradeRequest.class);\n    response = mock(JettyServerUpgradeResponse.class);\n\n  }\n\n  @Test\n  void testUnauthorized() throws InvalidCredentialsException, IOException {\n    when(environment.getAuthenticator()).thenReturn(authenticator);\n    when(authenticator.authenticate(eq(request))).thenThrow(new InvalidCredentialsException());\n    when(environment.jersey()).thenReturn(jerseyEnvironment);\n\n    WebSocketResourceProviderFactory<?> factory = new WebSocketResourceProviderFactory<>(environment, Account.class,\n        mock(WebSocketConfiguration.class), REMOTE_ADDRESS_PROPERTY_NAME);\n    Object connection = factory.createWebSocket(request, response);\n\n    assertNull(connection);\n    verify(response).sendForbidden(eq(\"Unauthorized\"));\n    verify(authenticator).authenticate(eq(request));\n  }\n\n  @Test\n  void testValidAuthorization() throws InvalidCredentialsException {\n    Account account = new Account();\n\n    when(environment.getAuthenticator()).thenReturn(authenticator);\n    when(authenticator.authenticate(eq(request)))\n        .thenReturn(Optional.of(account));\n    when(environment.jersey()).thenReturn(jerseyEnvironment);\n    final HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);\n    when(httpServletRequest.getAttribute(REMOTE_ADDRESS_PROPERTY_NAME)).thenReturn(\"127.0.0.1\");\n    when(request.getHttpServletRequest()).thenReturn(httpServletRequest);\n\n    WebSocketResourceProviderFactory<?> factory = new WebSocketResourceProviderFactory<>(environment, Account.class,\n        mock(WebSocketConfiguration.class), REMOTE_ADDRESS_PROPERTY_NAME);\n    Object connection = factory.createWebSocket(request, response);\n\n    assertNotNull(connection);\n    verifyNoMoreInteractions(response);\n    verify(authenticator).authenticate(eq(request));\n\n    ((WebSocketResourceProvider<?>) connection).onWebSocketConnect(mock(Session.class));\n\n    assertNotNull(((WebSocketResourceProvider<?>) connection).getContext().getAuthenticated());\n    assertEquals(((WebSocketResourceProvider<?>) connection).getContext().getAuthenticated(), account);\n  }\n\n  @Test\n  void testErrorAuthorization() throws InvalidCredentialsException, IOException {\n    when(environment.getAuthenticator()).thenReturn(authenticator);\n    when(authenticator.authenticate(eq(request))).thenThrow(new RuntimeException(\"database failure\"));\n    when(environment.jersey()).thenReturn(jerseyEnvironment);\n\n    WebSocketResourceProviderFactory<Account> factory = new WebSocketResourceProviderFactory<>(environment,\n        Account.class,\n        mock(WebSocketConfiguration.class),\n        REMOTE_ADDRESS_PROPERTY_NAME);\n    Object connection = factory.createWebSocket(request, response);\n\n    assertNull(connection);\n    verify(response).sendError(eq(500), eq(\"Failure\"));\n    verify(authenticator).authenticate(eq(request));\n  }\n\n  @Test\n  void testConfigure() {\n    JettyWebSocketServletFactory servletFactory = mock(JettyWebSocketServletFactory.class);\n    when(environment.jersey()).thenReturn(jerseyEnvironment);\n\n    WebSocketResourceProviderFactory<Account> factory = new WebSocketResourceProviderFactory<>(environment,\n        Account.class,\n        mock(WebSocketConfiguration.class),\n        REMOTE_ADDRESS_PROPERTY_NAME);\n    factory.configure(servletFactory);\n\n    verify(servletFactory).setCreator(eq(factory));\n  }\n\n  @Test\n  void testAuthenticatedWebSocketUpgradeFilter() throws InvalidCredentialsException {\n    final Account account = new Account();\n    final Optional<Account> reusableAuth = Optional.of(account);\n\n    when(environment.getAuthenticator()).thenReturn(authenticator);\n    when(authenticator.authenticate(eq(request))).thenReturn(reusableAuth);\n    when(environment.jersey()).thenReturn(jerseyEnvironment);\n    final HttpServletRequest httpServletRequest = mock(HttpServletRequest.class);\n    when(httpServletRequest.getAttribute(REMOTE_ADDRESS_PROPERTY_NAME)).thenReturn(\"127.0.0.1\");\n    when(request.getHttpServletRequest()).thenReturn(httpServletRequest);\n\n    final AuthenticatedWebSocketUpgradeFilter<Account> filter = mock(AuthenticatedWebSocketUpgradeFilter.class);\n    when(environment.getAuthenticatedWebSocketUpgradeFilter()).thenReturn(filter);\n\n    final WebSocketResourceProviderFactory<?> factory = new WebSocketResourceProviderFactory<>(environment, Account.class,\n        mock(WebSocketConfiguration.class), REMOTE_ADDRESS_PROPERTY_NAME);\n    assertNotNull(factory.createWebSocket(request, response));\n\n    verify(filter).handleAuthentication(reusableAuth, request, response);\n  }\n\n\n  private static class Account implements Principal {\n    @Override\n    public String getName() {\n      return null;\n    }\n\n    @Override\n    public boolean implies(Subject subject) {\n      return false;\n    }\n\n  }\n\n\n}\n"
  },
  {
    "path": "websocket-resources/src/test/java/org/whispersystems/websocket/WebSocketResourceProviderTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.any;\nimport static org.mockito.Mockito.anyInt;\nimport static org.mockito.Mockito.anyString;\nimport static org.mockito.Mockito.eq;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.never;\nimport static org.mockito.Mockito.times;\nimport static org.mockito.Mockito.verify;\nimport static org.mockito.Mockito.when;\n\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.google.common.net.HttpHeaders;\nimport com.google.protobuf.ByteString;\nimport io.dropwizard.auth.Auth;\nimport io.dropwizard.jersey.DropwizardResourceConfig;\nimport io.dropwizard.jersey.jackson.JacksonMessageBodyProvider;\nimport jakarta.validation.Valid;\nimport jakarta.validation.constraints.Min;\nimport jakarta.validation.constraints.NotEmpty;\nimport jakarta.ws.rs.Consumes;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.PUT;\nimport jakarta.ws.rs.Path;\nimport jakarta.ws.rs.PathParam;\nimport jakarta.ws.rs.Produces;\nimport jakarta.ws.rs.core.MediaType;\nimport jakarta.ws.rs.core.MultivaluedHashMap;\nimport jakarta.ws.rs.core.MultivaluedMap;\nimport jakarta.ws.rs.core.Response;\nimport jakarta.ws.rs.ext.ExceptionMapper;\nimport jakarta.ws.rs.ext.Provider;\nimport java.io.OutputStream;\nimport java.nio.ByteBuffer;\nimport java.security.Principal;\nimport java.time.Duration;\nimport java.util.Arrays;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Optional;\nimport java.util.concurrent.CompletableFuture;\nimport org.eclipse.jetty.websocket.api.CloseStatus;\nimport org.eclipse.jetty.websocket.api.RemoteEndpoint;\nimport org.eclipse.jetty.websocket.api.Session;\nimport org.eclipse.jetty.websocket.api.UpgradeRequest;\nimport org.eclipse.jetty.websocket.api.WriteCallback;\nimport org.glassfish.jersey.server.ApplicationHandler;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.ContainerResponse;\nimport org.glassfish.jersey.server.ResourceConfig;\nimport org.junit.jupiter.api.Test;\nimport org.mockito.ArgumentCaptor;\nimport org.mockito.stubbing.Answer;\nimport org.whispersystems.websocket.auth.WebsocketAuthValueFactoryProvider;\nimport org.whispersystems.websocket.logging.WebsocketRequestLog;\nimport org.whispersystems.websocket.messages.protobuf.ProtobufWebSocketMessageFactory;\nimport org.whispersystems.websocket.messages.protobuf.SubProtocol;\nimport org.whispersystems.websocket.session.WebSocketSession;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\nimport org.whispersystems.websocket.session.WebSocketSessionContextValueFactoryProvider;\nimport org.whispersystems.websocket.setup.WebSocketConnectListener;\n\nclass WebSocketResourceProviderTest {\n\n  private static final String REMOTE_ADDRESS_PROPERTY_NAME = \"org.whispersystems.weboscket.test.remoteAddress\";\n  private static final int LOCAL_PORT = 1234;\n\n  @Test\n  void testOnConnect() {\n    ApplicationHandler applicationHandler = mock(ApplicationHandler.class);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketConnectListener connectListener = mock(WebSocketConnectListener.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME,\n        LOCAL_PORT,\n        applicationHandler, requestLog,\n        Optional.of(new TestPrincipal(\"fooz\")),\n        new ProtobufWebSocketMessageFactory(),\n        Optional.of(connectListener),\n        Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n\n    provider.onWebSocketConnect(session);\n\n    verify(session, never()).close(anyInt(), anyString());\n    verify(session, never()).close();\n    verify(session, never()).close(any(CloseStatus.class));\n\n    ArgumentCaptor<WebSocketSessionContext> contextArgumentCaptor = ArgumentCaptor.forClass(\n        WebSocketSessionContext.class);\n    verify(connectListener).onWebSocketConnect(contextArgumentCaptor.capture());\n\n    assertThat(contextArgumentCaptor.getValue().getAuthenticated(TestPrincipal.class).getName()).isEqualTo(\"fooz\");\n  }\n\n  @Test\n  void testMockedRouteMessageSuccess() throws Exception {\n    ApplicationHandler applicationHandler = mock(ApplicationHandler.class);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"foo\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    ContainerResponse response = mock(ContainerResponse.class);\n    when(response.getStatus()).thenReturn(200);\n    when(response.getStatusInfo()).thenReturn(new Response.StatusType() {\n      @Override\n      public int getStatusCode() {\n        return 200;\n      }\n\n      @Override\n      public Response.Status.Family getFamily() {\n        return Response.Status.Family.SUCCESSFUL;\n      }\n\n      @Override\n      public String getReasonPhrase() {\n        return \"OK\";\n      }\n    });\n    when(response.getHeaders()).thenReturn(new MultivaluedHashMap<>());\n\n    ArgumentCaptor<OutputStream> responseOutputStream = ArgumentCaptor.forClass(OutputStream.class);\n\n    when(applicationHandler.apply(any(ContainerRequest.class), responseOutputStream.capture()))\n        .thenAnswer((Answer<CompletableFuture<ContainerResponse>>) invocation -> {\n          responseOutputStream.getValue().write(\"hello world!\".getBytes());\n          return CompletableFuture.completedFuture(response);\n        });\n\n    provider.onWebSocketConnect(session);\n\n    verify(session, never()).close(anyInt(), anyString());\n    verify(session, never()).close();\n    verify(session, never()).close(any(CloseStatus.class));\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/bar\",\n        new LinkedList<>(), Optional.of(\"hello world!\".getBytes())).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ContainerRequest> requestCaptor = ArgumentCaptor.forClass(ContainerRequest.class);\n    ArgumentCaptor<ByteBuffer> responseCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(applicationHandler).apply(requestCaptor.capture(), any(OutputStream.class));\n\n    ContainerRequest bundledRequest = requestCaptor.getValue();\n\n    assertThat(bundledRequest.getRequest().getMethod()).isEqualTo(\"GET\");\n    assertThat(bundledRequest.getBaseUri().toString()).isEqualTo(\"/\");\n    assertThat(bundledRequest.getPath(false)).isEqualTo(\"bar\");\n\n    verify(requestLog).log(eq(\"127.0.0.1\"), eq(bundledRequest), eq(response));\n    verify(remoteEndpoint).sendBytes(responseCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketMessage responseMessageContainer = SubProtocol.WebSocketMessage.parseFrom(\n        responseCaptor.getValue().array());\n    assertThat(responseMessageContainer.getResponse().getId()).isEqualTo(111L);\n    assertThat(responseMessageContainer.getResponse().getStatus()).isEqualTo(200);\n    assertThat(responseMessageContainer.getResponse().getMessage()).isEqualTo(\"OK\");\n    assertThat(responseMessageContainer.getResponse().getBody()).isEqualTo(\n        ByteString.copyFrom(\"hello world!\".getBytes()));\n  }\n\n  @Test\n  void testMockedRouteMessageFailure() throws Exception {\n    ApplicationHandler applicationHandler = mock(ApplicationHandler.class);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"foo\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    when(applicationHandler.apply(any(ContainerRequest.class), any(OutputStream.class))).thenReturn(\n        CompletableFuture.failedFuture(new IllegalStateException(\"foo\")));\n\n    provider.onWebSocketConnect(session);\n\n    verify(session, never()).close(anyInt(), anyString());\n    verify(session, never()).close();\n    verify(session, never()).close(any(CloseStatus.class));\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/bar\",\n        new LinkedList<>(), Optional.of(\"hello world!\".getBytes())).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ContainerRequest> requestCaptor = ArgumentCaptor.forClass(ContainerRequest.class);\n\n    verify(applicationHandler).apply(requestCaptor.capture(), any(OutputStream.class));\n\n    ContainerRequest bundledRequest = requestCaptor.getValue();\n\n    assertThat(bundledRequest.getRequest().getMethod()).isEqualTo(\"GET\");\n    assertThat(bundledRequest.getBaseUri().toString()).isEqualTo(\"/\");\n    assertThat(bundledRequest.getPath(false)).isEqualTo(\"bar\");\n\n    ArgumentCaptor<ByteBuffer> responseCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketMessage responseMessageContainer = SubProtocol.WebSocketMessage.parseFrom(\n        responseCaptor.getValue().array());\n    assertThat(responseMessageContainer.getResponse().getStatus()).isEqualTo(500);\n    assertThat(responseMessageContainer.getResponse().getMessage()).isEqualTo(\"Error response\");\n    assertThat(responseMessageContainer.getResponse().hasBody()).isFalse();\n  }\n\n  @Test\n  void testActualRouteMessageSuccess() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"foo\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/hello\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getMessage()).isEqualTo(\"OK\");\n    assertThat(response.getBody()).isEqualTo(ByteString.copyFrom(\"Hello!\".getBytes()));\n  }\n\n  @Test\n  void testActualRouteMessageNotFound() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"foo\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\",\n        \"/v1/test/doesntexist\", new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(404);\n    assertThat(response.getMessage()).isEqualTo(\"Not Found\");\n    assertThat(response.hasBody()).isFalse();\n  }\n\n  @Test\n  void testActualRouteMessageAuthorized() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"authorizedUserName\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/world\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getMessage()).isEqualTo(\"OK\");\n    assertThat(response.getBody().toStringUtf8()).isEqualTo(\"World: authorizedUserName\");\n  }\n\n  @Test\n  void testActualRouteMessageUnauthorized() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.empty(),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/world\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(401);\n    assertThat(response.hasBody()).isFalse();\n  }\n\n  @Test\n  void testActualRouteMessageOptionalAuthorizedPresent() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"something\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/optional\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getMessage()).isEqualTo(\"OK\");\n    assertThat(response.getBody().toStringUtf8()).isEqualTo(\"World: something\");\n  }\n\n  @Test\n  void testActualRouteMessageOptionalAuthorizedEmpty() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.empty(),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/optional\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getMessage()).isEqualTo(\"OK\");\n    assertThat(response.getBody().toStringUtf8()).isEqualTo(\"Empty world\");\n  }\n\n  @Test\n  void testActualRouteMessagePutAuthenticatedEntity() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"gooduser\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"PUT\",\n        \"/v1/test/some/testparam\", List.of(\"Content-Type: application/json\"),\n        Optional.of(new ObjectMapper().writeValueAsBytes(new TestResource.TestEntity(\"mykey\", 1001)))).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getMessage()).isEqualTo(\"OK\");\n    assertThat(response.getBody().toStringUtf8()).isEqualTo(\"gooduser:testparam:mykey:1001\");\n  }\n\n  @Test\n  void testActualRouteMessagePutAuthenticatedBadEntity() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"gooduser\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"PUT\",\n        \"/v1/test/some/testparam\", List.of(\"Content-Type: application/json\"),\n        Optional.of(new ObjectMapper().writeValueAsBytes(new TestResource.TestEntity(\"mykey\", 5)))).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(400);\n    assertThat(response.getMessage()).isEqualTo(\"Bad Request\");\n    assertThat(response.hasBody()).isFalse();\n  }\n\n  @Test\n  void testActualRouteMessageExceptionMapping() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new TestExceptionMapper());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"gooduser\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\",\n        \"/v1/test/exception/map\", List.of(\"Content-Type: application/json\"), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(1337);\n    assertThat(response.hasBody()).isFalse();\n  }\n\n  @Test\n  void testActualRouteSessionContextInjection() throws Exception {\n    ResourceConfig resourceConfig = new DropwizardResourceConfig();\n    resourceConfig.register(new TestResource());\n    resourceConfig.register(new TestExceptionMapper());\n    resourceConfig.register(new WebSocketSessionContextValueFactoryProvider.Binder());\n    resourceConfig.register(new WebsocketAuthValueFactoryProvider.Binder<>(TestPrincipal.class));\n    resourceConfig.register(new JacksonMessageBodyProvider(new ObjectMapper()));\n\n    ApplicationHandler applicationHandler = new ApplicationHandler(resourceConfig);\n    WebsocketRequestLog requestLog = mock(WebsocketRequestLog.class);\n    WebSocketResourceProvider<TestPrincipal> provider = new WebSocketResourceProvider<>(\"127.0.0.1\",\n        REMOTE_ADDRESS_PROPERTY_NAME, LOCAL_PORT, applicationHandler, requestLog, Optional.of(new TestPrincipal(\"gooduser\")),\n        new ProtobufWebSocketMessageFactory(), Optional.empty(), Duration.ofMillis(30000));\n\n    Session session = mock(Session.class);\n    RemoteEndpoint remoteEndpoint = mock(RemoteEndpoint.class);\n    UpgradeRequest request = mock(UpgradeRequest.class);\n\n    when(session.getUpgradeRequest()).thenReturn(request);\n    when(session.getRemote()).thenReturn(remoteEndpoint);\n\n    provider.onWebSocketConnect(session);\n\n    byte[] message = new ProtobufWebSocketMessageFactory().createRequest(Optional.of(111L), \"GET\", \"/v1/test/keepalive\",\n        new LinkedList<>(), Optional.empty()).toByteArray();\n\n    provider.onWebSocketBinary(message, 0, message.length);\n\n    ArgumentCaptor<ByteBuffer> requestCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint).sendBytes(requestCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketRequestMessage requestMessage = getRequest(requestCaptor);\n    assertThat(requestMessage.getVerb()).isEqualTo(\"GET\");\n    assertThat(requestMessage.getPath()).isEqualTo(\"/v1/miccheck\");\n    assertThat(requestMessage.getBody().toStringUtf8()).isEqualTo(\"smert ze smert\");\n\n    byte[] clientResponse = new ProtobufWebSocketMessageFactory().createResponse(requestMessage.getId(), 200, \"OK\",\n        new LinkedList<>(), Optional.of(\"my response\".getBytes())).toByteArray();\n\n    provider.onWebSocketBinary(clientResponse, 0, clientResponse.length);\n\n    ArgumentCaptor<ByteBuffer> responseBytesCaptor = ArgumentCaptor.forClass(ByteBuffer.class);\n\n    verify(remoteEndpoint, times(2)).sendBytes(responseBytesCaptor.capture(), any(WriteCallback.class));\n\n    SubProtocol.WebSocketResponseMessage response = getResponse(responseBytesCaptor);\n\n    assertThat(response.getId()).isEqualTo(111L);\n    assertThat(response.getStatus()).isEqualTo(200);\n    assertThat(response.getMessage()).isEqualTo(\"OK\");\n    assertThat(response.getBody().toStringUtf8()).isEqualTo(\"my response\");\n  }\n\n  @Test\n  void testGetHeaderList() {\n    assertThat(WebSocketResourceProvider.getHeaderList(new MultivaluedHashMap<>())).isEmpty();\n\n    {\n      final MultivaluedMap<String, String> headers = new MultivaluedHashMap<>();\n      headers.put(\"test\", Arrays.asList(\"a\", \"b\", \"c\"));\n\n      final List<String> headerStrings = WebSocketResourceProvider.getHeaderList(headers);\n\n      assertThat(headerStrings).hasSize(1);\n      assertThat(headerStrings).contains(\"test:a\");\n    }\n  }\n\n  @Test\n  void testShouldIncludeUpgradeRequestHeader() {\n    assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(\"Upgrade\")).isFalse();\n    assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(\"Connection\")).isFalse();\n    assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(\"Sec-WebSocket-Key\")).isFalse();\n    assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(HttpHeaders.USER_AGENT)).isTrue();\n    assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(HttpHeaders.X_FORWARDED_FOR)).isTrue();\n    assertThat(WebSocketResourceProvider.shouldIncludeUpgradeRequestHeader(\"X-Signal-Receive-Stories\")).isTrue();\n  }\n\n  @Test\n  void testShouldIncludeRequestMessageHeader() {\n    assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader(HttpHeaders.X_FORWARDED_FOR)).isFalse();\n    assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader(HttpHeaders.USER_AGENT)).isTrue();\n    assertThat(WebSocketResourceProvider.shouldIncludeRequestMessageHeader(\"X-Signal-Receive-Stories\")).isTrue();\n  }\n\n  @Test\n  void testGetCombinedHeaders() {\n    final Map<String, List<String>> upgradeRequestHeaders = Map.of(\n        \"Host\", List.of(\"server.example.com\"),\n        \"Upgrade\", List.of(\"websocket\"),\n        \"Connection\", List.of(\"Upgrade\"),\n        \"Sec-WebSocket-Key\", List.of(\"dGhlIHNhbXBsZSBub25jZQ==\"),\n        \"Sec-WebSocket-Protocol\", List.of(\"chat, superchat\"),\n        \"Sec-WebSocket-Version\", List.of(\"13\"),\n        HttpHeaders.X_FORWARDED_FOR, List.of(\"127.0.0.1\"),\n        HttpHeaders.USER_AGENT, List.of(\"Upgrade request user agent\"));\n\n    final Map<String, String> requestMessageHeaders = Map.of(\n        HttpHeaders.X_FORWARDED_FOR, \"192.168.0.1\",\n        HttpHeaders.USER_AGENT, \"Request message user agent\");\n\n    final Map<String, List<String>> expectedHeaders = Map.of(\n        \"Host\", List.of(\"server.example.com\"),\n        HttpHeaders.X_FORWARDED_FOR, List.of(\"127.0.0.1\"),\n        HttpHeaders.USER_AGENT, List.of(\"Request message user agent\"));\n\n    assertThat(WebSocketResourceProvider.getCombinedHeaders(upgradeRequestHeaders, requestMessageHeaders)).isEqualTo(\n        expectedHeaders);\n  }\n\n  private SubProtocol.WebSocketResponseMessage getResponse(ArgumentCaptor<ByteBuffer> responseCaptor)\n      throws Exception {\n    return SubProtocol.WebSocketMessage.parseFrom(responseCaptor.getValue().array()).getResponse();\n  }\n\n  private SubProtocol.WebSocketRequestMessage getRequest(ArgumentCaptor<ByteBuffer> requestCaptor)\n      throws Exception {\n    return SubProtocol.WebSocketMessage.parseFrom(requestCaptor.getValue().array()).getRequest();\n  }\n\n\n  public static class TestPrincipal implements Principal {\n\n    private final String name;\n\n    private TestPrincipal(String name) {\n      this.name = name;\n    }\n\n    @Override\n    public String getName() {\n      return name;\n    }\n  }\n\n  public static class TestException extends Exception {\n\n    public TestException(String message) {\n      super(message);\n    }\n  }\n\n  @Provider\n  public static class TestExceptionMapper implements ExceptionMapper<TestException> {\n\n    @Override\n    public Response toResponse(TestException exception) {\n      return Response.status(1337).build();\n    }\n  }\n\n  @Path(\"/v1/test\")\n  public static class TestResource {\n\n    @GET\n    @Path(\"/hello\")\n    public String testGetHello() {\n      return \"Hello!\";\n    }\n\n    @GET\n    @Path(\"/world\")\n    public String testAuthorizedHello(@Auth TestPrincipal user) {\n      if (user == null) {\n        throw new AssertionError();\n      }\n\n      return \"World: \" + user.getName();\n    }\n\n    @GET\n    @Path(\"/optional\")\n    public String testAuthorizedHello(@Auth Optional<TestPrincipal> user) {\n      if (user.isPresent()) {\n        return \"World: \" + user.get().getName();\n      } else {\n        return \"Empty world\";\n      }\n    }\n\n    @PUT\n    @Path(\"/some/{param}\")\n    @Consumes(MediaType.APPLICATION_JSON)\n    @Produces(MediaType.APPLICATION_JSON)\n    public Response testSet(@Auth TestPrincipal user, @PathParam(\"param\") String param, @Valid TestEntity entity) {\n      return Response.ok(user.name + \":\" + param + \":\" + entity.key + \":\" + entity.value).build();\n    }\n\n    @GET\n    @Path(\"/exception/map\")\n    public Response testExceptionMapping() throws TestException {\n      throw new TestException(\"I'd like to map this\");\n    }\n\n    @GET\n    @Path(\"/keepalive\")\n    public CompletableFuture<Response> testContextInjection(@WebSocketSession WebSocketSessionContext context) {\n      if (context == null) {\n        throw new AssertionError();\n      }\n\n      return context.getClient()\n          .sendRequest(\"GET\", \"/v1/miccheck\", new LinkedList<>(), Optional.of(\"smert ze smert\".getBytes()))\n          .thenApply(response -> Response.ok().entity(new String(response.getBody().get())).build());\n    }\n\n    public static class TestEntity {\n\n      public TestEntity(String key, long value) {\n        this.key = key;\n        this.value = value;\n      }\n\n      public TestEntity() {\n      }\n\n      @JsonProperty\n      @NotEmpty\n      private String key;\n\n      @JsonProperty\n      @Min(100)\n      private long value;\n\n    }\n  }\n\n}\n"
  },
  {
    "path": "websocket-resources/src/test/java/org/whispersystems/websocket/logging/WebSocketRequestLogTest.java",
    "content": "/*\n * Copyright 2013-2020 Signal Messenger, LLC\n * SPDX-License-Identifier: AGPL-3.0-only\n */\npackage org.whispersystems.websocket.logging;\n\nimport static org.assertj.core.api.Assertions.assertThat;\nimport static org.mockito.Mockito.mock;\n\nimport ch.qos.logback.classic.LoggerContext;\nimport ch.qos.logback.core.OutputStreamAppender;\nimport ch.qos.logback.core.spi.DeferredProcessingAware;\nimport com.google.common.net.HttpHeaders;\nimport io.dropwizard.logging.common.AbstractOutputStreamAppenderFactory;\nimport jakarta.ws.rs.core.Response;\nimport java.io.ByteArrayOutputStream;\nimport java.net.URI;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Locale;\nimport org.glassfish.jersey.internal.MapPropertiesDelegate;\nimport org.glassfish.jersey.server.ContainerRequest;\nimport org.glassfish.jersey.server.ContainerResponse;\nimport org.junit.jupiter.api.AfterEach;\nimport org.junit.jupiter.api.BeforeEach;\nimport org.junit.jupiter.api.Test;\nimport org.whispersystems.websocket.WebSocketSecurityContext;\nimport org.whispersystems.websocket.session.ContextPrincipal;\nimport org.whispersystems.websocket.session.WebSocketSessionContext;\n\npublic class WebSocketRequestLogTest {\n\n  private final static Locale ORIGINAL_DEFAULT_LOCALE = Locale.getDefault();\n\n  @BeforeEach\n  void beforeEachTest() {\n    Locale.setDefault(Locale.ENGLISH);\n  }\n\n  @AfterEach\n  void afterEachTest() {\n    Locale.setDefault(ORIGINAL_DEFAULT_LOCALE);\n  }\n\n  @Test\n  void testLogLineWithoutHeaders() throws InterruptedException {\n    WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class);\n\n    ListAppender<WebsocketEvent> listAppender = new ListAppender<>();\n    WebsocketRequestLoggerFactory requestLoggerFactory = new WebsocketRequestLoggerFactory();\n    requestLoggerFactory.appenders = List.of(new ListAppenderFactory<>(listAppender));\n\n    WebsocketRequestLog requestLog = requestLoggerFactory.build(\"test-logger\");\n    ContainerRequest request = new ContainerRequest(null, URI.create(\"/v1/test\"), \"GET\",\n        new WebSocketSecurityContext(new ContextPrincipal(sessionContext)), new MapPropertiesDelegate(new HashMap<>()),\n        null);\n    ContainerResponse response = new ContainerResponse(request, Response.ok(\"My response body\").build());\n\n    requestLog.log(\"123.456.789.123\", request, response);\n\n    listAppender.waitForListSize(1);\n    assertThat(listAppender.list.size()).isEqualTo(1);\n\n    String loggedLine = new String(listAppender.outputStream.toByteArray());\n    assertThat(loggedLine).matches(\n        \"123\\\\.456\\\\.789\\\\.123 \\\\- \\\\- \\\\[[0-9]{2}\\\\/[a-zA-Z]{3}\\\\/[0-9]{4}:[0-9]{2}:[0-9]{2}:[0-9]{2} (\\\\-|\\\\+)[0-9]{4}\\\\] \\\"GET \\\\/v1\\\\/test WS\\\" 200 \\\\- \\\"\\\\-\\\" \\\"\\\\-\\\"\\n\");\n  }\n\n  @Test\n  void testLogLineWithHeaders() throws InterruptedException {\n    WebSocketSessionContext sessionContext = mock(WebSocketSessionContext.class);\n\n    ListAppender<WebsocketEvent> listAppender = new ListAppender<>();\n    WebsocketRequestLoggerFactory requestLoggerFactory = new WebsocketRequestLoggerFactory();\n    requestLoggerFactory.appenders = List.of(new ListAppenderFactory<>(listAppender));\n\n    WebsocketRequestLog requestLog = requestLoggerFactory.build(\"test-logger\");\n    ContainerRequest request = new ContainerRequest(null, URI.create(\"/v1/test\"), \"GET\",\n        new WebSocketSecurityContext(new ContextPrincipal(sessionContext)), new MapPropertiesDelegate(new HashMap<>()),\n        null);\n    request.header(HttpHeaders.USER_AGENT, \"SmertZeSmert\");\n    request.header(\"Referer\", \"https://moxie.org\");\n    ContainerResponse response = new ContainerResponse(request, Response.ok(\"My response body\").build());\n\n    requestLog.log(\"123.456.789.123\", request, response);\n\n    listAppender.waitForListSize(1);\n    assertThat(listAppender.list.size()).isEqualTo(1);\n\n    String loggedLine = new String(listAppender.outputStream.toByteArray());\n    assertThat(loggedLine).matches(\n        \"123\\\\.456\\\\.789\\\\.123 \\\\- \\\\- \\\\[[0-9]{2}\\\\/[a-zA-Z]{3}\\\\/[0-9]{4}:[0-9]{2}:[0-9]{2}:[0-9]{2} (\\\\-|\\\\+)[0-9]{4}\\\\] \\\"GET \\\\/v1\\\\/test WS\\\" 200 \\\\- \\\"https://moxie.org\\\" \\\"SmertZeSmert\\\"\\n\");\n\n    System.out.println(listAppender.list.get(0));\n    System.out.println(new String(listAppender.outputStream.toByteArray()));\n  }\n\n  private static class ListAppenderFactory<T extends DeferredProcessingAware> extends\n      AbstractOutputStreamAppenderFactory<T> {\n\n    private final ListAppender<T> listAppender;\n\n    public ListAppenderFactory(ListAppender<T> listAppender) {\n      this.listAppender = listAppender;\n    }\n\n    @Override\n    protected OutputStreamAppender<T> appender(LoggerContext context) {\n      listAppender.setContext(context);\n      return listAppender;\n    }\n  }\n\n  private static class ListAppender<E> extends OutputStreamAppender<E> {\n\n    public final List<E> list = new ArrayList<E>();\n    public final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();\n\n    protected void append(E e) {\n      super.append(e);\n\n      synchronized (list) {\n        list.add(e);\n        list.notifyAll();\n      }\n    }\n\n    @Override\n    public void start() {\n      setOutputStream(outputStream);\n      super.start();\n    }\n\n    public void waitForListSize(int size) throws InterruptedException {\n      synchronized (list) {\n        while (list.size() < size) {\n          list.wait(5000);\n        }\n      }\n    }\n\n  }\n\n\n}\n"
  }
]